# Technical Documentation: sebastienros/jint

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/sebastienros/jint)

### Overview (`README.md`)

[](https://github.com/sebastienros/jint/actions/workflows/build.yml)
[](https://www.nuget.org/packages/Jint)
[](https://feedz.io/org/sebastienros/repository/jint/packages/Jint)

# Jint

Jint is a modern ECMAScript interpreter for .NET. It runs JavaScript directly in
your process without native dependencies, bytecode generation, or a separate
runtime.

Jint targets .NET Framework 4.7.2, .NET Standard 2.0/2.1, .NET 8, and .NET 10.
Start with the [documentation](https://sebastienros.github.io/jint/) or install
the core package:

```bash
dotnet add package Jint
```

## Quick start

The examples below assume `using Jint;`.

### Evaluate JavaScript


```csharp
var engine = new Engine();
var result = engine.Evaluate("40 + 2").AsNumber();
```


### Expose .NET and call JavaScript


```csharp
var engine = new Engine()
    .SetValue("log", new Action<string>(Console.WriteLine))
    .Execute("""
        function greet(name) {
            const message = `Hello, ${name}!`;
            log(message);
            return message;
        }
        """);

var greeting = engine.Invoke("greet", "Ada").AsString();
```


### Prepare code that runs repeatedly


```csharp
var script = Engine.PrepareScript(
    "items.reduce((sum, value) => sum + value, 0)",
    source: "sum.js",
    strict: true);

var engine = new Engine();
engine.SetValue("items", new[] { 1, 2, 3 });

var total = engine.Evaluate(in script).AsNumber();
```


`Prepared<Script>` and `Prepared<Module>` are thread-safe and may be shared
across engines. JavaScript objects are not: a `JsValue` that contains an object
belongs to the engine and realm that created it.

## Features

- Modern ECMAScript, modules, promises, async functions, generators, `Intl`,
  and `Temporal`
- Direct projection of .NET values, delegates, objects, collections, and types
- Synchronous and asynchronous module loaders
- Execution constraints, hardened untrusted-code defaults, and security
  diagnostics
- Profiling, statement coverage, debugging, and Chrome DevTools Protocol
  integration
- Opt-in Web APIs and Node-compatible modules
- A headless browser, command-line tool, and MCP server

Browser and Node APIs are not installed by default. Hosts explicitly grant the
capabilities each script needs.

## Used by

Projects using Jint include
[RavenDB](https://github.com/ravendb/ravendb),
[EventStoreDB](https://github.com/EventStore/EventStore),
[Orchard Core](https://github.com/OrchardCMS/OrchardCore),
[Elsa Workflows](https://github.com/elsa-workflows/elsa-core),
[Docfx](https://github.com/dotnet/docfx), and
[JavaScript Engine Switcher](https://github.com/Taritsyn/JavaScriptEngineSwitcher).

## Additional packages

`Jint` is the core package used throughout this README. Add another package
only when the application needs an optional debugging or browser surface.

| Package | Purpose |
| --- | --- |
| [`Jint.DevTools`](https://www.nuget.org/packages/Jint.DevTools) | Chrome DevTools Protocol server for debugging and profiling an engine |
| [`Jint.Browser`](https://www.nuget.org/packages/Jint.Browser) | Headless HTML, DOM, navigation, networking, storage, and content extraction |
| [`Jint.Browser.Tool`](https://www.nuget.org/packages/Jint.Browser.Tool) | `jint-browser` command-line tool |
| [`Jint.Browser.Mcp`](https://www.nuget.org/packages/Jint.Browser.Mcp) | Model Context Protocol server for browser automation |

See [Additional packages](https://sebastienros.github.io/jint/guide/choosing-a-package)
for dependencies, target frameworks, and common combinations.

## Branches and releases

Jint 5 is under development on `main` and has not been released. This branch
includes breaking changes; development packages are available from the
[preview feed](https://sebastienros.github.io/jint/guide/preview-packages).

The `4.x` branch is the maintenance lane for Jint 4.16. Changes there preserve
compatibility and focus on non-breaking correctness and conformance fixes. The
`3.x` branch receives only security and severe-correctness maintenance.

Pull requests target `main` unless they are explicit backports. See
[Branches and Releases](https://sebastienros.github.io/jint/reference/releases)
for the complete policy and
[Migrating to Jint 5](https://sebastienros.github.io/jint/guide/migrating-to-v5)
when upgrading from Jint 4.

## Standards conformance

These percentages describe the pinned test corpora in this repository, not the
entire web platform.

| Surface | Result | Measured scope |
| --- | ---: | --- |
| ECMAScript and ECMA-402 | **99.9%** (102,585 / 102,692) | Generated test262 cases from `annexB`, `built-ins`, `intl402`, `language`, and `staging`; 107 cases are skipped |
| WinterTC Minimum Common API | **79.5%** (62 / 78) | 59 members present and 3 correctly absent for Jint's global shape; the remaining 16 are WebAssembly, which Jint declines by design |
| Jint runtime Web APIs | **92.9%** (38,649 / 41,581) | Assertions in the vendored `.any.js` WPT corpus across 44 suite directories |
| Jint.Browser | **84.0%** (9,691 / 11,541) | Tests in the gated in-process browser WPT corpus |

The test262 result uses commit
[`419d3e0`](https://github.com/tc39/test262/commit/419d3e0a2273ba01a3bfcbec423f2801425b8e93).
Read the detailed scope and exclusions for
[ECMAScript](https://sebastienros.github.io/jint/reference/ecmascript),
[Web APIs](https://sebastienros.github.io/jint/reference/web-api-features), and
[Jint.Browser](https://sebastienros.github.io/jint/reference/browser-compatibility).

## Recommended usage

- Enable strict mode when compatible with the scripts you run.
- Prepare and cache scripts or modules that execute repeatedly.
- Give each engine to only one operation at a time; await async operations
  before reusing or disposing it.
- Expose narrow, purpose-built host capabilities instead of broad CLR access.
- Use a fresh engine across trust domains. Global snapshots help trusted reuse,
  but they are not an isolation boundary.
- Measure with your own scripts and host objects. Interpreter performance
  depends heavily on call frequency and script-to-host traffic.

See [performance](https://sebastienros.github.io/jint/guide/performance)
and [advanced hosting](https://sebastienros.github.io/jint/guide/advanced-hosting)
for engine lifetime, pooling, projection, and caching guidance.

## Benchmarks

The repository's current default-job BenchmarkDotNet comparison uses each
engine's recommended cached execution path:

- Jint is the fastest managed engine on 10 of 12 scripts and the fastest
  interpreter on all 12.
- Jint has the lowest managed allocation on 10 of 12 scripts.
- Script-to-host interop is 3.4x to 11.2x faster than the measured ClearScript
  lanes.
- The startup-shaped minimal script runs in about 1.1 us with a prepared Jint
  script, compared with about 380 us for the compiled V8 lane.

Native V8 remains faster on long, compute-heavy loops. Managed allocation
figures also cannot include V8's native heap. Read the
[engine comparison and methodology](https://sebastienros.github.io/jint/guide/engine-comparison)
before comparing engines; absolute numbers from different benchmark sessions
are not comparable.

## Running untrusted code

Jint is an in-process interpreter, not an operating-system security boundary.
Start with the hardened profile, keep the host surface small, and add a
process-level boundary for hostile input.


```csharp
var limits = UntrustedCodeLimits.Default with
{
    TimeoutInterval = TimeSpan.FromSeconds(1),
    MaxStatements = 50_000,
    MemoryLimit = 16_000_000
};

var options = new Options().ForUntrustedCode(limits);
using var engine = new Engine(options);

using (limits.BeginOperation(engine, cancellationToken))
{
    var value = engine.Evaluate(source);
    return engine.ConvertResult(value, limits.ResultLimits);
}
```


The profile disables broad CLR and reflection access, module loading, string
compilation, projected CLR writes, live CLR-array views, registered extension
methods, and blocking `Atomics.wait`. It also applies finite parsing, execution,
memory, recursion, regular-expression, promise, and result limits.

Constraints are cooperative and cannot preempt arbitrary host callbacks. Use
an external deadline, restrict module and network loaders on every hop, and run
hostile code in a least-privileged disposable process or container.

Read [Running untrusted code](https://sebastienros.github.io/jint/guide/untrusted-code)
and the [threat model](https://github.com/sebastienros/jint/blob/main/.github/THREAT_MODEL.md)
before deploying a script service.

## Documentation and support

- [Documentation](https://sebastienros.github.io/jint/)
- [Preview packages](https://feedz.io/org/sebastienros/repository/jint/packages)
- [Migration to Jint 5](https://sebastienros.github.io/jint/guide/migrating-to-v5)
- [Issues](https://github.com/sebastienros/jint/issues)
- [Sponsors](https://sebastienros.github.io/jint/sponsors)

Jint is licensed under the [BSD 2-Clause License](LICENSE.txt).

---

### Overview (`Jint.Benchmark/README.md`)

# Engine comparison benchmarks

This project benchmarks Jint against the other JavaScript engines available to .NET applications:
the managed engines ([NiL.JS](https://github.com/nilproject/NiL.JS),
[Okojo](https://github.com/akeit0/okojo) and [YantraJS](https://github.com/yantrajs/yantra)) and
[ClearScript](https://github.com/ClearFoundry/ClearScript) — Microsoft-originated, now
ClearFoundry-maintained bindings to Google's native V8 engine (the JIT inside Chrome and Node.js) —
across a set of representative scripts.

> Most of this document is about that cross-engine comparison. The rest of the project is Jint-only
> micro-benchmarks, which need no prose beyond their own XML doc comments — with one exception:
> the opt-in WHATWG web APIs have their own category, their own smoke mode and their own rules about
> what to run when. That is [Web API rows](#web-api-rows), at the end.

## How each engine executes

The engines reach the result in different ways, which shapes the numbers below:

* **Jint** — tree-walking interpreter over a prepared AST.
* **NiL.JS** — interpreter (with an optimizing pass over its syntax tree).
* **Okojo** — interpreter that compiles the script to bytecode and runs it on a virtual machine.
* **YantraJS** — compiler: it emits .NET IL, which the CLR then JIT-compiles to native code.
* **ClearScript (V8)** — native V8 behind a managed ↔ native interop bridge: a multi-tier
  optimizing JIT running outside the CLR, with every host interaction crossing the native boundary.

The structural consequences shape the whole table. Only the two compilers (YantraJS and V8) run
ahead on long, tight numeric/call loops, where compiled code approaches native speed. The
interpreters own the other end of the trade: engine start-up and small scripts, where even a
context created from a pre-warmed V8 isolate costs ~375 µs for work Jint completes in about a
microsecond. And pure-JS compute is only half of an embedding story — the other half is the
script ↔ host boundary, measured separately in the
[interop section](#script--host-interop) below, where the price of V8's native boundary inverts
the picture.

## The scripts

One variant per workload, 12 scripts in total. Where the suite previously carried both a classic
ES5 script and an ES2015+ `-modern` rewrite of it, only the modern variant is kept — that is how
JavaScript is written today, and the duplicated rows ranked the engines nearly identically while
doubling the wall-clock time of a full run. (The classic scripts remain in `Scripts/` and are still
used by the Jint-only benchmarks.)

## Running the benchmarks

Run from **this** directory (the scripts are loaded relative to the working directory):

```
dotnet run -c Release -- --allCategories EngineComparison
```

Notes:

* The `--` separator is required so the arguments are forwarded to BenchmarkDotNet instead of being
  consumed by `dotnet run`.
* `--allCategories EngineComparison` runs both the script suite and the interop suite; use
  `--allCategories EngineComparisonInterop` for the interop suite alone, or
  `--filter "*EngineComparisonBenchmark*"` for the script suite alone.
* Results are written to `BenchmarkDotNet.Artifacts/results/` — the
  `*-report-github.md` file is the table reproduced below.
* To re-measure a single engine (e.g. after a package bump) without disturbing the rest of the
  table, filter to its lane, for example `--filter "*EngineComparisonBenchmark.Okojo*"`.
* The benchmark config widens the parameter column (`MaxParameterColumnWidth = 40`) so the full
  script names are printed instead of BenchmarkDotNet's default truncation (e.g.
  `dromaeo-object-string-modern` rather than `droma(...)odern [28]`).

## How to read the table

* All engines are run in **global strict mode** — YantraJS is strict-only, and Okojo and
  ClearScript have no engine-level strict switch, so their source carries a leading `"use strict"`
  directive. Strict mode improves performance across the board.
* Every operation uses a **fresh engine** — the embedding pattern where executions must not leak
  state into each other.
* Three engines have a **cached-artifact lane** next to the re-parse lane, and the pairs mean the
  same thing: `Jint_ParsedScript` reuses a `Prepared<Script>` produced once by
  `Engine.PrepareScript`; `Okojo_Prepared` reuses a parsed program (Okojo's realm-independent
  artifact) and re-compiles the bytecode against each run's fresh realm; `ClearScript_Compiled`
  reuses a `V8Script` compiled once by a shared `V8Runtime` and runs it in a fresh script engine (a
  fresh V8 context) created from that runtime. The gap to the re-parse lane is parsing/compilation
  cost — **in production you should cache the prepared artifact**, which is what these lanes
  represent.
* The plain `ClearScript` lane creates a full V8 isolate + context per operation — the honest cost
  when each request gets a fully isolated engine, and the reason its short-script rows start around
  a millisecond. `ClearScript_Compiled` shares one isolate (and its warmed JIT state) across
  operations while still using a fresh context per operation — ClearScript's recommended production
  path.
* **`Allocated` counts managed memory only.** ClearScript's working memory lives on V8's native
  heap, which the managed diagnoser cannot see — the few dozen KB in its rows are interop-bridge
  overhead, not a memory-use figure comparable with the managed engines. Memory claims in this
  document therefore compare the managed engines with each other.
* V8 runs background threads (tiered JIT compilation, garbage collection). `Mean` is the wall-clock
  time the executing thread observes; total CPU consumed is higher than for the single-threaded
  managed engines, which matters on saturated servers.
* `Mean` is time per operation (lower is better); `Rank` groups results that are statistically
  tied. Every lane in both tables below comes from a single benchmark session on one machine, so
  ranks are BenchmarkDotNet's own — no rows are merged from separate runs. Cross-session
  comparisons of absolute numbers (including the V8 lanes) are unreliable; compare within a table.
* The `dromaeo-object-regexp-modern` row is the highest-variance in the table (for Jint it is
  dominated by .NET `Regex`); treat small gaps there — including ClearScript's fresh-engine lane
  appearing ahead of its compiled lane, and Jint's own two lanes sitting ~12% apart — as
  run-to-run noise. Jint's lead on that row is not one of those: the prepared lane is ~18 ms clear
  of the nearest V8 lane, more than five times its own standard deviation.

## At a glance

Using each engine's recommended production path (for Jint a cached prepared script,
`Jint_ParsedScript`; for ClearScript a precompiled `V8Script` on a shared runtime,
`ClearScript_Compiled`):

* **Jint owns everything start-up-shaped and eval-shaped; native V8 owns long compute.** Jint is
  the fastest engine outright on `minimal` (**1.1 µs vs V8's 380 µs, ~345×**), `evaluation-modern`
  (**~80×**), `linq-js` (**~6.7×**), `dromaeo-core-eval-modern` (**~5%** ahead of V8's compiled
  lane — eval defeats its compile cache) and `dromaeo-object-regexp-modern` (**1.25× ahead of V8's
  fresh-context lane and 1.46× ahead of its compiled lane**). That is five rows outright, out of
  twelve. No other managed engine takes a single row's top rank away from Jint or V8.
* **V8's wins are the tight-loop compute rows**: `dromaeo-string-base64-modern` (9.8×),
  `dromaeo-object-string-modern` (6.6×), `stopwatch-modern` (6.0×), `dromaeo-3d-cube-modern`
  (3.4×), `json-parse-modern` (2.2×) and narrow leads on `array-stress` (1.09×) and
  `dromaeo-object-array-modern` (1.08×) — the structural interpreter-vs-JIT gap. The two string
  rows carry the widest spread between Jint's own two lanes in this table (5% on base64, 10% on
  object-string, for lanes that differ only in whether the script was re-parsed), so read those
  two multipliers as approximate.
* **Jint is the fastest managed engine on 10 of 12 scripts** (the IL-compiling YantraJS leads
  `dromaeo-3d-cube-modern` and `stopwatch-modern`) and **the fastest interpreter on all 12**.
* **Jint allocates the least of the managed engines on 10 of 12 scripts** — Okojo is lower on
  `dromaeo-object-array-modern` (6,985 vs 9,122 KB) and NiL.JS on `minimal` (4.5 vs 9.9 KB).
  NiL.JS and YantraJS allocate one to two orders of magnitude more on the heavy rows, up to
  **~469×** more than Jint on `dromaeo-string-base64-modern` (763,918 vs 1,628 KB), which means
  far heavier GC pressure in real applications. (ClearScript's rows cannot be compared here — its
  memory lives on the V8 native heap, which the managed diagnoser does not see.)
* **Pure-JS compute is only half the story** — the [interop suite](#script--host-interop) below
  measures the script ↔ host boundary, where the picture inverts and Jint beats ClearScript on
  every row by 3.4×–11.2×.

### What changed since the 4.13.0 table

A profile-guided campaign (PRs [#2716](https://github.com/sebastienros/jint/pull/2716)–
[#2722](https://github.com/sebastienros/jint/pull/2722)) attacked the rows where V8 led, measured
with same-base A/B gates: string-receiver method calls are cached per call site
(`dromaeo-object-string-modern` −20%, `-base64-modern` −17%), JSON.parse interns property keys
(bestbuy-style payloads −22% and −32% allocation), chained `slice()` no longer materializes
intermediate views (−99.3% on that pattern), interop argument binding lost its per-call
reflection checks and boxing, ObjectWrapper members gained a per-callsite inline cache
(`interop-property-access` −25%), and CLR arrays gained identity caching plus an opt-in
`ArrayConversionMode.LiveView` mode (array traversal 2.5× faster, −75% allocation, without flags).

A second campaign round (PRs [#2725](https://github.com/sebastienros/jint/pull/2725),
[#2726](https://github.com/sebastienros/jint/pull/2726)) added bulk JSON string scanning plus an
exactly-rounded simple-number fast path (JsonBenchmark Parse −15–17%) and memoized closure-read
chain validation (`dromaeo-string-base64-modern` −6% at launchCount-5). A third candidate —
arity-specialized direct dispatch for built-in calls, eliminating the argument array — was built,
proven to engage on 4.3M of 4.3M eligible calls, and measured **flat**: Jint's builtin-call path
(pooled argument arrays + the cached-callee lane) is already at its cost floor, so the remaining
gap on the string rows is interpreter dispatch itself, not call ceremony. Measured-and-dropped is
recorded here so it isn't re-attempted.

The 4.14.0 release round shipped the two interop default flips — CLR arrays cross as live views
([#2728](https://github.com/sebastienros/jint/pull/2728)) and recently wrapped objects reuse their
wrappers ([#2734](https://github.com/sebastienros/jint/pull/2734)) — backed by cached array-like
wrapper factories with lazy length ([#2730](https://github.com/sebastienros/jint/pull/2730)),
boxing-free primitive element conversion ([#2731](https://github.com/sebastienros/jint/pull/2731),
extended to the `Array.prototype` iteration lane in
[#2735](https://github.com/sebastienros/jint/pull/2735)), a compiled-invoker fast lane for
single-candidate interop calls ([#2733](https://github.com/sebastienros/jint/pull/2733)) and
JSON.parse value interning plus span-based number parsing
([#2732](https://github.com/sebastienros/jint/pull/2732)). The visible movement in this table:
`interop-collection-traversal` **15,597 → 1,433 µs (10.9×, −99% allocation)** without any script
changes, every other interop row −8–16% with −17–76% allocation, `json-parse-modern` −6% time and
−23% allocation, and `stopwatch-modern` −6%. A pre-release review wave
([#2735](https://github.com/sebastienros/jint/pull/2735)–[#2740](https://github.com/sebastienros/jint/pull/2740))
hardened the new defaults (declared-type contracts, JS-array `in`/enumeration/out-of-range
semantics, constraint-gate balance) with no measurable cost; one further candidate — carrying the
interned JSON key hash into member adds — measured flat and was dropped.

Since 4.14.0, a further interop round cached the compiled method invoker process-wide instead of
per-engine ([#2743](https://github.com/sebastienros/jint/pull/2743)), compiled CLR property and
field access instead of reflecting on every hit ([#2744](https://github.com/sebastienros/jint/pull/2744),
which also more than halved `interop-property-access` allocation, 798 → 329 KB), trimmed the method
fast lane's per-call overhead ([#2745](https://github.com/sebastienros/jint/pull/2745)), and
memoized the converted value of a stable reference-typed property so a host array read in a loop
skips re-conversion ([#2756](https://github.com/sebastienros/jint/pull/2756)). The visible movement
in the interop table: `interop-collection-traversal` and `interop-string-passing` now rank first
(collection −8% from the memo alone, ahead of NiL.JS), and method calls closed to within ~4% of
NiL.JS while still allocating 7× less.

### What changed for 4.15.0

The 4.15.0 round shipped a string and regexp allocation wave — vectorized special-casing and
search pre-scans ([#2775](https://github.com/sebastienros/jint/pull/2775),
[#2776](https://github.com/sebastienros/jint/pull/2776)), `toWellFormed` no longer rebuilding
already-well-formed strings ([#2778](https://github.com/sebastienros/jint/pull/2778)), dense fast
paths for the callback-free array methods ([#2779](https://github.com/sebastienros/jint/pull/2779),
[#2780](https://github.com/sebastienros/jint/pull/2780)) and the string-concat plus RegExp fixes
aimed at this suite's regexp row ([#2781](https://github.com/sebastienros/jint/pull/2781), with
follow-up concat correctness in [#2811](https://github.com/sebastienros/jint/pull/2811) and
[#2812](https://github.com/sebastienros/jint/pull/2812)) — a fast-call lane for built-in calls
with declared per-argument leaf guards
([#2783](https://github.com/sebastienros/jint/pull/2783),
[#2828](https://github.com/sebastienros/jint/pull/2828),
[#2844](https://github.com/sebastienros/jint/pull/2844)), span- and UTF-8-based JSON parsing
([#2832](https://github.com/sebastienros/jint/pull/2832)), and an interop round that compiles CLR
member access for keyed writes, indexers and statics
([#2839](https://github.com/sebastienros/jint/pull/2839)), shares resolved members process-wide
across engines ([#2798](https://github.com/sebastienros/jint/pull/2798)) and stops re-reflecting
on every host delegate call ([#2799](https://github.com/sebastienros/jint/pull/2799)).

The visible movement in these tables: `dromaeo-object-regexp-modern`
**123,563 → 71,625 µs (−42%) with −47% allocation** (157,850 → 82,980 KB), which takes the row
from rank 3 to rank 1 — ahead of both V8 lanes, and the first time Jint leads it;
`dromaeo-object-string-modern` **−21%**; `dromaeo-string-base64-modern` **−13%** on the prepared
lane (the re-parse lane's −30% is partly the previous session's high reading — the two Jint lanes
now agree to within 1%); `array-stress` −5%, back to a rank-1 tie with V8's compiled lane;
`json-parse-modern` −1% time with −4% allocation. On the interop side `interop-property-access` is
−11% and `interop-method-calls` −4%, while `interop-collection-traversal` moved the other way
(+5%) and NiL.JS retook that row by ~4%.

### What changed for 4.16.0

The headline of the 4.16.0 round is **proper tail calls for strict functions**
([#2975](https://github.com/sebastienros/jint/pull/2975)): tail-position analysis plus a trampoline
that dispatches an interpreted tail call without growing the execution-context or call stacks,
with cleanup, constructor, callback, debugger and recursion-limit semantics preserved. In Jint's
own script benchmarks — not this comparison suite — the `controlflow-recursive` row measured
**−15.6% time and −40.4% allocation** against 4.15.3. Here it lands wherever a script recurses in
tail position rather than on any single row.

The fast-call lane — built-in calls that skip the pooled argument array and, where every route to
user code is closed off by a checkable guard, the call frame as well — grew again:
`parseInt`/`parseFloat` became one realm intrinsic each and took the frameless lane
([#2968](https://github.com/sebastienros/jint/pull/2968)); the cheapest `Number` type tests and six
`String.prototype` search methods followed under declared per-argument guards
([#2980](https://github.com/sebastienros/jint/pull/2980)); and `Map`/`Set` lookups became frameless
once each instance carried a type bit the guard could check
([#2984](https://github.com/sebastienros/jint/pull/2984)). On the interop side a wrapped dictionary
now answers existence questions — `in`, `hasOwnProperty`, `for..in`, `Object.keys`, spread,
`JSON.stringify` — from the target's compiled `ContainsKey` instead of converting the value and
building a descriptor only to discard both
([#2969](https://github.com/sebastienros/jint/pull/2969)); no script in the interop suite
enumerates a host dictionary, so that one does not surface in the table below.

One correctness fix went the other way, and its cost is disclosed rather than absorbed quietly:
`Array.prototype.join` (and `toString`, which is defined as a call to it) now re-reads a hole
through the array instead of answering `undefined` from its snapshot, because a `toString` side
effect on an earlier element can add an index property to the prototype that every later element
has to see ([#3003](https://github.com/sebastienros/jint/pull/3003)). The packed path is untouched;
a hole-heavy join costs **+3.4%**.

Against the 4.15.0 tables: the two sessions ran on different runtime and OS builds (.NET 10.0.10 →
10.0.11) with a YantraJS bump in between, so no row-for-row delta is claimed here — the comparison
below is directional only. The rank structure is the one 4.15.0 published, with one change of lead
in each table, and they point in opposite directions: `array-stress` slips out of its rank-1 tie,
leaving V8's compiled lane 1.09× ahead, while `interop-collection-traversal` puts Jint back into a
rank-1 tie with NiL.JS. Every other row is led by the engine that led it before, and
`dromaeo-object-regexp-modern` stays Jint's, ~18 ms clear of the nearest V8 lane.

## Script ↔ host interop

Embedding a JavaScript engine is rarely about pure computation — scripts exist to drive the host
application, and in interop-heavy systems the script ↔ host boundary dominates. This is also where
the engines differ structurally: the managed engines dispatch host calls in-process, while every
ClearScript host interaction crosses the managed ↔ native V8 boundary and marshals its arguments
across it.

`EngineComparisonInteropBenchmark` (run with `--allCategories EngineComparisonInterop`) drives
four byte-identical scripts against each engine: a host method-call loop, a host property
read/write loop, strings crossing the boundary, and traversal of a host `int[]`. Details that
keep the comparison fair:

* Host members are lowercase (`host.add`, `host.value`, …) because YantraJS camel-cases CLR
  member names while the other engines surface them verbatim — already-lowercase names are the
  fixed point of both conventions, so every engine runs the same source.
* Each script validates its final aggregate and throws on a mismatch, so an engine that silently
  mis-marshals (undefined, NaN) fails loudly instead of posting a fantasy time.
* `ClearScript` binds the host object with plain `AddHostObject` (reflection-based, like the
  managed engines); `ClearScript_FastProxy` uses ClearScript 7.5's FastProxy API — explicit
  member registration with zero-allocation marshaling for fundamental types — its recommended
  path for hot host objects.
* Okojo is absent: 0.1.2-preview.1 provides no public way to enable CLR access.
* As above, `Allocated` is meaningful for the managed engines but only counts bridge overhead
  for the two ClearScript lanes.

What the numbers show:

* **The managed engines win every interop row.** Crossing the native boundary costs plain
  ClearScript **8.6×–11.2×** against Jint on every row — the mirror image of the pure-compute
  table, and the half of the story that matters most in chatty embedding scenarios.
* **FastProxy narrows the gap but does not close it**: still **3.4×–7.0×** behind Jint, and it
  trades away convenience — every member is hand-registered instead of reflected. Its
  zero-allocation claim is real, though: 13 KB managed allocation on the method-call row where
  reflective ClearScript burns 12,753 KB.
* **Jint leads string passing outright, is tied for the lead on collection traversal, and
  allocates the least of the managed engines on all four rows.** It is rank 1 on
  `interop-string-passing` (522.4 µs, ~10% clear of YantraJS) and rank 1 alongside NiL.JS on
  `interop-collection-traversal` (1,251.0 vs 1,242.7 µs — 0.7% apart, a statistical tie that
  BenchmarkDotNet ranks equal). It is rank 2 on `interop-method-calls` (NiL.JS ahead, 1,172.2 vs
  1,440.0 µs, Jint +23%) and on `interop-property-access` (YantraJS ahead, 1,451.9 vs 1,489.8 µs,
  Jint +2.6%). Allocation is Jint's on every row: 3.9×–12.4× less than the row's nearest managed
  competitor. (Three ClearScript_FastProxy rows show smaller KB figures, but those count
  interop-bridge overhead only — V8's working memory is on its native heap.)
* **Historical 4.14.0 collection traversal result — last of all engines at 4.13.0 — was transformed at
  15,597 → 1,251 µs (12.5×, −99% allocation) with no script changes**: in that release,
  `ArrayConversionMode.LiveView` was the default and exposed the host array as a live view instead of
  re-copying it on every read, and a per-descriptor value memo
  ([#2756](https://github.com/sebastienros/jint/pull/2756)) skips re-converting the array on every
  read. NiL.JS took the row back by ~4% in the previous session; here the two are level (0.7%
  apart, both rank 1) while NiL.JS allocates 12× more. The repeated copies in this historical table predate
  the current recent-wrapper cache. `Copy` is now the default and normally reuses its first snapshot while
  cached; explicitly opt into `LiveView` to retain shared mutation and avoid the initial O(N) projection.

| Method                | FileName                     | Mean        | StdDev   | Rank | Allocated   |
|---------------------- |----------------------------- |------------:|---------:|-----:|------------:|
| NilJS                 | interop-collection-traversal |  1,242.7 μs |  3.19 μs |    1 |  4088.14 KB |
| Jint                  | interop-collection-traversal |  1,251.0 μs |  1.78 μs |    1 |   330.97 KB |
| YantraJS              | interop-collection-traversal |  3,685.3 μs | 13.86 μs |    2 |  5395.96 KB |
| ClearScript_FastProxy | interop-collection-traversal |  8,779.5 μs | 18.91 μs |    3 |  1185.72 KB |
| ClearScript           | interop-collection-traversal | 11,691.8 μs | 27.62 μs |    4 |  5016.24 KB |
|                       |                              |             |          |      |             |
| NilJS                 | interop-method-calls         |  1,172.2 μs |  3.76 μs |    1 |  2437.94 KB |
| Jint                  | interop-method-calls         |  1,440.0 μs |  2.86 μs |    2 |   328.37 KB |
| YantraJS              | interop-method-calls         |  2,102.4 μs |  8.30 μs |    3 |  3355.13 KB |
| ClearScript_FastProxy | interop-method-calls         |  5,457.7 μs | 15.37 μs |    4 |    13.01 KB |
| ClearScript           | interop-method-calls         | 16,108.5 μs | 57.35 μs |    5 | 12752.52 KB |
|                       |                              |             |          |      |             |
| YantraJS              | interop-property-access      |  1,451.9 μs |  3.45 μs |    1 |  1870.68 KB |
| Jint                  | interop-property-access      |  1,489.8 μs |  3.12 μs |    2 |    329.3 KB |
| NilJS                 | interop-property-access      |  1,702.0 μs |  5.10 μs |    3 |  4391.52 KB |
| ClearScript_FastProxy | interop-property-access      |  5,111.8 μs |  9.73 μs |    4 |    12.66 KB |
| ClearScript           | interop-property-access      | 12,875.7 μs | 20.43 μs |    5 | 10561.78 KB |
|                       |                              |             |          |      |             |
| Jint                  | interop-string-passing       |    522.4 μs |  0.96 μs |    1 |   304.08 KB |
| YantraJS              | interop-string-passing       |    577.5 μs |  3.25 μs |    2 |  1714.38 KB |
| NilJS                 | interop-string-passing       |    769.8 μs |  1.45 μs |    3 |  1179.64 KB |
| ClearScript_FastProxy | interop-string-passing       |  2,961.1 μs |  9.59 μs |    4 |   247.15 KB |
| ClearScript           | interop-string-passing       |  5,263.2 μs | 19.95 μs |    5 |  2470.18 KB |

## Engine versions

* Jint 4.16.0
* NiL.JS 2.6.1722
* Okojo 0.1.2-preview.1
* YantraJS.Core 1.2.422
* Microsoft.ClearScript.V8 7.5.1

Both tables come from one benchmark session on the same machine and .NET runtime (all lanes,
including ClearScript, measured together): 116 benchmarks — 96 script rows (8 lanes × 12 scripts)
and 20 interop rows (5 lanes × 4 scripts) — default job, otherwise idle machine. ClearScript's V8
lanes land within ~3% of the previous (2026-07-28) session on the identical package, so those rows
are broadly comparable across the two — but the ~30% swing those same lanes showed between the
4.14.0 and 2026-07-24 sessions is the standing reminder that the native-JIT rows carry more
session-to-session variance than the managed engines, and that absolute numbers should be compared
within a table rather than across sessions. Two things moved under the whole table between the two
sessions besides Jint itself: YantraJS 1.2.419 → 1.2.422, and the host runtime .NET 10.0.10 →
10.0.11 (SDK 10.0.302 → 10.0.400) on a newer Windows build. Last updated 2026-08-13 (4.16.0
release candidate `7b56c8341`).

```

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9168/25H2/2025Update/HudsonValley2)
AMD Ryzen 9 5950X 3.40GHz, 1 CPU, 32 logical and 16 physical cores
.NET SDK 10.0.400
  [Host]     : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3

```

| Method               | FileName                     | Mean             | StdDev         | Rank | Allocated     |
|--------------------- |----------------------------- |-----------------:|---------------:|-----:|--------------:|
| ClearScript_Compiled | array-stress                 |     2,035.717 μs |      7.6419 μs |    1 |        8.1 KB |
| Jint                 | array-stress                 |     2,209.995 μs |      3.9495 μs |    2 |    1091.93 KB |
| Jint_ParsedScript    | array-stress                 |     2,212.058 μs |      5.5029 μs |    2 |     1063.2 KB |
| YantraJS             | array-stress                 |     2,815.214 μs |     26.4164 μs |    3 |   17093.93 KB |
| ClearScript          | array-stress                 |     3,295.998 μs |      9.5510 μs |    4 |      16.06 KB |
| NilJS                | array-stress                 |     4,845.159 μs |      6.6924 μs |    5 |    4521.19 KB |
| Okojo                | array-stress                 |     5,546.333 μs |     17.9347 μs |    6 |    2697.82 KB |
| Okojo_Prepared       | array-stress                 |     5,546.651 μs |     25.6847 μs |    6 |    2682.11 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | dromaeo-3d-cube-modern       |     1,361.995 μs |      4.2358 μs |    1 |       9.26 KB |
| YantraJS             | dromaeo-3d-cube-modern       |     2,299.281 μs |      5.9113 μs |    2 |    7436.04 KB |
| ClearScript          | dromaeo-3d-cube-modern       |     3,178.514 μs |     16.6576 μs |    3 |      14.49 KB |
| Jint_ParsedScript    | dromaeo-3d-cube-modern       |     4,607.760 μs |      8.5718 μs |    4 |    1374.41 KB |
| Jint                 | dromaeo-3d-cube-modern       |     4,988.145 μs |      6.7079 μs |    5 |    1679.78 KB |
| Okojo_Prepared       | dromaeo-3d-cube-modern       |     6,826.450 μs |    109.1199 μs |    6 |    2311.61 KB |
| NilJS                | dromaeo-3d-cube-modern       |     7,095.742 μs |      4.8978 μs |    7 |    5977.95 KB |
| Okojo                | dromaeo-3d-cube-modern       |     7,161.629 μs |     97.7917 μs |    7 |    2498.75 KB |
|                      |                              |                  |                |      |               |
| Jint_ParsedScript    | dromaeo-core-eval-modern     |       886.665 μs |      0.6373 μs |    1 |     346.77 KB |
| Jint                 | dromaeo-core-eval-modern     |       920.932 μs |      1.4350 μs |    2 |     366.23 KB |
| ClearScript_Compiled | dromaeo-core-eval-modern     |       933.105 μs |      3.4049 μs |    2 |       7.82 KB |
| NilJS                | dromaeo-core-eval-modern     |     1,382.077 μs |      1.4440 μs |    3 |    1575.94 KB |
| ClearScript          | dromaeo-core-eval-modern     |     2,052.775 μs |     12.2834 μs |    4 |       12.9 KB |
| YantraJS             | dromaeo-core-eval-modern     |     4,839.609 μs |     21.3171 μs |    5 |   35784.84 KB |
| Okojo                | dromaeo-core-eval-modern     |     5,766.205 μs |    282.7096 μs |    6 |    4628.16 KB |
| Okojo_Prepared       | dromaeo-core-eval-modern     |     6,731.548 μs |    166.2416 μs |    7 |    4613.12 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | dromaeo-object-array-modern  |    13,511.360 μs |     28.1602 μs |    1 |      16.29 KB |
| Jint                 | dromaeo-object-array-modern  |    14,560.874 μs |    161.6323 μs |    2 |    9168.77 KB |
| Jint_ParsedScript    | dromaeo-object-array-modern  |    14,574.726 μs |    148.7198 μs |    2 |    9121.77 KB |
| ClearScript          | dromaeo-object-array-modern  |    15,233.491 μs |     42.0857 μs |    3 |     111.74 KB |
| YantraJS             | dromaeo-object-array-modern  |    26,468.928 μs |    449.6599 μs |    4 |  223683.36 KB |
| Okojo_Prepared       | dromaeo-object-array-modern  |    40,069.776 μs |     82.4893 μs |    5 |    6984.74 KB |
| Okojo                | dromaeo-object-array-modern  |    40,370.712 μs |     73.8563 μs |    5 |    7014.22 KB |
| NilJS                | dromaeo-object-array-modern  |    52,137.758 μs |     63.4400 μs |    6 |   17863.19 KB |
|                      |                              |                  |                |      |               |
| Jint_ParsedScript    | dromaeo-object-regexp-modern |    71,958.764 μs |  3,427.6529 μs |    1 |   82555.71 KB |
| Jint                 | dromaeo-object-regexp-modern |    80,412.888 μs |  6,125.1272 μs |    2 |   83913.27 KB |
| ClearScript          | dromaeo-object-regexp-modern |    90,178.237 μs |    286.2653 μs |    3 |      32.86 KB |
| ClearScript_Compiled | dromaeo-object-regexp-modern |   105,206.432 μs |  1,176.4466 μs |    4 |      15.71 KB |
| NilJS                | dromaeo-object-regexp-modern |   538,262.653 μs |  6,740.4339 μs |    5 |  767391.28 KB |
| YantraJS             | dromaeo-object-regexp-modern |   727,779.264 μs |  7,367.8313 μs |    6 |  826323.88 KB |
| Okojo_Prepared       | dromaeo-object-regexp-modern | 1,873,094.293 μs |  8,600.8802 μs |    7 | 1799271.88 KB |
| Okojo                | dromaeo-object-regexp-modern | 1,886,667.414 μs | 20,848.1909 μs |    7 | 1799460.78 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | dromaeo-object-string-modern |     5,837.141 μs |     22.5034 μs |    1 |      15.64 KB |
| ClearScript          | dromaeo-object-string-modern |     8,561.271 μs |     27.2480 μs |    2 |      25.24 KB |
| Jint                 | dromaeo-object-string-modern |    35,062.101 μs |  1,535.6300 μs |    3 |   21529.67 KB |
| Jint_ParsedScript    | dromaeo-object-string-modern |    38,511.691 μs |  2,516.2942 μs |    4 |   21340.16 KB |
| Okojo_Prepared       | dromaeo-object-string-modern |    55,635.516 μs |  1,113.7534 μs |    5 |    33452.7 KB |
| Okojo                | dromaeo-object-string-modern |    55,910.038 μs |    646.7601 μs |    5 |    33511.2 KB |
| NilJS                | dromaeo-object-string-modern |   143,285.835 μs |  2,970.2749 μs |    6 | 1354957.51 KB |
| YantraJS             | dromaeo-object-string-modern |   176,114.896 μs |  6,047.6327 μs |    7 | 1648871.26 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | dromaeo-string-base64-modern |     1,697.677 μs |      7.8604 μs |    1 |       8.89 KB |
| ClearScript          | dromaeo-string-base64-modern |     3,404.818 μs |     12.1244 μs |    2 |      15.38 KB |
| Jint_ParsedScript    | dromaeo-string-base64-modern |    16,717.857 μs |     31.9543 μs |    3 |    1628.09 KB |
| Jint                 | dromaeo-string-base64-modern |    17,639.956 μs |     21.7736 μs |    4 |    1728.46 KB |
| Okojo_Prepared       | dromaeo-string-base64-modern |    30,042.911 μs |    426.3164 μs |    5 |   43745.71 KB |
| Okojo                | dromaeo-string-base64-modern |    31,428.279 μs |    285.0574 μs |    6 |   43825.36 KB |
| NilJS                | dromaeo-string-base64-modern |    32,092.329 μs |    607.6797 μs |    6 |   31360.34 KB |
| YantraJS             | dromaeo-string-base64-modern |    35,415.762 μs |    762.1968 μs |    7 |  763917.73 KB |
|                      |                              |                  |                |      |               |
| Jint_ParsedScript    | evaluation-modern            |         4.651 μs |      0.0116 μs |    1 |      18.61 KB |
| Jint                 | evaluation-modern            |        14.038 μs |      0.0454 μs |    2 |      29.74 KB |
| NilJS                | evaluation-modern            |        25.992 μs |      0.0602 μs |    3 |      22.35 KB |
| YantraJS             | evaluation-modern            |       129.352 μs |      1.0605 μs |    4 |      703.4 KB |
| ClearScript_Compiled | evaluation-modern            |       374.891 μs |      1.1516 μs |    5 |       6.09 KB |
| ClearScript          | evaluation-modern            |     1,139.884 μs |      4.8187 μs |    6 |      10.97 KB |
| Okojo_Prepared       | evaluation-modern            |     1,437.314 μs |     49.8982 μs |    7 |    1283.54 KB |
| Okojo                | evaluation-modern            |     1,564.927 μs |     61.0150 μs |    8 |    1290.72 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | json-parse-modern            |     7,465.707 μs |     18.3971 μs |    1 |       9.79 KB |
| ClearScript          | json-parse-modern            |     9,252.981 μs |     38.1479 μs |    2 |      17.14 KB |
| Jint_ParsedScript    | json-parse-modern            |    16,243.839 μs |    208.5431 μs |    3 |   11397.93 KB |
| Jint                 | json-parse-modern            |    16,680.470 μs |    224.1474 μs |    3 |   11434.71 KB |
| YantraJS             | json-parse-modern            |    24,030.128 μs |    202.9171 μs |    4 |   42849.84 KB |
| Okojo                | json-parse-modern            |    24,851.231 μs |     56.3649 μs |    5 |   27271.79 KB |
| Okojo_Prepared       | json-parse-modern            |    24,871.608 μs |     43.3566 μs |    5 |   27235.55 KB |
| NilJS                | json-parse-modern            |   126,595.546 μs |    275.3617 μs |    6 |   67095.19 KB |
|                      |                              |                  |                |      |               |
| Jint_ParsedScript    | linq-js                      |        68.403 μs |      0.2889 μs |    1 |     214.39 KB |
| YantraJS             | linq-js                      |       316.724 μs |      1.6240 μs |    2 |    1049.75 KB |
| ClearScript_Compiled | linq-js                      |       462.290 μs |      2.4241 μs |    3 |       6.21 KB |
| Jint                 | linq-js                      |     1,091.041 μs |      3.5935 μs |    4 |    1296.63 KB |
| ClearScript          | linq-js                      |     2,036.363 μs |      8.9904 μs |    5 |      10.96 KB |
| NilJS                | linq-js                      |     3,865.936 μs |      4.6159 μs |    6 |    2739.46 KB |
| Okojo_Prepared       | linq-js                      |     5,912.862 μs |    133.9733 μs |    7 |    4131.95 KB |
| Okojo                | linq-js                      |     8,574.856 μs |    337.6247 μs |    8 |    4928.88 KB |
|                      |                              |                  |                |      |               |
| Jint_ParsedScript    | minimal                      |         1.094 μs |      0.0051 μs |    1 |       9.92 KB |
| Jint                 | minimal                      |         2.095 μs |      0.0061 μs |    2 |      11.84 KB |
| NilJS                | minimal                      |         2.883 μs |      0.0080 μs |    3 |       4.51 KB |
| YantraJS             | minimal                      |       126.579 μs |      0.5990 μs |    4 |     697.62 KB |
| ClearScript_Compiled | minimal                      |       379.509 μs |      1.4249 μs |    5 |       6.12 KB |
| ClearScript          | minimal                      |     1,127.502 μs |      6.8833 μs |    6 |      10.97 KB |
| Okojo                | minimal                      |     1,136.618 μs |     79.5654 μs |    6 |    1249.24 KB |
| Okojo_Prepared       | minimal                      |     1,154.071 μs |     80.6357 μs |    6 |    1247.39 KB |
|                      |                              |                  |                |      |               |
| ClearScript_Compiled | stopwatch-modern             |    14,161.990 μs |     43.6106 μs |    1 |       9.01 KB |
| ClearScript          | stopwatch-modern             |    17,170.161 μs |     53.0071 μs |    2 |      22.67 KB |
| YantraJS             | stopwatch-modern             |    56,498.545 μs |    187.0381 μs |    3 |  233993.23 KB |
| Jint                 | stopwatch-modern             |    85,132.661 μs |    332.5876 μs |    4 |   12122.73 KB |
| Jint_ParsedScript    | stopwatch-modern             |    85,204.309 μs |    753.0055 μs |    4 |   12090.63 KB |
| Okojo                | stopwatch-modern             |   151,840.067 μs |    916.1697 μs |    5 |   21469.24 KB |
| Okojo_Prepared       | stopwatch-modern             |   155,504.922 μs |  2,858.1551 μs |    5 |   21445.98 KB |
| NilJS                | stopwatch-modern             |   209,122.986 μs |    815.7981 μs |    6 |  324502.66 KB |

## Web API rows

The opt-in WHATWG web APIs under `Jint/WebApi/` shipped with a strict no-cost-when-off discipline —
nothing is installed unless `Options.WebApi.Features` names it, and a default engine is byte-for-byte
the engine it was before they existed. That half was measured. The other half, what the surface costs
a host that *does* switch it on, had nothing tracking it release to release. These rows are that.

Run the whole category:

```
dotnet run -c Release --project Jint.Benchmark -- --allCategories WebApi
```

Unlike the comparison suite, these rows load no files, so it does not matter which directory you run
them from. As everywhere else here, set `JINT_BENCH_MODE=gate` before quoting any number in a PR.

### Check the rows before measuring them

```
dotnet run -c Release --project Jint.Benchmark -- --smoke-webapi
```

This runs every web-API class's `[GlobalSetup]` and then each of its rows three times, and reports
pass/fail per class in a few seconds. It measures nothing. Two failures it is there to catch, both of
which otherwise surface half an hour into a benchmark session:

* a row's script throwing — a built-in that moved, or a row asking for the wrong feature flag;
* a row that works once and **drifts afterwards**. Every deterministic row records what its own first
  run produced and compares every later run against it, because BenchmarkDotNet will happily average
  an operation that is doing more work each time it runs — state accumulating on the row's engine, a
  queue that is never emptied, a buffer that was detached last time and is not now. That check can
  only bite from the second operation onwards, which is why the smoke mode runs each row more than
  once.

### Which rows cover which feature

| Class | `WebApiFeatures` | Source | Rows |
| --- | --- | --- | --- |
| `WebApiUrlBenchmark` | `Url` | `Url/` | `ParseAbsolute`, `ParseRelative`, `ReadComponents`, `MutateAndSerialize`, `SearchParamsMutate`, `SearchParamsIterate` |
| `WebApiEncodingBenchmark` | `Encoding` | `Encoding/` | `RoundTripSmall`, `RoundTripLarge`, `DecodeStreaming`, `EncodeIntoSmall` |
| `WebApiStructuredCloneBenchmark` | `StructuredClone` | `StructuredClone/` | `CloneFlat`, `CloneNested`, `CloneTransfer` |
| `WebApiFetchObjectModelBenchmark` | `Fetch` (implies `Events`, `Url`, `Files`) | `Fetch/` | `HeadersAppendAndIterate`, `RequestConstruction`, `ResponseConstruction`, `RequestClone` |
| `WebApiStreamsBenchmark` | `Streams` | `Streams/` | `PumpWithReader`, `PumpWithAsyncIteration`, `PipeThroughTransform` |
| `WebApiTimerBenchmark` | `Timers` | `Timers/` | `ScheduleAndCancel`, `FanOutFiring`, `IntervalFiring` |
| `WebApiCryptoBenchmark` | `Crypto` | `Crypto/` | `RandomValuesSmall`, `RandomValuesLarge`, `RandomUuid` |
| `WebApiSubtleCryptoBenchmark` | `Crypto` | `Crypto/` | `DigestSha256`, `HmacSign`, `HmacVerify`, `RsaVerify`, `EcdsaVerify`, `AesGcmEncrypt` |

The `crypto.subtle` rows are the operations an embedder runs per request rather than a tour of the
interface — HS256 signing and verification, RS256 and ES256 token validation against fixed imported
keys, and AES-GCM-128 sealing — all over the same 115-byte JWS signing input, so they differ by
algorithm and by nothing else. Each measures **one call and its settlement**: the script is a single
`crypto.subtle` call and the row unwraps the promise it returns host-side, which costs no event-loop
turn (the operations are synchronous CPU work and the promise is already fulfilled) but does put the
host-side unwrap ceremony inside the number. Keys are imported in `[GlobalSetup]`, on the row's own
engine, and never in the measured script.

**No row covers `Console`, `Base64`, `Performance`, `Files`, `Navigator` or `Scheduler` yet**, and
`Events` is only reached incidentally, through the `AbortSignal` every `Request` carries. A PR
touching one of those has nothing here to move, and saying so is the point of this paragraph — the
alternative is a contributor running the category, seeing it flat, and reading that as evidence.

Three properties of these rows are load-bearing and should survive any edit to them:

* **One engine per row, warmed with that row's own workload and nothing else** — see
  `IsolatedScript`'s doc comment for the measurement defect that rule exists to prevent. It matters
  more here than elsewhere, because these globals are installed lazily and one row's touch is what
  materializes them.
* **Each engine carries only its own feature**, never `WebApiFeatures.Default`, so a change to one
  feature cannot move another feature's rows through shared installation cost.
* **The timer rows drive a manual `TimeProvider`.** On `TimeProvider.System` every `setTimeout` would
  pay a real clock read and a row whose timers fire would be measuring how long the machine took to
  reach a wall-clock instant. Nothing in these rows sleeps, waits or opens a socket — including the
  fetch rows, which build `Request`/`Response`/`Headers` and never call `fetch`.

### The pre-release regression pass

Seven rows are the ones to run alongside SunSpider and Dromaeo before a release — one per feature
area, each chosen as the row a regression in its area reaches first:

```
dotnet run -c Release --project Jint.Benchmark -- --filter "*WebApiUrlBenchmark.ParseAbsolute" "*WebApiUrlBenchmark.SearchParamsMutate" "*WebApiEncodingBenchmark.RoundTripSmall" "*WebApiStructuredCloneBenchmark.CloneNested" "*WebApiFetchObjectModelBenchmark.RequestConstruction" "*WebApiStreamsBenchmark.PumpWithReader" "*WebApiTimerBenchmark.FanOutFiring"
```

* `WebApiUrlBenchmark.ParseAbsolute` — the URL parser is the largest hand-written state machine in
  the subtree, and `Request` construction runs it too.
* `WebApiUrlBenchmark.SearchParamsMutate` — the other half of `Url/`: the list, its serializer and
  the URL-object update the setters trigger.
* `WebApiEncodingBenchmark.RoundTripSmall` — dominated by per-call ceremony (WebIDL argument
  conversion, result-view allocation), which is the cost every one of these APIs shares, so it is the
  row a change to that shared layer moves.
* `WebApiStructuredCloneBenchmark.CloneNested` — the deepest recursive algorithm in the subtree, over
  a graph carrying a `Map`, a `Set`, a `Date`, a `RegExp`, a typed array and a cycle.
* `WebApiFetchObjectModelBenchmark.RequestConstruction` — one row crossing URL parsing, header-init
  conversion, body extraction and `AbortSignal` creation.
* `WebApiStreamsBenchmark.PumpWithReader` — promise and job-queue traffic, 256 chunks' worth.
* `WebApiTimerBenchmark.FanOutFiring` — the event loop's timer promotion, 500 timers per operation.

The last two are worth running for a change to the **engine's own** event loop as well, not only for
one under `Jint/WebApi/`: they are the densest promise-and-job workloads in the suite.

No baseline table is published here yet. The first `JINT_BENCH_MODE=gate` run of a release cycle
establishes one, and a number measured before that has nothing to be compared against.

---

### Overview (`Jint.Browser.Mcp/README.md`)

# Jint.Browser.Mcp

A [Model Context Protocol](https://modelcontextprotocol.io/) server over a headless browser, from
[Jint](https://github.com/sebastienros/jint) and [Jint.HtmlParser](../Jint.HtmlParser/README.md). An agent
navigates, reads a page as its accessibility tree or as markdown, and clicks, fills and types its way
through it — in one .NET process, with no browser to download and no native binary anywhere.

**It renders nothing.** Geometry comes from a synthetic flat box model; there are no painted pixels,
screenshots or PDFs. It answers with the page's accessibility tree, prose and text. For an agent that is usually the better
answer anyway, and it costs a fraction of the tokens a picture would.

## Using it

The quickest way is the `jint-browser` command line, which is this server plus a transport:

```bash
dotnet tool install -g Jint.Browser.Tool
```

Then, in Claude Desktop's `claude_desktop_config.json`, VS Code's `mcp.json`, or any client that starts a
server as a child process:

```json
{
  "mcpServers": {
    "jint-browser": {
      "command": "jint-browser",
      "args": ["mcp"]
    }
  }
}
```

or, in Claude Code:

```bash
claude mcp add jint-browser -- jint-browser mcp
```

**stdio is the transport, and the only one.** A client starts the program, drives it and ends it, so the
process is the session and its browsing context — its cookies, its storage, its history — is that session's
alone. See *Sessions and HTTP* below.

## In your own server

```csharp
// dotnet add package Jint.Browser.Mcp   (net8.0 and later)
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateEmptyApplicationBuilder(null);

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .AddJintBrowser(browser =>
    {
        browser.Timeout = TimeSpan.FromSeconds(20);
        browser.UrlFilter = url => url.Host.EndsWith(".example.org", StringComparison.Ordinal);
    });

await builder.Build().RunAsync();
```

`AddJintBrowser` registers the browser, the session and every tool and resource below. `BrowserAgentOptions`
is what a deployment decides: the security posture, the budgets, the ceiling on a snapshot, and the
`UrlFilter` that pins an agent to one site.

## The tools

| Tool | What it does |
| --- | --- |
| `navigate(url, waitUntil?)` | Loads a URL; answers its final URL, title and status. |
| `snapshot(mode?, mainContentOnly?, maxLength?)` | Reads the page. `ax` (the default) is the accessibility tree with a `ref=` on every element; `markdown` is the page as prose; `text` is the same without formatting. |
| `click`, `fill`, `type`, `press`, `select`, `hover`, `scroll` | Drive the page. Each takes a `ref=` from an `ax` snapshot or a CSS selector. |
| `upload(target, paths)` | Chooses files for an `<input type=file>`, as a file dialog would. **Refused unless a deployment set `BrowserAgentOptions.UploadDirectory`**, and refused for anything outside it. |
| `back`, `forward`, `reload` | The three buttons above the page. |
| `evaluate(expression)` | Runs one JavaScript expression and answers its JSON, serialized by the page. |
| `wait_for(selector?, text?, timeoutSeconds?)` | Waits for the page to catch up with what an action started. |
| `network_requests()` | Every request the page made, with the status each answered. |
| `cookies(url?)`, `set_cookie(name, value, url?)` | Read and seed the session's cookies. |
| `close()` | Ends the session; the next `navigate` starts a clean one. |

Two resources answer about the same session: `jint://page/markdown` and `jint://page/requests`.

**A `ref=` is the handle that makes a snapshot actionable.** An `ax` snapshot prints
`- button "Save" [ref=42]`, and `click("ref=42")` reaches exactly that element — which is what an agent has,
where a CSS selector is something it would have to invent. A reference belongs to the document it was
printed from, so take a fresh snapshot after anything that navigates.

**No tool throws through the transport.** Each answers its result as JSON, or `isError` with one sentence
saying what could not be done and what to try instead.

## The security posture

**Hardened by default**, because a client is by definition pointing this at content nobody vouched for — a
page a model chose, from a search result, from a link in an email. Every page runs Jint's
`ForUntrustedContent()` profile: no `eval`, no `new Function`, no CLR interop, no module loader, and bounded
statements, wall-clock time, allocation, recursion and regular expressions. Loopback, private, link-local and
cloud-metadata addresses are refused.

`BrowserAgentOptions.Trusted` turns the profile off and `BlockPrivateNetwork` decides the network rule on its
own, so a deployment pointing an agent at its own staging server says so once, out loud. `UrlFilter` is the
narrower tool and the one to reach for: it is checked on the first hop and on every redirect.

**`upload` is off until a directory is named, and it is the one tool that can move data outwards.** Every
other tool sends a page only what that page already had; an upload sends a file from this machine to a site
the model chose, and a model reading a page is a model that page can try to instruct. So
`BrowserAgentOptions.UploadDirectory` names the one directory files may be read from — nothing outside it,
and a symbolic link is followed before that is decided — and with no directory named the tool answers an
error rather than a file.

## Sessions and HTTP

A browsing session is state — cookies, storage, history, an open page — and the protocol's 2026-07-28
revision removed the session header from streamable HTTP, so the SDK's HTTP transport is stateless by
default and one server answers every caller. `AddJintBrowser` therefore registers **one** browser and one
session for the process, which is exactly right for stdio and wrong for a shared HTTP endpoint.

A host that serves HTTP and needs a session each does it through the SDK's own
`HttpServerTransportOptions.ConfigureSessionOptions` and `RunSessionHandler`: build a `BrowserAgent` per
session there, bind `BrowserTools` and `BrowserResources` instances to it, and dispose it in that handler's
`finally`. That is the SDK's session-ended hook, and it is what `AddJintBrowser` deliberately does not
assume on your behalf.

## What it cannot do

No screenshots, no PDFs, no layout — so nothing that depends on where a box really is, and no drag and drop.
No iframe scripting, no `WebAssembly`, no IndexedDB, no media. Images are never fetched; the reference is in
the request log with the reason instead. A hover fires `mousemove` but not `mouseenter`, so a menu written
against the latter does not open.

The engine underneath is an interpreter, so a page costs a fraction of Chromium's memory and CPU and some
multiple of its wall-clock time. What the whole stack can and cannot do — and how much of it the
web-platform-tests measure rather than claim — is in
[Jint's README](https://github.com/sebastienros/jint#headless-browser-opt-in-package).

Licensed under BSD-2-Clause, like the rest of Jint.

## HTTP response cache

The session's HTTP cache is disabled by default. Configure `BrowserAgentOptions.HttpCache` when embedding,
or pass `--http-cache memory`, `--http-cache-dir <path> --http-cache-partition <visitor-id>`, or
`--http-cache-temporary` to `jint-browser mcp`. The CLI also exposes finite byte, count and entry-size
budgets. Persistent identities belong to one visitor; temporary storage is deleted on context close.
Closing and reopening the session preserves its configuration. See the
[command-line cache options](../Jint.Browser.Tool/README.md#http-response-caching).

---

### Overview (`Jint.Browser.Tool/README.md`)

# jint-browser

A headless browser on the command line, from [Jint](https://github.com/sebastienros/jint) and
[Jint.HtmlParser](../Jint.HtmlParser/README.md). It parses HTML, runs a page's scripts against a real DOM,
follows its network, and answers what the page turned out to be — as markdown, as text, as its accessibility
tree, or over the Chrome DevTools Protocol so that Puppeteer and Playwright can drive it.

**It renders nothing.** Geometry comes from a synthetic flat box model; there are no painted pixels,
screenshots or PDFs, and there is no browser to download: it runs in one process. What it costs is a
fraction of Chromium's memory and CPU per page; what it costs you back is wall-clock time, because the
JavaScript is interpreted.

```bash
dotnet tool install -g Jint.Browser.Tool
```

## Installation and standalone downloads

Starting with 5.0, installation requires the **.NET 10 SDK or later**. The SDK selects a self-contained
Native AOT package for the current platform; running it does not require a .NET runtime.

The [GitHub Releases page](https://github.com/sebastienros/jint/releases) also carries the same executable
as a single-file download, with no SDK or runtime installation required:

| Platform | x64 | arm64 |
| --- | --- | --- |
| Linux (glibc) | `jint-browser-linux-x64` | `jint-browser-linux-arm64` |
| macOS | `jint-browser-osx-x64` | `jint-browser-osx-arm64` |
| Windows | `jint-browser-win-x64.exe` | `jint-browser-win-arm64.exe` |

Download the file for your OS and architecture and compare its SHA-256 hash with the release's
`SHA256SUMS`. On Linux/macOS, run `chmod +x` on the downloaded file, then rename it to `jint-browser`
and place it on your `PATH`. On Windows, rename it to `jint-browser.exe` and place it on your `PATH`.
No Apple notarization or Windows Authenticode signature is provided.

Linux builds use Ubuntu 22.04 (x64) and Ubuntu 24.04 (arm64), so they require at least those versions'
glibc and the usual .NET native dependencies, including ICU, OpenSSL and zlib. Alpine/musl is not included.
No invariant-globalization mode is used: JavaScript `Intl` remains available.

Native publishing is scoped to this tool, not a general trimming guarantee for browser library consumers.
To build it from source, use the .NET 10 SDK and the target OS's
[Native AOT prerequisites](https://learn.microsoft.com/dotnet/core/deploying/native-aot/):

```bash
dotnet publish Jint.Browser.Tool/Jint.Browser.Tool.csproj -c Release -f net10.0 \
  -r linux-x64 -p:BrowserToolNative=true
```

Use `dotnet pack -p:BrowserToolNative=true` on that project without a RID for the NuGet selection manifest,
and once per RID with `-r` for each implementation package. All seven packages must have the same version;
publish the six implementations before the manifest. The release workflow does this automatically.

## Reading a page

```bash
# The page as CommonMark, narrowed to its main content and capped
jint-browser fetch https://example.org/article --main-content --max-length 4000

# The accessibility tree: roles, names and states, one line per node
jint-browser fetch https://example.org/ --dump ax

# The document after its scripts have run
jint-browser fetch https://example.org/ --dump html

# A local file, with its own file: URL as the base
jint-browser fetch ./page.html --dump text
```

`--dump` is `markdown` (the default), `text`, `html` or `ax`. `--main-content` narrows the answer to the
first `<main>`, `[role=main]` or `<article>` the document has, and `--max-length` caps it at a word boundary
with a `[truncated]` marker so a short page and a cut one are told apart; both are refused with `--dump html`,
because a narrowed or truncated document is not a document. The answer goes to standard output as UTF-8 with
no byte order mark; everything the page's scripts got wrong goes to standard error.

## Waiting, headers and cookies

```bash
jint-browser fetch https://example.org/app \
  --wait-until networkidle \
  --timeout 60s \
  --header 'Authorization: Bearer …' \
  --cookie session=abc
```

`--wait-until` is `commit`, `domcontentloaded`, `load` (the default) or `networkidle` — the last being
`load` plus half a second in which the page made no request, which is what a single-page application needs
before it has anything to read. `--header` and `--cookie` may each be given more than once.

## Asking a question instead of reading the page

```bash
jint-browser eval https://example.org/ "document.querySelectorAll('article').length"
jint-browser eval https://example.org/ "[...document.links].map(a => a.href)"
```

The expression is evaluated in the loaded page and its result is serialized by `JSON.stringify` **in the
page**, so what comes out is what a script in that document would have got.

## Serving the protocol

```bash
jint-browser serve --port 9222
```

That is a browser on `http://127.0.0.1:9222` that Puppeteer, PuppeteerSharp, Playwright, Playwright for .NET,
chrome-remote-interface and the Chrome DevTools front end all connect to over CDP — `connect`, not `launch`:

```js
const browser = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9222' });
const page = await browser.newPage();
await page.goto('https://example.org/');
console.log(await page.title());
```

`--port 0` asks the operating system for a port and the banner says which. **The endpoint is
unauthenticated**, exactly as it is in Chrome: anything that can reach it can run script in this process, so
`--host` defaults to `127.0.0.1` and should stay there.

`--no-images` fetches no image at all, which is what a crawler that only reads the DOM wants: each `<img>`
is still listed among the page's requests with the reason it was not fetched, but no socket is opened and
the element fires neither `load` nor `error`, so `img.complete` stays `false`. `fetch` and `eval` take it too.

## Serving a browser to an agent

```bash
jint-browser mcp
```

That is a [Model Context Protocol](https://modelcontextprotocol.io/) server on standard input and output,
over one browsing session: an agent navigates, reads the page as its accessibility tree or as markdown, and
clicks, fills and types its way through it. In Claude Desktop's `claude_desktop_config.json`, VS Code's
`mcp.json`, or any client that starts a server as a child process:

```json
{
  "mcpServers": {
    "jint-browser": {
      "command": "jint-browser",
      "args": ["mcp"]
    }
  }
}
```

or, in Claude Code:

```bash
claude mcp add jint-browser -- jint-browser mcp
```

**`mcp` hardens the pages by default** — the opposite of the other commands, because a client is by
definition pointing this at content nobody vouched for. `--trusted` turns the profile off and
`--allow-private-network` lets an agent reach your own machine. The tools, the `ref=` handles that make a
snapshot actionable, and why stdio is the only transport are in
[`Jint.Browser.Mcp`'s README](https://github.com/sebastienros/jint/blob/main/Jint.Browser.Mcp/README.md).

## Loading content nobody vouches for

```bash
jint-browser fetch https://somewhere-unknown.example/ --untrusted --max-task-duration 2s --memory-limit 256mb
```

`--untrusted` hardens every page: no `eval`, no `new Function`, no CLR interop, no module loader, and bounds
on statements, wall-clock time, allocation, recursion and regular expressions — plus `--block-private-network`
on by default, so a page cannot reach `localhost`, a private address or a cloud metadata endpoint. Pass
`--allow-private-network` to load a page on your own machine under it anyway.

`--max-task-duration` and `--memory-limit` bound **one turn** of a page — one call the tool makes, one drain
of the event loop, one inline `<script>` — rather than the run as a whole, which is what a pumped event loop
allows anyone to bound.

## HTTP response caching

Caching is disabled by default. `serve`, `fetch`, `eval` and `mcp` accept the same cache switches:

```bash
jint-browser fetch https://example.com --http-cache memory
jint-browser serve --http-cache-dir ~/.cache/jint --http-cache-partition visitor-42
jint-browser mcp --http-cache-temporary --http-cache-max-bytes 32mb
```

`--http-cache disabled|memory|disk` selects storage. `--http-cache-dir <path>` implies disk mode when no
mode is supplied. Persistent disk storage requires both that directory and an explicit
`--http-cache-partition <id>` identifying the same visitor across runs. Different visitors must use
separate identities. `--http-cache-temporary` instead creates a unique disposable directory under the
supplied directory, or the system temporary directory, and removes it on context close. It cannot be
combined with a persistent identity.

The finite limits are `--http-cache-max-bytes <size>` (64mb), `--http-cache-max-entries <n>` (1024), and
`--http-cache-max-entry-bytes <size>` (4mb). Byte units are binary. Invalid combinations and cache limits
supplied with caching disabled are usage errors. An inaccessible cache directory also returns exit code 1.

For `serve`, the persistent identity belongs to the default browser context. Additional CDP contexts have
independent memory caches or unique temporary disk partitions; they never reuse that persistent visitor's
entries. MCP applies the configuration to its session context, including after `close` and reopening.
Memory caching lasts for one context; disk persistence can reuse responses across separate tool invocations.

For `fetch` and `eval`, `--header 'Cache-Control: no-cache'` requests validation and `no-store` bypasses
cache reads and writes. CDP clients can use `Network.setCacheDisabled` and `Network.clearBrowserCache`.
See [HTTP cache policy and lifecycle](../docs/guide/browser-http-cache.md) for freshness, credentials,
size limits and disk failure behavior. Cache hits still incur consumer body copies.

## Exit codes

| Code | Meaning |
| --- | --- |
| `0` | the page loaded and the command answered |
| `1` | the command line was wrong, or named a file or scheme that does not work |
| `2` | there was no document to show: a refused URL, a transport failure, or a timeout |
| `3` | the page loaded and something it ran exceeded its time or allocation budget |
| `4` | the expression `eval` was given threw |

A `404` or a `500` is exit code `0`: the error page is a document, and it is usually the one a caller
scraping it wants.

## What it cannot do

No rendering, so no screenshots, no PDFs, no `canvas` and nothing that depends on where a box really is. No
iframe scripting, no `WebAssembly`, no IndexedDB, no media. Images are never fetched; the reference is
recorded in the request log with the reason instead.

Full documentation of the package under it — what a page can and cannot do, and how much of it the
web-platform-tests measure rather than claim — is in
[Jint's README](https://github.com/sebastienros/jint#headless-browser-opt-in-package).

Licensed under BSD-2-Clause, like the rest of Jint.

---

### Overview (`Jint.Browser/README.md`)

# Jint.Browser

A headless browser in one .NET process, from [Jint](https://github.com/sebastienros/jint) and
Jint.HtmlParser. The native parser supplies the document tree and CSS model; Jint runs
the document's scripts; this package supplies their bindings and the page runtime. It
navigates, runs a page's scripts against a real DOM, follows its network, keeps its cookies and storage, and
answers what the page turned out to be. Its flat box model supplies geometry; it paints no pixels and
produces no screenshots. There is no browser binary to download or launch.

```c#
await using var browser = new Browser();
var page = await browser.NewPageAsync();

await page.NavigateAsync("https://example.org/login");
await page.SubmitFormAsync("#login");

var user = await page.EvaluateAsync<string>("document.querySelector('#user').textContent");
```

A browser can also be published on a [`Jint.DevTools`](https://www.nuget.org/packages/Jint.DevTools) server
with `server.AddBrowser(browser)`, which makes every page a Chrome DevTools Protocol `page` target that
Puppeteer, PuppeteerSharp, Playwright and Playwright for .NET drive over `connect` — in the same process,
with nothing to install. For the command line, see
[`Jint.Browser.Tool`](https://www.nuget.org/packages/Jint.Browser.Tool); for an agent,
[`Jint.Browser.Mcp`](https://www.nuget.org/packages/Jint.Browser.Mcp) serves the same page over the Model
Context Protocol.

Requires .NET 8 or later. The Browser library does not currently declare trimming or NativeAOT
compatibility. Native publication of the closed browser tool is a separate consumer contract.

Stylesheet installation loads native `@import` graphs in source order, resolves nested URLs against
each response's final URL, and bounds cycles without merging sibling sheet identities. CSSOM
`insertRule` and `deleteRule` queue import updates after the current script's microtasks; style reads
never fetch. Removed or replaced owners cannot receive stale imported sheets. Import failures are
reported and an initial linked-sheet failure raises the owner's `error` event before window load.
Encoding currently follows response headers and the inherited encoding; CSS BOM/`@charset` selection
and response MIME eligibility remain incomplete.

What a page can and cannot do, the per-page budgets, `ForUntrustedContent`, and how much of it is measured
rather than claimed are in
[Jint's README](https://github.com/sebastienros/jint#headless-browser-opt-in-package).

Licensed under BSD-2-Clause, like the rest of Jint.

Automatic HTTP caching is opt-in per context; see [configuration, HTTP policy and persistence](../docs/guide/browser-http-cache.md).

---

### Overview (`Jint.DevTools/README.md`)

# Jint.DevTools

A [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) server for
[Jint](https://github.com/sebastienros/jint), so a debugging client can attach to an engine your host is
already running. It speaks CDP over a WebSocket, and a Jint engine appears to a client the way a Node process
does — a target it can list, attach to and evaluate in — with no browser anywhere in the picture. `Runtime`,
`Debugger`, `Profiler`, `Console`, `Log`, `Target`, `Browser` and `Schema` answer; everything else is
honestly `-32601`, which is what a client feature-detects on.

```c#
using Jint.DevTools;

var engine = new Engine(options => options.UseDevTools());

await using var server = new DevToolsServer(new DevToolsServerOptions { Port = 9222 });
server.AddTarget(new EngineTarget(engine));
server.Start();

// The host's own loop is what runs the engine AND answers the protocol.
while (running)
{
    engine.Tasks.ProcessTasks();
    engine.Tasks.WaitForScheduledWork(TimeSpan.FromMilliseconds(50));
}
```

Point Chrome's `chrome://inspect` at `127.0.0.1:9222`, or connect PuppeteerSharp with
`new ConnectOptions { BrowserURL = "http://127.0.0.1:9222" }`. **The endpoint is unauthenticated**, exactly as
it is in Chrome: anything that can reach it can evaluate arbitrary script in your process, so `Host` defaults
to `127.0.0.1` and should stay there.

Requires .NET 8 or later, and is Native AOT compatible — everything the protocol serializes goes through a
source-generated `System.Text.Json` context, and a published native binary is driven over a real socket in
CI. [`Jint.Browser`](https://www.nuget.org/packages/Jint.Browser) adds the page domains (`Page`, `DOM`,
`Network`, `Fetch`, `Input`, `Emulation`, `Storage`, `Accessibility`) on the same session core.

Which thread answers a command, what each domain answers, and how to try it from the REPL are in
[Jint's README](https://github.com/sebastienros/jint#chrome-devtools-protocol-opt-in-package).

Licensed under BSD-2-Clause, like the rest of Jint.

---

### Overview (`Jint.HtmlParser/README.md`)

# Jint.HtmlParser

Jint.HtmlParser is an experimental native markup package for .NET 8 and .NET 10. Its public API is provisional until **ACC-06 (external package and API acceptance)** in the
[acceptance tracker](https://github.com/sebastienros/jint/blob/main/docs/design/html-parser-completeness.md)
is explicitly closed. Internal Browser use and API snapshots do not close that gate. Public signatures,
behavior and value-type layouts may change between prereleases, including positional record structs such
as `ShadowRootInit` and `BoundaryPoint`; pin a tested package version when integrating.

The package remains prerelease even when Jint releases GA. Existing prerelease inputs are preserved
(for example `5.0.0-preview-123`); stable version inputs become `5.0.0-experimental-0` by default,
or `5.0.0-experimental-<BuildNumber>` when a build number is supplied. This applies to explicit
`Version` and `PackageVersion` overrides as well as the normal prefix/suffix calculation. The numeric
base follows the repository release; it is not a promise that this package's API has frozen.
Promotion to a stable parser package requires a separate reviewed change removing the build policy
and recording the external acceptance evidence. Jint.Browser can retain its repository release version
while declaring the calculated prerelease parser dependency.

The [API usage guide](https://sebastienros.github.io/jint/packages/jint-htmlparser/) covers installation,
parsing, tree manipulation, CSS syntax, XPath, serialization, limits and threading.

Its implemented public surface includes:

- A mutable document tree with elements, attributes, text, comments, CDATA and processing instructions.
- Inert HTML document and contextual fragment parsing through `MarkupParser.ParseHtml` and `ParseHtmlFragment`, with lexical limits and bounded diagnostics.
- XML document and fragment parsing through `MarkupParser.ParseXml` and `ParseXmlFragment`, and strict SVG-root parsing through `ParseSvg`.
- CSS Syntax parsing of whole stylesheets, rules, declarations and component values. These methods return syntax, without CSS property validation or computed styles.
- Native mutation subscriptions with explicit record draining through `Document.ObserveMutations`.
- Explicit HTML/XML serialization through `MarkupSerializer`, with output limits, cancellation and selected native shadow roots.
- XPath 1.0 compilation, evaluation and selection through `NativeXPath`, preserving namespaces and native result identities.

```csharp
using Jint.HtmlParser;

var document = MarkupParser.ParseXml("<root><item/></root>");
var root = document.DocumentElement!;
var fragment = MarkupParser.ParseXmlFragment("<next/>", root);
root.AppendChild(fragment);
```

XML parsing defaults to a ceiling of 10,000,000 UTF-16 entity replacement units, including when
other limits are supplied through `new ParseLimits()`. Select `ParseLimits.Unbounded` or explicitly
set `MaxEntityExpansionCharacters = 0` only for trusted inputs. Exceeding a limit raises
`ParseLimitException`, separately from XML syntax errors.

XML results expose immutable `SkippedXmlEntities`, `XmlNotations` and
`XmlDtdProcessingInstructions` inventories. DTD instructions retain target, data and original UTF-16
offsets in encounter order (including repeated parameter-entity invocations). They are parse metadata,
not DOM children: DOM serialization and XPath do not relocate them into the document. Document
clones preserve these inventories; importing/adopting nodes or parsing a fragment does not transfer
or overwrite them. No external resolver is exposed.

HTML parsing recovers malformed markup and creates implied document structure. Fragment results belong to the context owner document and leave existing children untouched. `HtmlParseOptions.ScriptingEnabled` changes grammar without executing scripts. Native HTML parsing performs no network requests.

Input length and token limits default to zero (disabled); CSS nesting also defaults to zero.
The finite XML entity ceiling is not an overall memory or time budget. Supply bounds suited to your input
and a cancellation token for cooperative cancellation. For example:

```csharp
var bounded = MarkupParser.ParseHtml("<p>Hello</p>", new HtmlParseOptions
{
    Limits = new ParseLimits { MaxInputCharacters = 100_000, MaxTokenCharacters = 20_000 }
});
```

Whole-sheet CSS parsing returns immutable syntax and the exact original source:

```csharp
using Jint.HtmlParser;
using Jint.HtmlParser.Css;

CssStyleSheetSyntax sheet = MarkupParser.ParseCss("@future x; p { color: var(--theme); }");
foreach (CssRuleSyntax rule in sheet.Rules)
{
    string originalRule = sheet.Source.Substring(rule.Span.Start, rule.Span.Length);
    Console.WriteLine(originalRule);
}
```

`ParseCss` consumes the whole input with standard CSS Syntax recovery; empty input succeeds.
Rules expose immutable preludes and nested token, function and block components with original UTF-16 spans.
Unknown at-rules, unsupported properties, invalid property values and pending substitutions remain syntax.
Parsing does not validate selectors or properties, resolve URLs, load imports, attach styles, or compute a cascade or geometry.
`CssParseOptions` supplies the existing limits and optional diagnostic collector; cancellation is supported.
The result retains no parser, options or collector. Public mutable CSSOM promotion and equivalent CSSOM benchmarks are separate work.

Serialization selects its format explicitly, without moving nodes or fetching resources:

```csharp
string html = MarkupSerializer.ToHtml(document);
string xml = MarkupSerializer.ToXml(document, requireWellFormed: true,
    limits: new SerializationLimits { MaxOutputCharacters = 100_000 });
```

`ToHtmlChildren` and `ToXmlChildren` omit the container's tags and use template contents where appropriate.
`requireWellFormed` enables the DOM Parsing algorithm's checks; it is not general XML/DTD validation.
Output-limit failures throw `SerializationLimitException` without returning partial output.
Normal HTML output omits attached shadow roots. `Element.AttachShadow` returns the real native
`ShadowRoot` (even for closed mode); `OpenShadowRoot` exposes only open roots. Pass roots explicitly
in `HtmlSerializationOptions`, or pass `serializableShadowRoots: true` to include reachable roots marked
serializable. Attachment does not execute Browser custom-element reactions or resolve its definitions.

XPath expressions can be reused across documents:

```csharp
NativeXPathExpression expression = NativeXPath.Compile("//item");
IReadOnlyList<object> nodes = NativeXPath.Select(document, expression);
double count = NativeXPath.Evaluate(document, "count(//item)").NumberValue;
```

Unprefixed XPath element tests match no namespace; use a namespace resolver for HTML/XML namespaces.
Contexts can be a `Node`, an attached/detached non-XMLNS `Attr`, or a captured `XPathNamespaceBinding`.
Results contain only native nodes, attributes and namespace bindings. Membership and captured scalar
values remain stable after mutation; the selected native objects themselves are not frozen.
Reusing a namespace context rejects a binding whose URI is no longer in scope. No BCL cursor or
mutable expression escapes. Cancellation brackets BCL work but is not a hard timeout during its
non-preemptible compilation/evaluation intervals.

`Jint.Browser` now uses this native tree, parser and CSS model. Neither production packages nor tests
depend on AngleSharp. AngleSharp, AngleSharp.Css and AngleSharp.Xml remain only in `Jint.Benchmark`
as comparison controls; dependency removal is not a claim of complete behavioral parity.

## Threading

After parsing finishes and the tree is safely published to other threads,
concurrent immutable inspection of tree links, node names, character data and
attribute values is supported, including cold `Text.Data`/`Attr.Value` getters,
text-content reads and serialization with separate per-call state. Keep the tree
unchanged for the entire read operation. Parsing sessions, mutation, adoption,
live ranges/iterators and mutation subscriptions require one writer and external
synchronization with all readers; one writer alone does not make overlapping
reads safe. Operations that initialize mutable DOM state or indexes (including
selector/id queries, form-control state and processing-instruction attribute
views) also require external synchronization. This is not a blanket thread-safety
promise for every method that looks like a read.

Jint.Browser page trees retain their page-loop affinity: access them through the
page's scheduled operations. The standalone parser's immutable-read support does
not permit reading a live Browser DOM from another thread while its page can run
script, parse or mutate it.

## Contributor documentation

Implementation contracts, regeneration, corpus maintenance and package verification belong in the
[contributor guide](https://github.com/sebastienros/jint/blob/main/Jint.HtmlParser/CONTRIBUTING.md).
The [acceptance tracker](https://github.com/sebastienros/jint/blob/main/docs/design/html-parser-completeness.md)
records remaining work; dependency removal does not establish complete behavioral parity.

## Renderless CSS boundary

The [renderless CSS contract](https://github.com/sebastienros/jint/blob/main/Jint.HtmlParser/CONTRIBUTING.md#renderless-css-boundary)
defines Browser's text-value scope and the deliberately limited declaration grammar.

## Browser integration contract

The [Browser integration contract](https://github.com/sebastienros/jint/blob/main/Jint.HtmlParser/CONTRIBUTING.md#browser-integration-contract)
defines reviewed internal hooks, snapshots and parser/Browser ownership.

---

### Overview (`Jint.Tests.Test262/README.md`)

To generate test suite, run:

```
dotnet tool restore
dotnet test262 generate
```

A build does this for you whenever `Test262Harness.settings.json` or `.config/dotnet-tools.json`
changes. The generator pinned in `.config/dotnet-tools.json` and the `Test262Harness` library pinned
in `Directory.Packages.props` come from one release and carry the same version; the build fails while
they differ.

The test run downloads the archive for the generated suite's exact `SuiteGitSha` into a unique
staging directory, verifies the pinned corpus content digest, and atomically publishes it to the
cache. Transient acquisition failures are retried up to three times. Set `JINT_TEST262_CACHE` to
choose the directory that holds `test262-<sha>.zip`. Set `JINT_TEST262_OFFLINE=1` to require that
exact cached archive and disable network acquisition; a missing, partial, corrupt, or mismatched
archive fails the run rather than falling back to another corpus.

## Reusable provider examples

`NodaTimeZoneProvider.cs` and `IcuCldrProvider.cs` in this directory are not just test
fixtures — they are working examples of the provider extension points described in the
[internationalization guide](../docs/guide/internationalization.md#intl-and-temporal-data). The test
suite reaches its current test262 conformance numbers by registering them on the
`Engine.Options.Temporal.TimeZoneProvider` and `Engine.Options.Intl.CldrProvider`
properties; end users can copy these files into their own projects (with the matching
`NodaTime` / `ICU4N` NuGet packages) to get the same behaviour.

---

### Overview (`docs/README.md`)

# Jint documentation

The documentation site is built with VitePress.

## Run locally

From the repository root:

```bash
cd docs
npm ci
npm run dev
```

VitePress prints the local URL, normally `http://localhost:5173/`.

## Build the production site

```bash
cd docs
npm ci
npm run build
npm run check-links
npm run preview
```

GitHub Actions runs the same production build for pull requests. A push to
`main` that changes the site or its workflow publishes the result to GitHub
Pages.

The link checker reads the generated site under its configured base path. It
validates internal pages, assets, and heading anchors without requesting
external URLs, so links to packages or services that are not published yet do
not block the build.

## Validate documentation changes

From `docs/`:

```bash
npm run test:tools
npm run samples:check
dotnet build snippets/Jint.Documentation.Samples/Jint.Documentation.Samples.csproj -c Release
DOCS_BASE=/jint/ npm run build
DOCS_BASE=/jint/ npm run check-links
npm run lint
```

## Compiled C# samples

High-traffic examples are maintained as `#region docs:name` blocks in
`snippets/Jint.Documentation.Samples`. The sample project compiles in Release,
and the Markdown references each region with stable markers:

```markdown


```

Run `npm run samples:sync` after changing a sample. Commit the resulting
Markdown so GitHub, NuGet.org, and the documentation site all show the reviewed
code. CI runs `npm run samples:check` and fails if a block is stale or missing.

## Native parser documentation

The [docs-site API guide](packages/jint-htmlparser/index.md) covers standalone API usage with compiled
examples. The [package README](https://github.com/sebastienros/jint/blob/main/Jint.HtmlParser/README.md)
covers standalone embedding. The [contributor guide](https://github.com/sebastienros/jint/blob/main/Jint.HtmlParser/CONTRIBUTING.md)
covers parser contracts, regeneration, corpora and package verification, and the
[acceptance tracker](design/html-parser-completeness.md) records open gates. Markdown lint covers both
package and contributor documents; the instruction guardian tests check parser routing and local anchors.

---

### Overview (`Jint.Benchmark/ParserCorpus/README.md`)

# Parser comparison corpus

These twelve UTF-8 inputs were written for this repository and are distributed under its
[BSD 2-Clause license](../../LICENSE.txt). They use fixed counts and names; none downloads content,
samples a live site or depends on a random seed. The files are copied to the benchmark output directory.

| Input | Bytes | Coverage |
| --- | ---: | --- |
| `html-small.html` | 535 | Doctype, attributes, entities, script rawtext, textarea, template, inline SVG and foreign HTML |
| `html-recovery.html` | 247 | Optional end tags, duplicate attributes, table cells and misnested formatting |
| `html-large.html` | 55,095 | 256 repeated articles with attributes, text, entities and links |
| `html-recovery-large.html` | 11,285 | 128 repeated broken paragraphs, tables and formatting elements |
| `svg-small.svg` | 374 | XML prolog, namespaces, gradient, text and XLink attribute |
| `svg-large.svg` | 23,205 | 192 groups with paths, transforms and text |
| `xml-small.xml` | 235 | Default and prefixed namespaces, entities, CDATA and comment |
| `xml-large.xml` | 26,229 | 256 namespaced catalog items and attributes |
| `css-rules-small.css` | 277 | Layer, media, supports, selectors, custom property and keyframes |
| `css-rules-large.css` | 20,347 | 192 nested selector rules and declarations |
| `css-declarations-small.css` | 120 | Shorthands, custom property, color and URL token |
| `css-declarations-large.css` | 16,195 | 256 custom properties and repeated standard declarations |

`HtmlParserCorpusBenchmark` parses each source into a complete AngleSharp document, stylesheet or
declaration block. HTML and the two error-recovery cases use the HTML parser. Standalone SVG and XML
use the XML parser; inline SVG in `html-small.html` exercises the HTML foreign-content rules. The
source string and parser instance are prepared in `GlobalSetup`; each measured operation creates a
fresh result. The case label includes UTF-8 input bytes, and `[MemoryDiagnoser]` tracks managed
allocation per operation.

Run `dotnet run -c Release --project Jint.Benchmark/Jint.Benchmark.csproj --
--validate-html-parser-corpus` from the repository root to parse all inputs without timing them.
It compares the resulting tree/rule counts with pinned AngleSharp shapes and checks HTML entities,
rawtext, template content, namespaces, table repair, XML CDATA and values, and CSS property values
inside conditional rules and keyframes. The shape includes a template's content fragment and XML
CDATA text. Count-preserving value mutations in HTML, XML and CSS must fail validation. These
pinned counts detect accidental fixture or baseline changes; they are an
AngleSharp reference, not a standards oracle.

`XmlSvgParserComparisonBenchmark` pairs the four standalone XML/SVG inputs with the public
`MarkupParser.ParseXml` and `ParseSvg` APIs. Its AngleSharp row is the control. Both rows receive
the same loaded string, keep parser configuration outside the timed method, and return a fresh,
complete document. `dotnet run -c Release --project Jint.Benchmark/Jint.Benchmark.csproj --
--validate-xml-svg-parser-comparison` compares the trees without timing. The comparer checks node
kinds, element and attribute names, prefixes, namespaces, values and order, text/CDATA, comments,
processing instructions, doctypes, parent links and owner documents. It also rejects deliberate
count-preserving changes to content, node kind, attribute order, regular attribute namespace and
SVG XLink value. A separate untimed XML input exercises an internal entity, PI and doctype because
the four throughput fixtures do not all carry those constructs.

The XML comparison uses default, unbounded parsing with no external entities in the inputs.
This is an **equal parse workload with a documented default-`xmlns` representation difference**,
not a claim of identical DOMs. Pinned AngleSharp.Xml 1.2.0 reports the unprefixed `xmlns`
declaration with a null/empty namespace; the native DOM reports the XMLNS namespace. Outside
timing, the comparer requires precisely those two tuples, including the same name, value, position
and owner. A prefixed `xmlns:m` or `xmlns:xlink` declaration must use XMLNS on both sides; ordinary
attributes and elements require exact namespace equality. Deliberately wrong declaration tuples,
missing or reordered declarations, and corrupt descendant bindings are rejected. The rows compare
parsed trees; SVG document MIME branding differs between the generic AngleSharp XML entry and
native `ParseSvg`.

`HtmlParserComparisonBenchmark` pairs the four HTML inputs with public `MarkupParser.ParseHtml`.
Both rows receive identical cached strings, reuse scripting-disabled configuration, retain no
source references or diagnostics, and produce fresh complete documents. Setup checks all node
kinds, values, attribute order/namespaces, child order, template subtrees and owner/parent links.
Untimed corruption probes exercise content, attributes, comments, namespaces and ownership.
Run `--validate-html-parser-comparison` for these checks without starting a timing run.

`HtmlControlsParserComparisonBenchmark` adds three separate pairs over 256 text/url/email/password
inputs: parsing alone, parsing plus first value access, and warm value access. First access includes
a fresh parse and control-array construction on both sides; warm access has row-owned parsed
documents and control arrays outside measurement. Setup verifies identical values and that native
parsing leaves input value sidecars absent. These rows deliberately expose deferred sanitizer cost.
URL and email values are already canonical: the pinned AngleSharp getter preserves surrounding URL
spaces while native HTML normalization removes them, so such inputs cannot establish equal observable
value workloads. Setup compares each control value, not only a checksum.
The native semantic row invokes the production native input-state accessor through benchmark
internal access; it does not claim a public parser-package semantic API.

CSS remains AngleSharp-only: native public CSS Syntax results are not comparable to AngleSharp
CSSOM output. A future candidate must return a fully materialized result of the same kind and validate it during
setup. A tokenizer-only row would not be comparable to document-producing rows. Use the
repository's paired benchmark procedure for reported timing numbers; no timing results are
recorded with this corpus.

---

### Overview (`Jint.Benchmark/ParserPrimitives/README.md`)

# Markup parser primitive comparisons

Dedicated decision benchmarks for the native HTML/XML parser work. These compare equivalent small
operations, not Markdown against HTML or a scanner against a complete parser. They change no runtime
parser behavior. The newest supported benchmark TFM is **net10.0**, selected by the existing project;
do not add older runtime jobs or an installed preview runtime to these comparisons.

## What Markdig actually does

Source inspected at commit **56e9c238584a44a169f174c881855c049768634c**, dated 2026-09-20:

| Markdig source | Observed technique | Relevant candidate here |
| --- | --- | --- |
| [CharacterMap](https://github.com/xoofx/markdig/blob/56e9c238584a44a169f174c881855c049768634c/src/Markdig/Helpers/CharacterMap.cs) | 128-entry ASCII array, optional frozen dictionary for other characters; cached SearchValues for locating registered opening characters | ASCII membership table and cached delimiter search |
| [ParserList](https://github.com/xoofx/markdig/blob/56e9c238584a44a169f174c881855c049768634c/src/Markdig/Parsers/ParserList.cs) | Builds character-to-parser-array dispatch once | Reuse immutable lookup structures; do not build them per character/token |
| [CharHelper](https://github.com/xoofx/markdig/blob/56e9c238584a44a169f174c881855c049768634c/src/Markdig/Helpers/CharHelper.cs) | Cached SearchValues.Contains for punctuation/email sets; a bit-mask fast branch for ASCII whitespace and separate Unicode handling | Test Contains and a simple guarded bit mask against the existing small `or` pattern |
| [HtmlRenderer](https://github.com/xoofx/markdig/blob/56e9c238584a44a169f174c881855c049768634c/src/Markdig/Renderers/HtmlRenderer.cs) | IndexOfAny with a cached four-character SearchValues; a direct two-character overload for soft escaping | Compare scalar scanning, direct span IndexOfAny and cached SearchValues for the same set |
| [HtmlBlockParser](https://github.com/xoofx/markdig/blob/56e9c238584a44a169f174c881855c049768634c/src/Markdig/Parsers/HtmlBlockParser.cs) | Reads/lowercases into a short stack span, then CompactPrefixTree.TryMatchExact against predefined HTML names | Distinguish exact name lookup from tokenization; evaluate predefined sets against Jint's materialized-name `or` pattern |

The tree supports prefix operations, but this Markdig HTML call uses **exact** matching. Its case folding,
CommonMark tag grammar and whitespace semantics must not be imported into HTML/XML rules. The bit-mask
candidate here tests the same technique with HTML's five ASCII whitespace characters; it is not a copy
of Markdig's full Unicode whitespace helper. The bool array similarly specializes the ASCII indexing
idea; these rows do not claim to measure the generic CharacterMap itself. No Markdig source or runtime
dependency is vendored by this benchmark.

The current Jint comparison targets were inspected in integration commit `37c8a6712`:
`Html/HtmlTokenizer*.cs` character classification, `Html/HtmlInput.cs` input boundaries, and
`Html/TreeConstruction/HtmlTreeBuilder.Body.cs::IsBlockStart`. XML's whitespace excludes form feed, and
XML scalar/name validation has separate rules. These HTML kernels are not replacements for those checks.

## Equal work and units

All hot tables/configuration and input strings are created outside timing. Each method consumes the
same input and returns a count/checksum used by BenchmarkDotNet. No engine or mutable shared parse state
exists. Lookup input strings are freshly materialized rather than interned literal hits.

| Class | Compared methods | One reported operation | Inputs |
| --- | --- | --- | --- |
| MarkupCharacterMembershipBenchmark | `or` pattern, ASCII bool table, cached SearchValues.Contains, guarded bit mask | One UTF-16 character; 4,096 checks per invocation | Text-heavy and whitespace-heavy mixtures, including non-ASCII nonmatches |
| MarkupDelimiterScanBenchmark | Scalar `or` loop, span IndexOfAny, cached SearchValues | Scan one entire named buffer and checksum every delimiter position | Short text, long sparse text, dense markup, Unicode text |
| MarkupTagMembershipBenchmark | String `or` pattern, ordinal HashSet, ordinal FrozenSet | One exact tag lookup; 256 checks per invocation | Hits, misses, deterministic mixed distribution; prefix/suffix/case/non-ASCII misses |
| MarkupLookupConstructionBenchmark | Construct whitespace table/SearchValues or block-tag HashSet/FrozenSet | Build one named lookup | Compare only constructors for the same set |

Every class reports **Op/s** and **Allocated B/op** through BenchmarkDotNet. Construction rows separately
expose the cost hidden by hot static reuse; their allocation totals are not retained-heap measurements.
No-table patterns have no lookup construction to measure. Do not compare character Op/s to buffer Op/s,
or claim a parser throughput increase from these kernels alone. The delimiter set is exactly `<`, `&`,
CR and NUL; ordinary LF and Unicode are deliberately not equivalent delimiter substitutions.

The full suite currently has 33 parameterized rows. Small fixed sets can already be compiled efficiently
from `or`; the collection candidates are hypotheses, not a presumption that a lookup is faster. A frozen
set benefits from static lifetime and can lose for short names or miss distributions. Table initialization
must be included in a later end-to-end experiment if the proposed lifetime is per parse.

## Validation and measurement

Correctness-only, with a fresh Release build:

```sh
dotnet run -c Release -f net10.0 --project Jint.Benchmark/Jint.Benchmark.csproj -- --validate-markup-primitives
```

This checks every UTF-16 value for whitespace equivalence; sliced/randomized delimiter buffers,
including exact first-match offsets at every suffix, NUL, CR, surrogate code units and SIMD-sized
boundaries; exact tag cases; and each configured benchmark's result. A failure aborts independently
of timing. Each GlobalSetup also verifies its own input. These checks exercise search semantics,
not XML well-formedness or full HTML parser conformance.

Run on an otherwise idle machine, using the repository's benchmark environment instructions:

```sh
JINT_BENCH_MODE=gate dotnet run -c Release -f net10.0 --project Jint.Benchmark/Jint.Benchmark.csproj -- \
  --filter '*MarkupCharacterMembershipBenchmark*' '*MarkupDelimiterScanBenchmark*' \
           '*MarkupTagMembershipBenchmark*' '*MarkupLookupConstructionBenchmark*' \
  --exporters json csv
```

The local class config adds only Op/s; it does not replace the repository job, select another runtime,
disable tiering/PGO or bypass the busy-machine check. Do not use `--job short` results, `--no-build`,
concurrent builds/tests, or machine-idle overrides to decide a winner. Keep runtime/architecture, commit,
raw reports and environment metadata with results. Latest TFM alone does not establish portability
between x64 and Arm64. Treat small differences as inconclusive until the repository's repeat/paired
measurement requirements are met.

No timing results have been recorded yet. A coordinated idle measurement window is required while the
parser implementation work runs in other worktrees.

## Adoption gate

Keep a candidate only when it wins the relevant distributions without harmful allocation or startup
tradeoffs. Before changing a parser, prove the exact grammar/state set and retained character/offset
semantics. A vectorized scan must stop at the proper chunk boundary, charge all skipped work, poll
cancellation at the established cadence, respect quotas and preserve CRLF/NUL/Unicode handling.
Cached sets belong to immutable grammar data, not engine/document/host state.

Runtime feature owners make any resulting optimization as a separate reviewed change. Run the full
affected conformance tests and a paired equal-work whole-parser benchmark afterward. These primitive
rows inform that work; they do not replace the standalone API, Browser parity or production dependency
removal gates.

---

### Overview (`Jint.Browser/Styling/README.md`)

# Native styling boundary

Browser follows the [renderless CSS boundary](../../Jint.HtmlParser/README.md#renderless-css-boundary).
`NativeCssStyleSheets.Install` stores owner identity, source/base URLs, source text and resource
revision. It does not parse inline attributes, run the cascade or perform layout. `NativeCssParsing`
owns demand-driven rule/media caches; inactive bodies stay raw until CSSOM inspection or matching
requires them. Imports load without parsing unrelated rule bodies.

`NativeCssQuery` memoizes matching sources and requested text values for one synchronous query.
It checks document, resource, imported-sheet graph, inline-block and Browser read witnesses.
Matching occurs once per element/query, including native shadow scope and slot/host inheritance.
No state is attached to native nodes. Origin, importance, encapsulation, inline style, layer rank,
specificity and source order select declarations; inherited properties and catalog defaults fill gaps.
Explicit revert/revert-rule/revert-layer values retain cascade rollback.

Values are text, not typed grammars. Custom properties inherit declared text. Ordinary values may
use bounded textual `var()` substitution with fallbacks (depth 32; expansion limit 1,000,000 characters).
It is not token substitution: no declaration-scope resolution, deferred shorthand evaluation, typed
functions, URL resolution or unit computation. Colors stay declared text in the cascade; `ResolvedStyle`
serializes color longhands as `rgb()`/`rgba()` only at the CSSOM and DevTools read boundary. A missing/cyclic expansion defaults
the property rather than selecting an earlier declaration. Substitution-produced rollback keywords
remain text. Simple display blockification stays; overflow axes no longer compute together.

The data catalog supplies a small initial-value table and inherited flags; other known names have
empty initial text. Only layout-used shorthands expand by whitespace splitting. `ResolvedStyle`
returns synthetic box width/height where available and text otherwise. Text extraction interprets
white-space keywords locally. DevTools and CSSOM consume the same values and native rule identities.

Style, media, supports, layer, import and font-face rules retain real models and mutation identities.
Other at-rules are plain CSSRule objects preserving cssText and never entering the cascade.
Font-face descriptors remain passive text. CSS.supports tests known/custom names and nonempty values,
with the existing condition/selector parser, not property grammars.

Keep `CssValueWork` and selector work checks in surviving loops. Cancellation and reentrant edits
must not publish obsolete state. Computed declarations stay read-only and live across later reads;
borrowed cross-realm mutator exception branding remains separate binding work.

---

### Overview (`Jint.Tests.Browser/Wpt/README.md`)

# The web-platform-tests browser lane

The `.any.js` lane hands a file to an engine. This one **loads a document**: a vendored `.html` is served by
`WptServer` at a real URL, a `Browser` navigates a fresh `Page` to it, and the document pulls in upstream's own
`resources/testharness.js` through a `<script src>` exactly as a browser does. The realm is a real `Window`
with a `document`, a `<div id=log>` and a `load` event, so the harness deciding what passed is upstream's and
there is nothing of Jint's between the corpus and the verdict. A document that drives input reaches
`test_driver`, and that goes through the same `InputDispatcher` the `Input` domain does — the
`testdriver-vendor.js` slot upstream ships empty is where the two meet.

**The corpus is vendored once.** Everything here runs out of `Jint.Tests/Wpt/Vendor/`, at the commit
[`Vendor/README.md`](../../Jint.Tests/Wpt/Vendor/README.md) names, byte-verified the way that file describes;
this project holds no copy of a vendored file. How the lane works — the results overlay, the synthesized
wrappers, the environment a document runs in, where a divergence goes — is
[`AGENTS.md`](AGENTS.md) beside this file. What follows is what the lane *found*.

**This is not the only web-platform-tests number, and the other one is not a rival.** The census below is
*ours* — our driver, a vendored subset, an exclusion row per failure — and it is a **gate**. The
[scoreboard](https://github.com/sebastienros/jint/blob/wpt-scoreboard/docs/wpt-scoreboard.md) is
*upstream's*: a nightly `wpt run` over `wptserve`, across ten whole suites, and it gates nothing. It runs
the same corpus at the same pin, so a suite it reports that this table does not have is a suite nobody has
vendored here yet. Its plugin is [`tools/wpt-scoreboard/`](../../tools/wpt-scoreboard/README.md).

## The lane, suite by suite

| Suite | Documents | Synthesized | Tests | Not passing |
| --- | --- | --- | --- | --- |
| `dom/events/` | 56 | 9 | 548 | 10 |
| `dom/nodes/` | 168 | 0 | 8,115 | 21 |
| `dom/collections/` | 8 | 0 | 43 | 0 |
| `dom/lists/` | 5 | 0 | 189 | 1 |
| `dom/traversal/` | 13 | 0 | 52 | 0 |
| `dom/ranges/` | 17 | 0 | 84 | 0 |
| `html/dom/` | 15 | 0 | 56,745 | 2 |
| `html/infrastructure/common-dom-interfaces/collections/` | 1 | 0 | 41 | 0 |
| `html/obsolete/requirements-for-implementations/other-elements-attributes-and-apis/` | 1 | 0 | 2 | 0 |
| `html/webappapis/scripting/events/` | 12 | 0 | 51 | 0 |
| `html/webappapis/scripting/processing-model-2/` | 25 | 0 | 44 | 0 |
| `html/semantics/embedded-content/the-img-element/` | 4 | 0 | 99 | 0 |
| `html/semantics/selectors/pseudo-classes/` | 27 | 0 | 122 | 1 |
| `custom-elements/` | 16 | 0 | 513 | 7 |
| `custom-elements/parser/` | 8 | 0 | 20 | 6 |
| `custom-elements/reactions/` | 14 | 0 | 255 | 28 |
| `custom-elements/upgrading/` | 2 | 0 | 7 | 0 |
| **total** | **392** | **9** | **66,930** | **76** |

*Measured on Windows.* **Documents** are `.html` files in this repository; **Synthesized** are the
`<name>.any.html` wrappers `WptServerWrappers` manufactures for a suite's `.any.js` files, which are bytes
nowhere. **Not passing** is a **ceiling**: a rise fails as a regression naming the suite and the size of it, a
fall fails as staleness, and the rewrite refuses to write a larger figure. Take the census with

```bash
# check the table (about ten seconds; it runs every document)
JINT_WPT_BROWSER_CENSUS=1 dotnet test --project Jint.Tests.Browser -c Release

# rewrite it from what the lane measures, then commit the diff
JINT_WPT_BROWSER_CENSUS=update dotnet test --project Jint.Tests.Browser -c Release
```

`JINT_WPT_BROWSER_CENSUS=update-raising-the-ceiling` is the one spelling that may write a *larger*
not-passing figure, for a corpus bump that genuinely arrives with new failures.

**A case is a document *at one variant*, and that is why `Tests` can move without `Documents` moving.**
A document may carry [`<meta name="variant" content="?query">`](https://web-platform-tests.org/writing-tests/testharness.html#variants)
elements; upstream's manifest then makes one test per declaration, at the document's path with that
string appended, and the document reads which one it is out of `location.search`. This lane enumerates
them the same way and names a case exactly as the manifest names it —
`dom/ranges/Range-in-shadow-after-the-shadow-removed.html?mode=open` — so an exclusion, a minimum-test
entry and a cause all key on the *case*, and there is deliberately no spelling that means every variant:
two variants are two runs, and a divergence measured in one is not evidence about the other. Two vendored
documents declare any, and both pass at every one of their five variants between them:
`dom/events/handler-count.html` (`?document`, `?window`, `?element`) and
`dom/ranges/Range-in-shadow-after-the-shadow-removed.html` (`?mode=closed`, `?mode=open`). The engine
lane's answer is the opposite one and is not a contradiction: `// META: variant=` sharding is ignored
there because one unsharded run of a `.any.js` file is the union of its `?1-1000` shards, while a query a
document branches on selects *different* tests that no single run is the union of.

## What this corpus says about this browser

**The eleven defects this lane first recorded are fixed.** They were filed as
[#3686](https://github.com/sebastienros/jint/issues/3686) to
[#3695](https://github.com/sebastienros/jint/issues/3695) — `document.createEvent` and the document
constructors, `window.event`, the report of a script's exception, the compiled handler's shape and scope, the
body's window-forwarded handlers and `HTMLFrameSetElement`, the legacy UI-event initializers, the default
passive value, one activation behaviour per click, the detached control's silent toggle, and named access on
the window — and the three seams two of them needed in the engine are
[#3696](https://github.com/sebastienros/jint/pull/3696).

**Two more left `NeedsTriage` with the script-loading path, and both were about a URL.** A `data:` URL is a
subresource now: `ParserDriver` answers the `data` arm of
[scheme fetch](https://fetch.spec.whatwg.org/#scheme-fetch) out of
[Fetch §5.2's processor](https://fetch.spec.whatwg.org/#data-url-processor) rather than over a socket, so
`<script src="data:text/javascript,…">` runs and the three `processing-model-2/` documents about it pass.
And a subresource's response URL carries its fragment, which is what
[Fetch](https://fetch.spec.whatwg.org/#concept-response-url) says a response URL *is* — the fragment is left
out of the request-target and of nothing else — so `<script src="support/syntax-error.js#">` reports the `#`
that the same element's `src` already reflected, instead of losing it to a re-serialization. Five rows and
two; the same processor also replaced the page's *second*, open-coded `data:` decoder, so a navigation to
one now reads its `charset` and takes a payload `Convert.FromBase64String` refuses.

What `NeedsTriage` holds now is one thing, and the exclusion table names it: **a form-associated custom
element has no form owner.** `window.customElements` exists now, and the one row of
`compile-event-handler-lexical-scopes-form-owner.html` that is left asserts that a compiled handler on an
`<x-foo static formAssociated>` sees the *form's* lexical scope — which needs the element to be a
form-associated element and take part in `form.elements`. This package records the flag and nothing
consults it: there is no `ElementInternals`. The file's other three rows pass.

**`@@unscopables` was another of them, and it is gone.** WebIDL puts one on the interface prototype object of
every interface with an `[Unscopable]` member, the former DOM integration's metadata cannot say which members those are, and
the answer is an `unscopables` list in `overrides.json` — the standard's half of the table, the way
`reflected` is — rather than a hand-edited `.g.cs`. DOM §4.2.8 and §4.2.9 mark every member of `ChildNode` and
`ParentNode`, which is seven names on `Element` and three or four on each of the other four interfaces that
include one; `compile-event-handler-symbol-unscopables.html` and `dom/nodes/remove-unscopable.html` pass
whole, nine rows between them. It matters outside a conformance suite for one reason: HTML compiles an inline
event handler with the element, its form owner and the document on the scope chain, so without it
`<div onclick="remove()">` calls the element's `remove()` instead of the page's own global.

The twenty-two `<a>`/`<area>` shapes of `Event-dispatch-single-activation-behavior.html` were the last of
them, and they pass now. What kept them red was not the page loop's scheduling but the fragment arm being
gated on the page's own load having returned and on the navigation gate being free: this file's tests run
*during* the parse, where neither is true, so every one of their fragment moves was queued as a whole
navigation behind the gate and landed after the two turns the file allows. The move is a same-document one
exactly when the request came from the document the page is showing, which is the question
[`Jint.Browser/Runtime/AGENTS.md`](../../Jint.Browser/Runtime/AGENTS.md#navigation-is-a-fetch-and-a-new-engine)
now says it asks.

The five interfaces `document.createEvent`'s alias table named that this package did not build — `DragEvent`,
`StorageEvent`, `TouchEvent` and the two device events — are built now, and the category they had
(`NeedsMoreEventInterfaces`) is empty. What unlocked them was separating two questions the old reason ran
together: whether the runtime ever *fires* such an event, and whether a page can *construct and dispatch* one.
Only the second is what the corpus tests and what the alias table needs, so each interface is built from its
own dictionary in full — `DragEvent` over the `DataTransfer` this package already has for file inputs,
`StorageEvent` with the `initStorageEvent` Web Storage still carries, `TouchEvent` with `Touch` and
`TouchList`, and the two device events with the two readings a motion event carries — while nothing fires any
of them and each class says so. `EventTarget-dispatchEvent.html` passes whole, and the six rows of
`Document-createEvent.https.html` the file itself guards with `assert_implements_optional('ontouchstart' in
document)` pass too: touch detection belongs to a client here (`Runtime/TouchEmulation`), so the lane opens
that one document as a touch device (`WptBrowserExclusions.TouchDocuments`, an environment rather than an
exclusion) and the row left in the file is its `TextEvent` one. And eight rows of
`Event-dispatch-single-activation-behavior.html` moved to `AssertsWhatNothingRequires`: the file's
instrumentation is a `<form onsubmit>` handler and cannot tell an activation behaviour from an ordinary
bubble, and `submit` and `reset` both bubble.

## What the custom element corpus says

`custom-elements/` and its `parser/`, `reactions/` and `upgrading/` sub-directories arrived with the
implementation of HTML §4.13, and **the shape of what is missing from that corpus is one sentence: most of
it is written against a second global.** `resources/custom-elements-helpers.js` gives it
`create_window_in_test`, which loads an iframe and resolves with its window, and `document_types()`, which
makes every assertion in five documents — this one, a `new Document()`, a `createHTMLDocument()`, an
iframe's and an XHR-fetched one. Same-origin, unsandboxed sourced frames now have independent realms and
run classic scripts on the page's loop. Their DOM constructors and node creation brands are distinct, and
their window listeners, timers and microtasks run against the child global. Empty iframes and child-specific
custom-element registries and reaction delivery remain [#3771](https://github.com/sebastienros/jint/issues/3771).
Thirty-seven documents stay in the not-vendored table pending those capabilities and re-examination.
Re-vendoring moves the census's Documents and Tests columns and requires measuring each document.

The native acceptance run leaves these distinct causes:

1. **The parser upgrades a custom element where HTML constructs one.** The native session hands
   script execution to Browser, but still lacks the element-construction handoff. Attributes already exist
   when an upgrade runs. Native insertion now yields before children so Browser drains attribute and
   connection reactions at the semantic boundary, independently of tokenizer quotas. The `document.write`
   connection-timing case and the two corresponding ordinary-parser reaction checks pass without
   exclusions. Synchronous construction and constructor-failure semantics remain debt.
2. **Cross-document and namespace-sensitive reactions remain incomplete.** Retained exclusions name
   the exact adoption, registry and attribute callback assertions. They are not blanket exclusions for
   native attribute storage, cloning or ordinary mutation delivery.
3. **Some CSS computation and grammars are still missing.** Web Animations now passes the unobserved-style
   reaction case. Its two observed-style cases require interpolation from a synthetic underlying-style
   keyframe: discrete `commitStyles()` has no value to write before the single supplied keyframe at offset 1.
   Those two assertions are `NeedsLayout`, not missing animation APIs or a broken reaction bridge.
   `CSSStyleDeclaration` exclusions now cover only the failing float, border-width and prefixed-filter
   assertions, not the whole document.

ARIA reflection, dataset writes and Selection deletion now share native mutation preparation/completion.
Their four reaction documents pass without exclusions. Historical nullable-namespace and CSS serialization
failures are no longer reasons to exclude passing native assertions.

**`builtin-coverage.html` is green now**, all four hundred and forty-four rows of it. Its `'new'` and
`createElement` halves were two hundred and twenty-two rows of one defect, and the defect was not the
customized-built-in path at all — its `innerHTML` and parser halves always passed. Every one of those rows
failed on `customized.cloneNode().constructor`: DOM creates a clone with the element's **is value**, which is
a slot that `createElement(tag, { is })` and `new XY()` set without adding an attribute, and the former DOM integration's
clone copies attributes and nothing else. So the two halves that set the slot cloned into a plain built-in
and the two that write the `is` attribute in markup did not. `custom-elements/upgrading/`'s own row was the
same rule read from the other side — a clone must follow the slot even when the `is` attribute says
something else — and it went with it.

Two more things the corpus found are **not** defects and are recorded where they belong instead.
`Element.insertAdjacentText` is missing, which upstream's own result renderer calls — the overlay turns the
renderer off for its own reasons and `AGENTS.md` says so rather than letting that line hide it. And
`Event.timeStamp` is not coarsened, which `PerformancePrototype` records as a deliberate divergence; the one
document whose subject is that resolution is in the not-vendored table for the reason
`performance-timeline/webtiming-resolution.any.js` is out of the engine lane.

## The image element, and the four documents that fit here today

`html/semantics/embedded-content/the-img-element/` arrived with HTML §4.8.4.3's image request, and **all four
of its documents pass with no exclusions**. `Image-constructor.html`'s five tests are the
`[LegacyFactoryFunction]`'s shape — the name, the prototype it shares with `HTMLImageElement`, the descriptor
of `Image.prototype`. `nonexistent-image.html` could not have passed before the model existed, because it
waits for an `error` event this browser had no image request to fire. And the two the source set added are
the ones that made it worth writing: **`update-the-source-set.html` is 89 tests over §4.8.4.3.6 itself** —
every descriptor form, the `sizes` lengths, a `<source>`'s `media` and `type`, and the tokenizer's own corner
cases — and `img-picture-ancestor.html` is the four about which `<picture>` an `<img>` belongs to. Both are
written entirely in `data:` URLs, which is why they can run in a corpus that vendors no image.

`update-the-source-set.html` is also the one that found two defects nothing else had asked about. A media
query that does not match the grammar is `not all`, so `not all and !` is `false` — `MediaQuery` answered
`true`, and `matchMedia` said so too. And a `srcset` candidate's URL is delimited by white space rather than
by a comma: `srcset="data:,b"` is **one** candidate whose URL contains a comma, and a `Split(',')` turned it
into `data:` — which is a URL, so nothing downstream could have noticed.

**Four documents out of a hundred and sixty is deliberate, and the reason is what the directory is made of.**
Most of the suite is a *rendering* test: `image-loading-lazy-*` alone is thirty-odd documents about whether an
image is inside a scrolling area, `available-images.html` and its siblings are reference tests compared
pixel-by-pixel, and a dozen more draw the loaded image into a `<canvas>` and read the bytes back. Of what is
left, `img.complete.html` and `naturalWidth-naturalHeight-width-height.html` are the two whose subject is
exactly this model — and each needs upstream's photographs, a 91 kB and a 380 kB JPEG, plus
`?pipe=trickle(d1)` server-side throttling for the assertion that `complete` cannot change inside a task.
This corpus vendors no binary at all today, and adding half a megabyte of them is a change of its own rather
than a passenger on the one that made the model exist. `img.complete.html` would also fail that one
assertion honestly: a subresource fetch is synchronous with the parse here (`Runtime/Parsing/AGENTS.md`), so
`complete` really can move inside the task that set `src`. `update-media.html`, `adoption.html` and
`relevant-mutations.html` are the source set's own siblings and are held back by the same missing bytes:
each names `/images/green-2x2.png` or its kind.

Three more are held back by a gap this browser has rather than by a missing environment, and
[`Dom/divergences.md`](../../Jint.Browser/Dom/divergences.md) carries a row for each. `invalid-src.html` and
`null-image-source.html` both wait for the `error` an `<img src="">` fires, and the former DOM integration asks the resource
loader for nothing when it selects no source, so there is no request processor to hang that event on.
`non-active-document.html` asserts that an image in a document nothing is showing performs no load, and its
`<template>` case fails here: the former DOM integration gives a template's contents no owner document of their own, so an
`<img>` inside one has a fully active node document and really is fetched. Its other two cases — a
`DOMParser` document and `createHTMLDocument` — pass, and the document is left out rather than vendored with
a `NeedsTriage` row, because it also needs a binary this corpus does not hold.

## The two smallest suites, and `document.all`

`html/infrastructure/common-dom-interfaces/collections/htmlallcollection.html` and
`html/obsolete/requirements-for-implementations/other-elements-attributes-and-apis/document-all.html` are the
whole of what upstream asks about `document.all`, and **all 43 of their assertions pass, with no exclusions**.
Between them they cover HTML §4.13.2.3 — the supported names, the "all"-named elements, the named lookup that
answers a live `HTMLCollection` when several elements match, `item(nameOrIndex)`, `namedItem(name)` and the
legacy caller, including that it is not a constructor and ignores its `this` — and ECMAScript Annex B.3.6's
`[[IsHTMLDDA]]` slot, which is what makes `typeof document.all` answer `"undefined"` and
`if (document.all)` take its else branch. `Dom/AGENTS.md` says which wrapper carries the slot and how the
override table selects it.

**One document is vendored out of each directory**, because a suite is a directory here and each of these two
is one. Their siblings are about other interfaces — `HTMLFormControlsCollection`, `HTMLOptionsCollection`,
`RadioNodeList`, `DOMStringList` and HTML's obsolete document colours — and vendoring one moves the census's
`Documents` and `Tests` columns, so it is a change of its own rather than a passenger on this one.

## What the pseudo-classes suite says about this browser

`html/semantics/selectors/pseudo-classes/` is HTML §4.16.3's own suite: one document per selector, run
against a page's real selector engine rather than against a table of strings. **27 documents, 122 tests, 13
of which do not pass**, and every failure is one of three bounded things the former DOM integration does — only one of which
is still its `DefaultPseudoClassSelectorFactory`. That is the reason the suite is here: the page owns
`:target`, `:link`/`:visited`/`:any-link`, `:enabled`/`:disabled`, `:default`, `:open`/`:closed`,
`:valid`/`:invalid`, `:in-range`/`:out-of-range`, `:read-only`/`:read-write`, `:placeholder-shown`,
`:indeterminate`, `:focus`/`:focus-within`, `:active`, `:checked` and `:required`/`:optional`
(`Runtime/Parsing/PagePseudoClassSelectorFactory`), and nothing until this suite arrived
measured any of them.

None of these three has a row in the cause table above, and that is by construction: the table counts the
six DOM suites, and every one of these is a failure of this suite alone.

| Tests | What it is |
| ---: | --- |
| 9 | **`:dir()` compares its argument with the `dir` content attribute of that element alone.** Directionality is inherited and its `auto` value is resolved from text, so an element declaring no `dir` matches neither keyword. |
| 3 | **A reversed range is an underflow and an overflow at once.** §4.10.5.4 gives the time state a periodic domain, so `min` greater than `max` wraps midnight; `ValidityState` compares against both bounds unconditionally. `element.validity` says the same, so it is not the selector's. |
| 1 | **A cloned control loses its dirty value flag**, so `maxlength`'s "too long" state does not survive `cloneNode`. `element.validity` says the same. |

**Five of the eleven the suite arrived with are gone, and the two that are left of them are named for what
they now hold.** The page owns `:in-range`/`:out-of-range` and `:valid`/`:invalid` — both ask HTML's
"candidate for constraint validation" question the former DOM integration folds into `CheckValidity()`, `:in-range` also
asks for range limitations, and a `fieldset` is decided from its descendants — and `:read-only`/`:read-write`,
`:placeholder-shown` and `:indeterminate`, which ask whether the attribute they are about *applies* to the
type state, answer for a `<textarea>`, and know about a radio button group and about a `progress` attribute
that is absent rather than empty. That retired 34 rows of this suite and one of
`dom/nodes/Element-closest.html`. **Three type-change documents did not become green and moved instead**:
their selectors answer correctly now and their remaining assertion compared a computed colour against a
literal. `getComputedStyle` now serializes color longhands as `rgb()`, so all three pass, and
`RenderlessSelectorStyleTests` independently checks the type changes and live cascade.

**`:focus` is the sixth, and it needed the page rather than the predicate.** the former DOM integration answers `:focus` and
`:focus-within` from `IElement.IsFocused`, a flag nothing in this package sets — its own `DoFocus()` assigns
neither that flag nor `ActiveElement`, which is why `Events/FocusController` is the page's focus model — so
the selector could not see a focus every event and `document.activeElement` already agreed about. The
factory now reads that model, and HTML's focusing steps stop being confined to the displayed document: an
`element.focus()` inside a child navigable takes the focus out of the page, which is exactly what
`focus.html`'s last case asserts and what kept it passing while the other four did not.
`:focus-visible` is deliberately still the former DOM integration's and still matches nothing — Selectors §9.4 makes it a
decision about drawing a focus indicator, which a browser with no rendering cannot make — and
`Dom/divergences.md` records that.

**And the last three predicates went with them, which leaves `:dir()` as the only selector cause in the
table above.**
`:active`'s five categories are four *formal activation states* — a keyboard notion this browser has no
key-held state for — and **being actively pointed at**, which is pure input: `BrowserEventRealm.MousePressTarget`
is already the element a trusted pointer press landed on and is cleared by its release, so the predicate is
that element, its ancestors, and the labeled control of a `label` among them. Nothing asks whether the
element is disabled and the standard does not either, which is the whole subject of `active-disabled.html`.
`:checked` stops answering for the historical `<menuitem>` and starts asking an input for its type state.
`:required`/`:optional` ask §4.10.5.3.4 whether the attribute *applies*, so an input outside its fifteen type
states is in neither class — which is what `required-optional-hidden.html` is about, and that document's row
moved to the color-serialization group above rather than turning green, exactly as three type-change documents did
before it.

**`checked.html` is green now, and its last row was never a selector at all.** Only two of that file's three
rows were the `<menuitem>`; the third sets `option2.selected = "selected"` in a single-selection `<select>`
whose `option1` is already selected, and [HTML §4.10.10](https://html.spec.whatwg.org/multipage/form-elements.html#dom-option-selected)
makes that setter **ask for a reset** — the select's selectedness setting algorithm, which leaves only the
last selected option selected. `option.selected`, `selectedIndex`, `value` and `selectedOptions` all reported
the same three-choice one-choice select, so no predicate could have decided it: `HTMLOptionElement.selected`
is a `hooks` setter now and `Dom/HtmlSelectState` owns the algorithm, the display size `:open` already needed
and §4.10.10's disabled option `:disabled` already needed. What HTML also runs on an option's *insertion* and
*removal* steps is still unrun, and `Dom/divergences.md` names it.

**Four of the directory's files are not vendored and none of the reasons is a defect.** `autofill.html`'s two
assertions are `test_valid_selector`, which lives in `/css/support/parsing-testcommon.js` — a helper root this
corpus does not hold — so the file throws at file scope and reports nothing; `indeterminate-radio-group.html`
is a reftest and its `-ref.html` is the reference it is judged against; and the two `.window.js` files are the
glob every suite here has. `checked-001-manual.html` is covered by the lane's own `-manual.` marker.
`focus-iframe.html` *is* vendored, as a frame body: `focus.html` loads it to assert that `:focus` does not
match a focused element inside a frame.

## What the DOM corpus says about this browser

All **43 assertions in the eight `dom/collections/` documents pass**, with no exclusions.
`HTMLCollection-as-prototype.html` now permits an inheriting receiver to assign its own property over
a supported name; the collection's named reads remain live.

**Four collection algorithms are the standard's here rather than the former DOM integration's**, because the former DOM integration's own
answer is reachable through no seam: `getElementById` refuses the empty key DOM §4.9 says no element can
have, `getElementsByClassName` parses the ordered set and folds ASCII case only in quirks mode,
`NodeList.item` answers `null` past the end the way an indexed getter must, and `HTMLCollection.namedItem`
takes the first element in tree order whose ID or *HTML-namespace* `name` is the key. `Dom/divergences.md`
has the upstream half of each, and `Dom/AGENTS.md` says which override list carries it.

`dom/nodes/`, `dom/collections/`, `dom/lists/`, `dom/traversal/`, `dom/ranges/` and `html/dom/` are the DOM
standard's own suites and HTML's DOM half — the corpus every other suite in this lane is written on top of.
Across the six of them there are 226 documents and 65,228 tests, and **24 of those tests do not pass**.
Those three figures are live and checked against the census. They arrived together as 207 documents and
5,247 tests with 1,532 not passing; those arrival figures are historical and deliberately not re-derived.

The table names each distinct cause test by test. Its two numeric columns and its order are generated from the
same browser-lane run as the census: a cause is a named group in `WptBrowserExclusions.Causes`, and each row
carries that name in an HTML comment this page does not render. The prose remains hand-written.

`JINT_WPT_BROWSER_CENSUS=1` checks the table and `=update` rewrites the numbers and order. Every failing test
must belong to exactly one cause, and every cause that reaches these six suites must have exactly one row.
Unlike the census's `Not passing` ceiling, both columns are equalities: a cause growing or shrinking means the
table needs to be regenerated.

| Tests | Documents | What it is |
| ---: | ---: | --- |
| 10 | 1 | **Ill-formed XML fixture assertions.** Native HTML PI parsing passes. The remaining exclusions require preservation of a PI from an XML document with no document element, where DOMParser must instead return a parsererror document. Patterns exclude only the PI-dependent assertions, not name-validation checks that also pass on an element.  |
| 8 | 1 | **The pinned Selectors 3 `:empty` expectation excludes whitespace-only elements.** [Selectors 4](https://drafts.csswg.org/selectors/#the-empty-pseudo) explicitly includes them. The native matcher follows that rule; `::slotted`, language matching and namespace-wildcard attribute matching now pass.  |
| 2 | 2 | **One assertion each or one small family per document.** These cover conversion order, import/clone identity, attribute selection and ordering, element-name identity, node equality and `accessKeyLabel`; each pattern is kept separate where neighboring rows pass.  |
| 1 | 1 | **Remaining frame interfaces and registry access.** Native frame identity, adoption and CDATA branding now pass. The remaining exclusions cover the missing `TextEvent` interface and a custom-element registry accessed across an adopted container's realms.  |
| 1 | 1 | **Two refusals the bindings do not make.** `insertBefore` with a second argument that is not a node, `null` or `undefined` must be a `TypeError`, and replacing with a document or a doctype must be a `HierarchyRequestError`. Both are about *insertion*; they sat with the name-creation rows because one pull request named them together, and [#3950](https://github.com/sebastienros/jint/issues/3950) emptied everything else out from under them.  |
| 1 | 1 | **The pinned PI mutation fixture expects the old HTML bogus-comment data.** Current HTML parsing produces a processing instruction with data `data`, not the comment spelling `?processing data?`. Native parser and outerHTML mutation records pass without exclusions.  |
| 1 | 1 | **A `relList` on a MathML `<a>` that no standard defines.** The file's own `testAttr()` asks for a `DOMTokenList` in the MathML namespace beside the SVG one, and MathML Core's only interface is [`MathMLElement`](https://w3c.github.io/mathml-core/#dom-and-javascript), which declares neither `rel` nor `relList`; nothing else defines one on a MathML element either, so this is `AssertsWhatNothingRequires` rather than debt. The SVG row passes now — [SVG 2 §16.2](https://svgwg.org/svg2-draft/linking.html#InterfaceSVGAElement)'s `SVGAElement` is one of `DomManualInterfaces`' local-name interfaces, and `Dom/divergences.md` records what is still missing.  |

**The XML-document cause is gone, and it was four different things.** It arrived as a scope decision —
"a page here parses HTML, the former DOM integration builds no XML document" — and by the time it was re-measured that
sentence had stopped being true: a frame parses XML ([#3873](https://github.com/sebastienros/jint/issues/3873)),
`XMLDocument` has its interface ([#3893](https://github.com/sebastienros/jint/issues/3893)), and
`createDocument` registers its own table. Running its seven documents one at a time
([#3766](https://github.com/sebastienros/jint/issues/3766) is the history) split its 346 rows four ways.
**190 were metadata this package owns and now produces**: `location` is null for a document with no
browsing context, `characterSet` answers the Encoding Standard's name with `charset` and `inputEncoding`
beside it, `contentType` is what the algorithm that made the document gave it, and `createElement` on a
document that is not an HTML one keeps the name's case. **137 are not about XML documents at all** —
`processing-instruction-attributes.html` exercises the attribute surface now specified by
[DOM §4.13](https://dom.spec.whatwg.org/#interface-processinginstruction), plus HTML processing-instruction
parsing. Its earlier description as an unstandardized proposal is obsolete. The attribute-map implementation
and element serialization fix removed 100 failures; native HTML PI parsing in
[#4207](https://github.com/sebastienros/jint/pull/4207) resolves the remaining 20 HTML parser assertions
tracked by [#4098](https://github.com/sebastienros/jint/issues/4098). The current cause contains ten
PI-dependent assertions whose ill-formed PI-only XML fixture expects a preserved PI before the error
tree, contrary to HTML §8.5.1. These are `AssertsWhatNothingRequires`, not engine debt; name-validation
assertions that also pass on the `parsererror` element remain unexcluded. **42 were the name refusals the table already named**, reached three times each; [#3950](https://github.com/sebastienros/jint/issues/3950) made them pass and took the rows out. **The rest are
the former DOM integration's**: node equality compares base URLs, a live range is not adjusted across documents, and the
HTML element factory lower-cases a local name it is handed.

**`NeedsXmlDocuments` has no rows left at all.** The last thing it named was one the former DOM integration mapping:
`application/xhtml+xml` reached the **HTML** parser, so the two XHTML fixtures `Document-createElementNS.html`
and `Document-createElement.html` frame came back wrapped in a second `<html><body>` skeleton and all 244
rows died on the trailing newline that adds, before either reached the member it is about. A page picks its
own parser from the response's content type now, for a frame and for a navigation alike
([`Jint.Browser/Runtime/Parsing/AGENTS.md`](../../Jint.Browser/Runtime/Parsing/AGENTS.md)), so **all 244 of
them pass** and neither file carries a single exclusion — [#3950](https://github.com/sebastienros/jint/issues/3950)
having already taken out the name-validation rows that would otherwise have been the XHTML half's share. The
category was read for a long time as "the former DOM integration cannot be made to do this", and what was actually
unreachable was a second **document factory**; choosing the parser was never the same question. The member stays for the one
thing an XML document here still cannot do — **run a script** — which is also why no `.xhtml` document in
the not-vendored table below becomes vendorable: each of them loads `testharness.js` through a
`<script src>` and would report nothing.

**`html/dom/historical.html` is the file that tells three different things apart**, and "remove it" is the wrong
answer for two of them. `HTMLAppletElement`, `HTMLTableDataCellElement` and `HTMLTableHeaderCellElement` are names
the standard removed: the first was never declared here, and the other two were the former DOM integration's split of
`HTMLTableCellElement` into two interfaces HTML does not have, so both are `excludedInterfaces` rows now and the
one member the split carried — `scope` — comes back as a `reflected` row on the interface HTML puts it on.
`document.applets` is the opposite: HTML §16.3 *keeps* it and defines it to answer an `HTMLCollection` whose
filter matches nothing, which is a member the binding had to gain rather than lose. And `<applet>` is neither —
the element still parses, and takes the `HTMLUnknownElement` every unlisted HTML name takes, which the former DOM integration
cannot say because it builds a real `HtmlAppletElement`. Four of the file's six failures went with those, and a
fifth with `document.all` becoming a real `HTMLAllCollection`, whose supported names take a `name` attribute only
from one of the fourteen "all"-named elements and `applet` is not among them. The one that is left is somebody
else's cause — `cssFloat` is not one of the ten properties `ResolvedStyle` answers an initial value for — so the
row this file used to have is gone rather than shrunk.
**Half of the `Range` row is gone, and that half was never about `Range`.**
`Range-in-shadow-after-the-shadow-removed.html` takes its shadow-root mode from
`<meta name="variant" content="?mode=open">` and its closed sibling, and the lane used to serve the bare
path — so `mode` was `null`, `attachShadow({mode: null})` raised the `TypeError` WebIDL's enum conversion
owes a browser too, and both of the file's tests failed before either reached a `Range` at all. Running a
document once per declared variant is what removed them: four assertions over two cases now, all passing,
nothing excluded, and what is left under that cause is the `Range-adopt-test.html` pair, which really is
the bookkeeping. It is worth naming as a shape — a share of a cause that turns out to be the *lane*
rather than the engine, whose fix is therefore a change to how a case is enumerated and not to the
subject the document was about.

**All ten of HTML's reflection documents are cases, and nine of the ten pass whole**
([#3770](https://github.com/sebastienros/jint/issues/3770)). HTML §2.6.1's reflection algorithms are
`Jint.Browser/Dom/ReflectedAttribute.cs` and the members that take them are `overrides.json`'s `reflected`
list, so `reflection-misc.html` (4,877 assertions, 1,866 of them failing before), `reflection-text.html`
(10,202, 3,360 failing before), `reflection-sections.html` (5,604, 2,189 failing before),
`reflection-tabular.html` (6,116, 3,552 failing before), `reflection-forms-weekmonth.html` (1,579, 420
failing before), `reflection-embedded.html` (8,922, 3,774 failing before), `reflection-grouping.html` (5,358,
2,006 failing before), `reflection-obsolete.html` (2,621, 1,483 failing before) and `reflection-forms.html`
(8,271, 2,160 failing before) pass with **nothing** excluded, and `reflection-metadata.html` (3,110, 1,218
failing before) has one cause left — and it is not reflection. **22,028 of 56,660
assertions failed when the suite was measured; 16 do now.**

191 rows did it, and the first fifteen were mostly the **global** attributes every element carries —
`dir`, `lang`, `tabIndex`, `autofocus`, `inputMode`, `enterKeyHint` — which is why `text` needed only eleven
rows of its own for 3,360 assertions. The rest are element-specific, one `reflected` row each.
`metadata` took seven of those —
`link`'s `as`, `crossOrigin`, `referrerPolicy`, `charset` and `target`, and `meta`'s `media` and `scheme` —
plus one member that is deliberately not reflection at all: **`nonce` answers HTML §2.5.3's
`[[CryptographicNonce]]` slot**, whose setter writes the slot and leaves the content attribute alone, so a
`script[nonce]` selector cannot read back a nonce a header-delivered policy issued
(`Jint.Browser/Dom/CryptographicNonce.cs`).

`sections` took thirteen and cost the row model two things it could not say. Six of its members are on
**`Document` and reflect an attribute of another element** — §3.2.6.4's `document.dir` off the `html`
element, and §16.3.3's `fgColor`, `linkColor`, `vlinkColor`, `alinkColor` and `bgColor` off the `body`
element — so a row names a `target` now, and a document with no such element reads exactly as an absent
attribute and does nothing on setting, which is what the standard says of `dir`. Ten of them are
**`[LegacyNullToEmptyString]`**, where `el.text = null` writes `""` and not `"null"`; that is a flag on the
`DOMString` row rather than a fourteenth algorithm, because the getter is unchanged.

`obsolete` took eight, all on `<marquee>` — and found one defect in the shared implementation that every
unsigned reflected integer had: HTML says a value outside the range 0 to 2147483647 writes the attribute's
**default**, and WebIDL's `unsigned long` conversion is modulo 2³², so `el.hspace = 4294967295` really does
arrive as that number rather than as −1. The setter wrote it verbatim. Eight rows of `obsolete` found it and
`col.span`, `td.colSpan`, `input.size` and `textarea.cols` would all have carried it.

`tabular` took thirty-nine, the largest table of the seven and the first with a **clamped unsigned long** in
it: `col.span` and `colgroup.span` clamp to [1, 1000], `td.colSpan` and `th.colSpan` to the same, and
`rowSpan` to [0, 65534] — three ranges, each with a default of 1, none of which any CLR signature carries.
It also needed one `skip`: the former DOM integration splits `<th>` and `<td>` into two interfaces HTML does not have, and
the header cell's own `scope` shadowed the reflected `HTMLTableCellElement.scope`, so `<th>` answered the raw
attribute value while `<td>` answered the enumeration.

`forms-weekmonth` took eleven, all on `<input>`, and one of them is HTML's only exception to URL reflection:
**`formAction` answers the element's node document's URL when the content attribute is missing or empty**
(§4.10.18.6), which is what a form posting to itself reads. `form.action` is the other member with that rule
and the row model can say it now. Two more are `<input>`'s `width` and `height`, whose *getters* are the
rendered image dimensions and are not reflection at all — but whose setters are, in as many words, so what
the rows fix is the half that is. Both rows still take the whole accessor pair, and deliberately: this
package renders no image, so the getter answers the content attribute either way, and answering it through
HTML's rules for parsing non-negative integers rather than through the former DOM integration's `DisplayWidth` is the
difference between `width="-5"` reading 0 and reading −5 out of an `unsigned long`.

`forms` took fifteen more — the rest of the form controls, and the two remaining numeric shapes: `textarea`'s
`cols` and `rows` are **limited unsigned longs with fallback** (a zero or out-of-range set writes 20 and 2
rather than throwing, which is what separates them from `input.size`, where it is an `IndexSizeError`), and
`progress.max` is a **limited double**, whose setter declines to write at all when the value is not greater
than zero. `select.size` defaults to 0 where `input.size` defaults to 20, and `button.formMethod` has a
`dialog` keyword `input.formMethod` does not — three facts about three members that no CLR signature carries
and the `reflected` list has to state.

Six more rows finished it, and they are the first that reflect **only a setter**. `<meter>`'s `value`, `min`,
`max`, `low`, `high` and `optimum` are the case a whole replacement would have made worse: their getters are
HTML §4.10.14's own algorithm — an absent `max` is 1, `optimum` is the midpoint, the value is constrained to
the range — which the former DOM integration implements and no reflection type can express, while their setters are plain
§2.6.1 reflection and were writing `Double.ToString(InvariantInfo)` where HTML wants "the best representation
of the number as a floating-point number", which is ECMAScript's Number-to-String. So the row model grew a
`setterOnly` flag: the entry replaces the setter and the generator keeps the projected getter's body, and it
refuses — as a diagnostic, not as generated code — a row that asks for it where there is no projected getter
to keep or where a getter hook has already claimed the read.

`embedded` took the last fifty, over ten interfaces, and was the largest single table: `<img>`'s twelve,
`<object>`'s twelve, `<iframe>`'s nine, and the media elements' `preload`, `crossOrigin` and `loading` on
`HTMLMediaElement` rather than on `<video>` and `<audio>` separately, which is where HTML puts them.
`track.kind` is the suite's clearest case for stating **both** defaults — its missing value default is
`subtitles` and its invalid value default is `metadata` — and `preload`'s are implementation-defined among
its three states, which is why the row picks `auto` and the corpus asserts membership of an array rather than
one value.

**The one cause left is not reflection**, and it is the dependency. Two others were, and both are gone: four
obsolete elements — `<dl>`, `<dir>`, `<font>` and `<frame>` — get an interface of their own from HTML and a
plain `HTMLElement` from the pinned assemblies, so there was nowhere for `compact`, `color`, `src` and their
kind to be reflected onto, and 506 rows named the tests. `DomManualInterfaces` declares all four by local name
now, the way it already declared `HTMLFrameSetElement` — one shape and one prototype chain per interface
over the same the former DOM integration element — so `reflection-grouping.html` and `reflection-obsolete.html` pass whole,
and the divergence table records what the pinned assemblies are still missing. `<meter>`'s six setters were
writing a `double` with .NET's number format, so `-0` kept its sign and an exponent was `1E-10` where HTML
wants ECMAScript's `1e-10` — three values per member, eighteen rows, and a whole `reflected` row would have
been a regression because those getters are HTML §4.10.14's and are right; `setterOnly` is what took the
half that was wrong, and `reflection-forms.html` passes whole. What is left is `<style>`'s `media`, which
cannot be *written* at all when the value is not a media query the former CSS integration can parse — the exception comes
out of `Element.setAttribute` itself, through the attribute observer the former DOM integration core registers, where Media
Queries §2.1 requires an unparseable query to be replaced by `not all`. Those 16 rows are the only failures
this lane's `html/dom/` figure gained, against 51,783 assertions it did not have before.

**Four documents did not terminate at all, and that was the finding this campaign put first.**
`TreeWalker-currentNode.html`, `TreeWalker-previousNodeLastChildReject.html`, `TreeWalker-traversal-reject.html`
and `TreeWalker-traversal-skip.html` each spun forever: the former DOM integration's `TreeWalker.ToPrevious` never advanced the
sibling it was reading and never climbed to a parent, so `previousNode()` looped the moment the previous
sibling was not accepted outright — a filter answering `FILTER_REJECT` or `FILTER_SKIP`, or a `currentNode`
pointed outside the root beside a node `whatToShow` excludes. Nothing in this lane could bound that —
`BrowserOptions.MaxTaskDuration` is deliberately infinite, the driver's own 30 s deadline cannot interrupt a
page thread that never yields, and a node `whatToShow` excludes is `FILTER_SKIP` *without the page's filter
being called*, so the loop never re-entered the engine for a constraint to fire in — which is why an embedder
should read [#3765](https://github.com/sebastienros/jint/issues/3765) as a denial of service rather than as a
conformance gap. DOM §6.1's seven traversals are `Jint.Browser`'s own now
(`Dom/Views/DomTreeWalker`, and its file argues each loop's termination); the four documents are cases, all
seventeen of their tests pass, and `TreeWalker-basic.html`'s "Walk over nodes." passed with them.

**And two missing members were worth thirty-six documents.** `dom/common.js` is the fixture builder the
whole of `dom/ranges/` and half of `dom/traversal/` load; it calls `document.createCDATASection` on a
`new Document()` and `document.implementation.createDocument` two lines later, both before a single
`test()` runs, so twenty-four Range documents, three traversal documents and nine more under `dom/nodes/`
and `dom/events/` reported nothing at all. **Both members exist now**
([#3766](https://github.com/sebastienros/jint/issues/3766)), their not-vendored rows say so, and vendoring
the thirty-six is a change of its own: it moves this table's Documents and Tests columns, which the change
that fixes an engine deliberately does not — the same standing the four `dom/events/` documents that were
waiting on `document.createEvent` already have.

**Adding `createDocument` raised the ceiling, deliberately and once.** `DOMImplementation-createDocument.html`
builds its own table of 434 cases *inside its first test*, and the builder called the missing member — so
the file reported **two** tests and the other 432 were never registered at all. They are registered now,
348 of them fail, and `dom/nodes/`'s not-passing figure went from 2,000 to 2,333 with them. That is what
`JINT_WPT_BROWSER_CENSUS=update-raising-the-ceiling` is for, and it is used here for exactly that: the
failures are not new, only newly *counted*.

## What is not vendored, and why

`WptBrowserExclusions.NotVendored` is the enforced list; the shape of it is worth knowing because it is
different from the engine lane's. **Almost every row is a document that cannot produce a per-test report at all**
— a harness `ERROR` or `TIMEOUT` — which is what puts it there rather than in the exclusion table: a harness
error covers the whole file and no per-test exclusion can name it. The rest are the globs upstream's own
markers and this lane's directory rule earn, and the helper files of documents nothing here runs. They fall
into twenty-nine groups; the counts are rows rather than files, since several are globs. Ninety of
the rows belong to the six DOM suites, which is what a corpus about every member of every node interface
costs: half of them are one member reached at file scope. **A twenty-ninth answer is not in this table at
all**: `WptBrowserExclusions.FrameBodies` names the documents that are vendored and served and never run,
which is what a fixture sitting beside the cases that load it needs — see below.

| Why | How many | What it is |
| --- | --- | --- |
| a sub-directory that is not a suite | 2 | `dom/events/scrolling/` and `non-cancelable-when-passive/`, both layout |
| not a document, or a directory this PR does not vendor | 7 | `.window.js`, `.worker.js`, and four directories of the scripting tree |
| upstream's own markers | 5 | `.tentative.`, `-manual.`, and `.sub.html`, which needs a second origin to substitute into |
| a cause that has gone | 4 | they met `document.createEvent` before a test could report; it exists now, and vendoring them moves the census's Documents and Tests columns, so it is a change of its own |
| a name this browser does not have | 2 | a `javascript:` URL, and one that read `window.event` before it existed |
| a rendering | 6 | a CSS animation or transition event, a pseudo-element, and the coarse-clock assertion |
| a focus event that does not arrive | 1 | the one row that is a finding rather than an environment; see its reason |
| a frame that runs script | 11 | a second **realm** with a document in it — a frame has a window and a document here, and nothing in it runs — and the helper documents of those tests |
| the WebIDL conformance harness | 2 | `idl_test([…])`, which the engine lane declines for the same reason |
| the timer's string handler | 2 | `setTimeout("{", 10)`, which `TimerFunctions` documents declining |
| a second origin | 1 | `location.href.replace('://', '://www1.')`, and there is one origin here |
| a helper of a document above | 2 | the bodies two of those tests load |
| a `custom-elements/` directory or marker | 12 | `form-associated/` and `registries/` and `state/` (`ElementInternals` and scoped registries), `htmlconstructor/` (both of its documents build their subject in an iframe), the `.tentative.`/`.window.js`/`.xhtml`/`.svg` globs |
| a `custom-elements/` frame that runs script | 37 | `create_window_in_test` and `document_types()`; see the section above |
| `custom-elements/` needs `ElementInternals` | 5 | `attachInternals()` at file scope, so none of them registers a test |
| a `custom-elements/` crash test or reftest | 4 | neither loads `testharness.js`, so the driver's own deadline is what ends them |
| a `custom-elements/` finding, and one whose cause is spent | 2 | the parser's upgrade-instead-of-construct, and the file whose `unhandledrejection` the engine used to raise at the tracker's cadence rather than at the checkpoint (fixed; vendoring it is a change of its own) |
| a DOM sub-directory that is not a suite | 13 | `Document-contentType/`, `moveBefore/`, `insertion-removing-steps/`, `crashtests/`, `tentative/`, `unfinished/`, and five of `html/dom/`'s |
| a DOM marker, or not a document | 6 | `.window.js`, `.tentative.html` and `.sub.html` under the six new suites |
| an XML document | 6 | `.xhtml`, `.xht`, `.svg` and the three `.xml` fixture globs. A page here parses XML now, and these are still absent for the half that did not move: a `<script>` in an XML document does not run, so none of them could load `testharness.js` |
| the WebIDL conformance harness, again | 2 | `html/dom/idlharness.https.html`, and one that needs an `RTCPeerConnection` |
| HTML's reflection suite, the two files that are not the suite | 2 | [#3770](https://github.com/sebastienros/jint/issues/3770); all ten `reflection-*.html` documents are cases now, so what is left out is `reflection-original.html`, the same suite in the aggregating spelling, and the attribute table of a `.tentative.` document nothing here runs |
| a DOM crash test or reftest | 5 | none loads `testharness.js` |
| a helper document beside its test | 4 | three frames and a fragment; a document under a suite would have to be a case, and the fourth answer is the frame-bodies table below |
| a DOM frame that runs script | 14 | sourced frames now have documents, realms and classic scripts ([#3771](https://github.com/sebastienros/jint/issues/3771)); each row still needs re-examination against empty-frame initialization and remaining child services. Three have had it: the selector documents are cases |
| a member reached at file scope | 30 | `createCDATASection` (31 documents, 24 of them `dom/ranges/`, through `dom/common.js`), `createDocument` (5) and `setAttributeNode` (1) |
| one DOM file each | 3 | a `SyntaxError` no `error` event carries to the harness, and two `MutationObserver` documents waiting for a record that never comes |
| the pseudo-classes suite's four non-cases | 4 | a helper root this corpus does not hold, a `.window.js` glob, and a reftest with its reference |
| too slow to be a case | 2 | the six `NodeList-static-length-getter-tampered*` documents and their helper: a static `NodeList` re-reads its tampered `length` getter, so each spends between 5.9 s and 18.8 s and one of them crossed the driver's 30 s deadline on a loaded machine |

**A document can also be vendored, served and never run.** `WptBrowserExclusions.FrameBodies` is that
third answer, and it exists because a document directly under a suite is a case — `WptCorpus.BrowserTestFiles`
never descends, so a helper lives under `resources/` or `support/` and a case does not. Upstream does not
always agree. `ParentNode-querySelector-All-content.html` sits beside the three documents that load it into a
frame and is a fixture with no `testharness.js` in it, so the only two answers this lane had were to run it
and time out or to leave it out of the corpus and lose every case that loads it. Now it is vendored, served
and not a case, and the table is held from both ends like every other one here: a row must name a document
the corpus really holds, directly under a suite this lane claims, and no row may also be a `NotVendored`
pattern — a path cannot be absent and served at once. It takes no minimum-test entry and appears in no census
column, because neither counts anything about a document that reports nothing; what holds it to its job is
the three cases that load it, which fail loudly if the frame they wait for never arrives.

**There are two rows now.** `focus-iframe.html` is the second: `focus.html` frames it and asserts that
`:focus` does not match a focused element inside it, which is a document that has to be served and must
not be a case of its own.

**And that is what let the selector table in.** `Element-matches.html`, `Element-webkitMatchesSelector.html`
and `ParentNode-querySelector-All.html` are the whole of wpt's Selectors-API suite, run three times over —
through `matches()`, through its prefixed alias, and through `querySelector`/`querySelectorAll` in five
contexts (a document, an in-document element, a detached element, an empty element and a fragment). They were not vendored for
two reasons at once: the frame body above, and the fact that a frame had no document to be
([#3771](https://github.com/sebastienros/jint/issues/3771)). Both are answered, and the three documents bring
**3,313 tests, of which 3,297 pass** — and `dom/nodes/` grows from 4,802 tests to 8,115. The 16 that do
not pass are bounded to the patterns in the exclusion table, and all 16 are in the third document: the
selector-error contract differences are gone. A relative selector, an undeclared namespace prefix at any
depth and an attribute selector closed by EOF are all `DomSelectorText`'s now
([#3946](https://github.com/sebastienros/jint/issues/3946), and `Jint.Browser/Dom/divergences.md` has a row
for each), so `Element-matches.html` and `Element-webkitMatchesSelector.html` pass entirely. What is left is
the `::slotted()` functional pseudo-element, whose native parser dispatch is a private frozen dictionary
with no registration point. The empty namespace prefix now uses `DomSelectors`: a token adapter and a
public predicate factory composed with the native parser and matcher. They are `NeedsTriage`, for the reason that category
exists: the change that first runs a suite is not also the change that moves the engine. The older branch
named 518 failures; current `main` fixed 390 of them before the corpus landed, and the two-sided exclusion
check removed every stale row rather than preserving that historical result.

**One of them was the machine's answer rather than the browser's, and that is fixed rather than excluded.**
`:lang(en)` on an element with **no** inherited language matched on a host whose culture is English and did
not on one whose culture is invariant — the Windows and the Linux CI leg exactly, so four rows of this
document passed on one and failed on the other and no exclusion could name them on both. the former DOM integration resolves
such an element through the browsing context's culture, and the context had none, so it took
`CultureInfo.CurrentCulture` off whichever thread was parsing. `ParserDriver` gives the context the
**engine's** culture now (`Options.Culture`, which itself defaults to the current culture, so nothing moves
for a host that sets none), and this lane pins its own to the invariant culture — a gate whose answer depends
on the runner's locale is not a gate. All four pass everywhere now; scoping the exclusion to an operating
system would have encoded the coincidence instead of removing it.

**The `testdriver.js` group is gone, which is what recording it by name was for.** Campaign item C4 mapped
upstream's automation API onto the same `InputDispatcher` the `Input` domain reaches, through the
`testdriver-vendor.js` slot upstream ships empty for a vendor to fill (`AGENTS.md` has the rules). Its seven
documents were then re-examined one at a time, and **five are cases now** —
`Event-dispatch-redispatch.html`, `focus-event-document-move.html`, `handler-count.html`,
`no-focus-events-at-clicking-editable-content-in-link.html` and `pointer-event-document-move.html`,
fourteen tests between them, all passing, none excluded — fourteen rather than ten because
`handler-count.html` declares three variants and is three cases. Two still cannot report, and neither
reason was ever the driver's:
`Event-dispatch-on-disabled-elements.html` spends five of its nine tests waiting for CSS transition and
animation events on a disabled control, so it never completes and never reaches its testdriver-driven test at
all; and `click-on-absolute-pseudo.html` reads `event.pseudoTarget` and `element.pseudo('::after')`, which
need a pseudo-element model. Both are `a rendering` rows now. **The mapping found no new defect**, which is
the outcome running documents through an existing dispatcher should have.

The `customElements` row that used to sit in the "a name this browser does not have" group has gone the same
way: `window.customElements` exists, and `EventTarget-add-listener-platform-object.html` is a case again.

## What runs, and what it costs

The whole lane is **about forty seconds** — one `WptServer`, one `Browser`, and a fresh `BrowserContext` and
`Page` per document. It was ten seconds before the DOM suites, which multiplied the documents by two and a
half and the assertions by four. **No case comes within a factor of three of the driver's 30 s deadline**,
and keeping that true is why the six `NodeList-static-length-getter-tampered*` documents are not vendored:
the largest of them took 18.8 s idle and crossed 30 s on a loaded machine, and a case whose outcome depends
on the machine is exactly what the census exists to keep out. Nothing in it waits on a real clock except upstream's own harness timeout, and no document
reaches it: every case reports, which is the property `EveryVendoredDocumentIsAccountedFor` and the
minimum-test table together keep true.

---

### Overview (`tools/browser-comparison/README.md`)

# Controlled offline browser comparison

This tool follows [#3575 X4](https://github.com/sebastienros/jint/issues/3575). The original
[#3907](https://github.com/sebastienros/jint/pull/3907) smoke/raw-diagnostic harness is extended under
[#3930](https://github.com/sebastienros/jint/issues/3930). One PuppeteerSharp client applies identical offline
workloads and independently expected checksums to **Jint.Browser**, Chromium, and Lightpanda.
No Lightpanda source is read or copied.

A working harness is not a completed benchmark campaign. X4 remains open until the complete idle-accepted
paired run and its scoped conclusion are published. No timing result is included here.
Read [the benchmark instructions](../../Jint.Benchmark/AGENTS.md) before measuring or quoting numbers.

## Correctness first

Build the Jint executable in Release, copy `example.json`, and fill in the already installed browser paths.
The harness never installs or downloads a browser. Record the Lightpanda release asset identity and verify
its digest before running the binary. Official nightly assets currently include macOS aarch64 and x86_64;
[macOS 13 or later is supported](https://lightpanda.io/docs/run-locally/installation/system-requirements).
The [nightly URL is mutable](https://lightpanda.io/docs/run-locally/installation/nightly-builds), so a URL alone
is not a version pin. `LIGHTPANDA_DISABLE_TELEMETRY=true` is set for owned Lightpanda processes.

```sh
dotnet build Jint.Browser.Tool/Jint.Browser.Tool.csproj -c Release -f net10.0
python3 tools/browser-comparison/measure.py --config /absolute/path/config.json \
  --output /absolute/path/new-verification-directory
```

Verification freshly builds the Release harness and runs all five workloads in both lanes on every configured
adapter. It exports exact HTML, JavaScript, expected results, identity and provenance. Every result has
`Measurements: null`; no elapsed/CPU/memory fields are emitted as measurements. Missing binaries, unsupported
behavior, mismatched checksums and cleanup failures fail visibly. One adapter is sufficient for verification.

The underlying one-row CLI remains useful:

```sh
dotnet run --project tools/browser-comparison/BrowserComparison.csproj -c Release -- \
  --config /absolute/path/config.json --smoke
```

`workload`, `lane`, `warmupCount`, and `iterations` select a row. Cold requires one iteration on a new browser
and page; warm first executes five unmeasured repetitions of that row, then ten measured repetitions in the
collector. Each repetition navigates to a fresh copy of the fixture. Warm means a warmed browser/runtime,
not retention of a mutated DOM across iterations. No sibling workload warms another row's state.

| Workload | Contract |
| --- | --- |
| `parse-extract` | Parse 1,000 static articles and extract their count, numeric sum and final title |
| `mutate-query` | Create 1,000 nodes, remove alternate nodes, query and mutate the surviving inventory |
| `event-form` | Deliver 100 bubbling input events, serialize the form and inspect the resulting DOM |
| `fetch-update` | Fetch deterministic loopback JSON, update the DOM and cross a promise continuation |
| `interpreter-control` | Run a deterministic arithmetic loop without DOM operations |

The `FormData(form)` compatibility failure originally found by the unchanged `event-form` workload
([#3931](https://github.com/sebastienros/jint/issues/3931)) is resolved. The collector and correctness smoke
landed in [#3941](https://github.com/sebastienros/jint/pull/3941). The remaining X4 acceptance work is an
empirical Jint A/A calibration and the complete three-browser comparison on an idle-accepted Linux host
with delegated cgroup v2, including the scoped hosted-VM route below, followed by independent
reproduction, retained evidence and a scoped conclusion in
[#3930](https://github.com/sebastienros/jint/issues/3930).

These cover the campaign's extraction, mutation, interaction and local asynchronous automation use cases.
They do not represent rendering, Internet latency, arbitrary production sites, or every framework.

## Ownership and measurement boundaries

Owned Jint, Chromium and Lightpanda adapters launch new processes for every row. Chromium receives a new
profile; Lightpanda uses its documented `serve --host 127.0.0.1 --port 0` endpoint announcement. Startup,
connection, page work, teardown and failure cleanup have bounded waits. Both diagnostic pipes are drained into the preserved per-launch stderr log.
The configured command and effective arguments/settings are recorded. Teardown attempts browser close and
records whether forced process-scope termination was needed; every owned descendant must exit.

A Lightpanda configuration with `endpoint` instead of `executable` borrows a local endpoint and may optionally
name its `processId`. Borrowed adapters disconnect without terminating that process. They remain available
for smoke/raw diagnostics and are rejected by the comparison gate.

| Boundary | Included |
| --- | --- |
| Launch/connect | Owned process launch, endpoint readiness, client connection |
| Preparation | Browser identity queries, page creation, and the row's warmup, if any |
| Page work | Navigation to `load`, workload evaluation and correctness assertion; repeated only in the warm lane |
| Teardown | Page close, browser close/disconnect, and confirmed owned process-scope exit |
| Total lifecycle | Continuous launch-through-teardown clock, including observer/boundary overhead |

Complete installation/runtime manifests and workload export happen before the idle checks. A single batch
hashes the union of every configured adapter and harness installation plus system library roots once.
Each adapter retains its own executable, arguments and version label, with the same conservative superset
of dependency hashes. Coverage metadata explicitly identifies the batch union; analysis requires identical
union contents for every adapter and the harness. An independent fresh batch after collection hashes the
whole union again; there is no cache across phases, and changed dependencies invalidate the entire run.
The batch setup deadline is 1,800 seconds, bounded separately from measurement: hosted diagnostics showed
steady progress through 130,796 files / 9.47 GB at 870 seconds before the former 900-second identity limit.
Canonical traversal remains serial; a bounded queue feeds at most four synchronous hashing workers
only during setup. This overlaps independent file reads without dropping paths or reusing old hashes.
Any traversal or worker failure rejects the entire snapshot. Progress records retain active read paths
as well as completed file/byte totals. This addresses the variable cold-filesystem throughput observed
in hosted run 35729896926, which reached 140,793 files / 10.55 GB before the same setup deadline.
No measurement-window, idle-check or accounting deadline changes.
The Linux manifests include complete system library trees to cover native loaders, dynamic libraries,
child helpers and runtime snapshots, plus configured `dependencyRoots` for additional installations.
The one declared exclusion is canonical `/etc/ssl/private` and its descendants: this private-key
store is reached by Ubuntu's `/usr/lib/ssl/private` directory link, but is not a library installation.
Its contents are never enumerated or opened. The manifest records the exact boundary and reason,
and analysis rejects absent or broadened exclusion metadata. Explicit executables, file arguments or
configured dependency roots within that boundary are refused. Native helpers and external library
symlinks remain covered, and every other missing/unreadable file still fails visibly.
Symlinks are resolved before checking the boundary; dynamic-loader overrides are rejected.
On macOS, application bundles are hashed but the OS shared runtime remains diagnostic-only.
Binary hashing never runs between an accepted idle check and its browser launch. Stage sums can differ from the continuous total
because scope snapshots and observer shutdown are explicit overhead. Cold results are not a whole-application
startup measurement; warm rows are not a claim about persistent browser caches or JIT convergence.

## Whole process accounting and supported platforms

Gate mode requires an **already delegated Linux cgroup v2 root** with CPU and memory controllers enabled.
The host administrator supplies an empty delegation; the tool does not alter parent cgroup policy or elevate
itself. The kernel must provide `cpu.stat`, `memory.current`, `memory.peak`, `cgroup.kill`, and population state.
Each launch receives a new UUID child scope. The browser starts only after entering that scope, and all of its
descendants remain accounted even after reparenting or exit. The client, fixture server and observer remain
outside it. Shell startup and the membership write occur before scope entry and are excluded from the
cgroup counters; browser exec and the remaining shell work after entry are included. The launch wall clock
also includes that initial shell startup.

PID/start-time observations are fallible diagnostics. The collector rechecks start time and membership,
records observed identity changes, missing fields and read failures, and labels successful observations
non-atomic. Such misses do not invalidate the independent kernel CPU/memory counters or imply that a process exited.

CPU is the scope's cumulative microsecond counter, including exited children. Memory is **charged cgroup
usage**, which can include page cache and kernel charges; it is not portable RSS. The launch, preparation, page-work and teardown peaks are each the maximum observed simultaneous
scope usage sampled continuously every 10 ms, with explicit stage labels, timestamps and boundary samples.
The kernel's lifetime scope peak is recorded separately. Never sum individual PID peaks, subtract lifetime
peaks, or compare different backends as though they measured the same memory. Sampling can miss transients;
the observer itself adds overhead outside the browser's scope.

| Platform/backend | Verification/raw diagnostics | Gate comparisons |
| --- | --- | --- |
| Linux with delegated cgroup v2 and required counters | Supported | Supported after the kernel verification leg passes |
| Linux without delegation/counters | Supported | Explicit refusal |
| macOS / Windows | Supported when the requested browser distribution exists | Explicit refusal until a complete accounting backend is implemented |

The portable root-PID snapshots remain labeled diagnostics and are never substituted for missing tree
counters. Native macOS Lightpanda availability does not imply that complete accounting is available there.
Container/VM results must identify that host boundary; do not treat an idle guest as proof its physical host
is idle. A dedicated idle physical host gives stronger isolation; hosted-VM campaign evidence must retain
that limitation, pass the same guards and accounting checks, and independently reproduce.

## Controlled collection and analysis

```sh
python3 tools/browser-comparison/measure.py --config /absolute/path/config.json \
  --output /absolute/path/new-gate-directory --measure --rounds 6 --launches 3 \
  --scope-root /sys/fs/cgroup/already-delegated-comparison-root
python3 tools/browser-comparison/analyze.py /absolute/path/new-gate-directory > analysis.json
```

The driver requires all three owned browsers, a clean committed checkout, and exact identities. It freshly
builds Release, exports workload bytes, requires a fresh successful real kernel accounting validation, then uses the **same blocking idle validator source as BenchmarkDotNet**
(40% of one core, up to 45 seconds settling). A second Linux aggregate CPU check closes process-enumeration
blind spots. Checks bracket every launch. An idle refusal invalidates the collection and is preserved;
`JINT_BENCH_SKIP_IDLE_CHECK` is rejected. Unsupported platform/counter conditions fail before measurement.
This does not continuously monitor interference inside an individual workload; that limitation remains in
the evidence alongside the recorded physical-host or hosted-VM boundary.

The Linux topology behavior matches the repository's unpinned fallback. No power plan is changed; fixed-clock
overrides are rejected. Production tiering/PGO stays enabled. Jint uses blocking workstation GC; other engines
retain their own GC. Inherited runtime tuning overrides are rejected rather than silently changing a lane.

Six rounds cover all six orders of the three browsers, with three independent launches per row per round.
This balances middle positions as well as first/last order. Execution is serial. The analysis first takes each
browser's median launch value within a round, then forms paired round percentage differences. It reports their
median and a 95% percentile bootstrap interval over paired rounds (10,000 resamples, seed 3575), preserving
all pairs. These are pointwise exploratory intervals, without correction for multiple comparisons. Launches are not treated as independent rounds. An interval crossing zero has unresolved direction;
an untouched control establishes the experiment's noise floor. There is no control subtraction.

Analysis rejects missing/duplicate/reordered pairs, changed binary/dependency identities, mismatched workloads,
failed idle checks, digest mismatches, incomplete teardown, regressing/missing counters and invalid memory
samples. The artifact directory keeps the configuration, source/binary hashes, actual Lightpanda `version`
output and release identity, runtime/host details (including Linux CPU topology, memory and clock policy), schedule, idle logs, per-launch raw JSON and errors.
`manifest.json` records each planned row before it starts and is updated atomically as rows finish.
A per-launch lifecycle journal records every stage and a structured terminal failure/cleanup/accounting
verdict. The collector separately persists its own scope-cleanup attempt, even if the runner times out. A failed or partial
manifest is never accepted by analysis. Preserve the whole directory, not just a rendered table.

## Validation and calibration

```sh
dotnet test --project tools/browser-comparison.Tests/BrowserComparison.Tests.csproj -c Release
python3 -m unittest discover -s tools/browser-comparison -p 'test_*.py'
JINT_BROWSER_COMPARISON_CGROUP_ROOT=/sys/fs/cgroup/already-delegated-comparison-root \
  python3 -m unittest discover -s tools/browser-comparison -p test_linux_accounting.py
```

The kernel leg verifies a CPU/memory child whose parent has exited and retained counters. The Release
adapter tests exercise the actual owned-process cleanup for cancellation, timeout, checksum assertion
and early launch failures, including reparented children in the delegated Linux CI leg.
It reports a skip when delegation is absent; that is not a passing kernel validation. Synthetic tests cover
scheduling, counter failures, pairing and confidence intervals without supplying performance evidence.
An empirical A/A calibration is available by adding `--calibrate jint` (the configured adapter name) to
the gate collection command. It runs two identical owned configurations through the same collector and
analysis. Calibration must run on the same machine (the same VM for a hosted run) before trusting small deltas; this
repository does not claim that synthetic zero-difference fixtures constitute that calibration.

## Manual hosted-runner feasibility

`.github/workflows/browser-comparison-hosted.yml` offers `verify`, `calibrate`, and `compare` dispatches
on `ubuntu-24.04`, with a 360-minute job ceiling and no parallel measurement jobs. This is a hosted-VM
feasibility experiment, not evidence that its physical host is dedicated or idle. An accepted guest idle
check cannot observe noisy physical neighbors. Keep this boundary alongside any conclusions. Campaign
acceptance also requires an independent repeat under the agreed criteria; a single successful hosted run
does not establish reproducibility or guarantee that a later runner will qualify.

`verify` installs the reviewed `hosted-pins.json` assets and runs correctness only. `calibrate` runs Jint
A/A through the existing six-round, three-launch collector. `compare` first performs that calibration,
then the full three-browser collection serially on the **same VM**. Inspect the calibration intervals and
untouched controls before interpreting differences; a completed calibration is not an automatic noise-floor
acceptance verdict. A refusal or timeout is feasibility evidence, never a timing result. No gate is weakened,
no service unrelated to this run is disabled, and no physical-host control is claimed.

The optional `browser_pins` JSON dispatch input replaces the reviewed pins in full. Full Chrome for Testing
must have a versioned official linux64 ZIP URL, exact version and independently reviewed archive SHA256.
Lightpanda must have an official release asset API URL, matching asset ID and SHA256 of the executable.
The checked-in pins were resolved before measurement; a mutable nightly alias is never sufficient.
The default Lightpanda asset is the Linux x86_64 binary from
[stable release 0.4.1](https://github.com/lightpanda-io/browser/releases/tag/0.4.1).
The earlier pinned nightly asset was removed upstream before calibration; changing the pin requires
fresh correctness verification, and its results must not be mixed with the earlier binary's evidence.
`setup_hosted.py` verifies downloaded bytes **before execution**, preserves download/executable hashes,
records actual version output, and creates the collector configuration outside the clean checkout.
No Lightpanda source is accessed. A removed asset or digest mismatch fails without resolving a replacement.

Ubuntu's user-namespace restriction needs a scoped allowance for downloaded Chrome. The workflow uses
[Chromium's documented AppArmor option 2](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md),
with an exact canonical executable path instead of a glob. A run/attempt-specific root-owned profile
allows `userns` for that executable; the path alphabet rejects AppArmor patterns and variables.
Chrome's sandbox stays enabled, no global sysctl is changed, and no unrelated profile is reloaded.
The generated policy, its hash, and load/removal logs remain in the artifact. Cleanup removes only the
profile installed by this run, after stopping its measurement service. No AppArmor setup runs locally.

Only the measurement invocation runs in a transient systemd unit. Administrative creation delegates CPU and
memory controllers to that unit; `DelegateSubgroup=controller` keeps the unprivileged collector out of the
empty parent. `run_hosted.sh` enables controllers only in its own delegation and creates the empty measurement
root there. The unchanged collector still creates a fresh child scope per owned browser launch and runs
its real kernel validation. Unit termination cleans only that invocation's descendants. No collector code
acquires elevated privileges or changes a parent cgroup policy.

Always-uploaded artifacts include dispatch pins, runner image and source identity, browser archives/binaries,
setup manifest, build and driver logs, full calibration/comparison directories, accepted analysis or partial
failure evidence. Retention is 30 days; retain the artifact independently before using it for campaign closure.
The Python runner, idle audit, library preflight and setup rejection tests run in comparison CI
without downloading browsers or changing host packages. The Linux accounting test skips in this
first pass; the delegated-cgroup CI step runs it separately:

```sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tools/browser-comparison -p 'test_*.py'
```

The hosted image `20260907.300.1` contained three dangling LLDB18 Python library links, all owned by
`python3-lldb-18`; strict system-library hashing correctly refused verification. The setup preflight inventories
all dangling links and installed package versions. It permits only those exact paths, targets and ownership,
then simulates removal of the unused `python3-lldb-18` package without autoremove. The removal set must be a
subset of `python3-lldb-18`, `lldb-18`, and `lldb`, contain the defective package, and install/configure nothing.
The same package-manager removal is then applied, actual package changes must match the simulation, and a
fresh inventory must contain no dangling links. Unknown defects fail before mutation. This is recorded setup
before binary manifests or measurements; it neither skips library hashes nor changes the collector.
`library-preflight/` retains link inventories, package versions, apt logs and the terminal repair verdict.
The workflow's `inspect` phase (or the helper's `--inspect`) records evidence without any package mutation.

### Hosted idle-category audit (diagnostics only)

The `idle-audit` workflow phase samples the unchanged two-second aggregate `/proc/stat` guard
continuously for 30 minutes on a fresh hosted VM. It skips .NET setup, library repair, browser downloads,
AppArmor setup, builds, calibration and comparison. Every flushed JSONL observation retains the eight
raw start/end counters, category tick deltas, and each category's contribution to percent of one core.
Guest counters are already included in user/nice; idle and iowait contribute zero, while steal remains busy.
The existing 40% acceptance boundary and elapsed-time arithmetic are unchanged.

The always-uploaded artifact includes all observations and a diagnostics-only summary of refusals and
category totals/maxima. An accepted observation or completed audit does not accept any benchmark,
calibrate a noise floor, or establish physical-host isolation. Failures retain observations already written.

---

### Overview (`tools/cdp-histogram/README.md`)

# CDP client handshake histogram

What does an automation client *actually* send? Not what the protocol describes, and not what a
compatibility table claims — what Puppeteer, PuppeteerSharp, Playwright, Playwright for .NET and the Chrome
DevTools frontend put on the wire when they drive a real Chrome through one ordinary scenario.

This tool answers that by recording it. It launches Chrome, puts a man-in-the-middle proxy in front of its
DevTools endpoint, points each client at the proxy, walks all of them through the *same* twenty-five step
scenario, and writes one file per client plus a cross-client matrix into
[`tools/devtools-protocol/handshakes/`](../devtools-protocol/handshakes/). Those files are the
implementation manifest for `Jint.DevTools` and `Jint.Browser`: a method that appears in the matrix is a
method some client will call, and a method that appears in the **minimum must-answer set** is one nothing
works without.

The results are checked in. Re-recording is a deliberate act — a client release changes them.

## Running it

```bash
cd tools/cdp-histogram
npm install                 # also downloads Chrome for Testing (puppeteer's postinstall)
node run-all.mjs            # records every client, then rebuilds matrix.md
```

Other entry points:

```bash
node run-all.mjs --only=playwright-node,puppeteer-node   # a subset
node run-all.mjs --skip=devtools-frontend                # everything but the best-effort capture
node run-all.mjs --matrix-only                           # rebuild matrix.md from the checked-in results
node fixture/serve.mjs                                   # just the fixture site, to look at it
```

Needs Node 20+ and, for the two .NET clients, the .NET 10 SDK; `run-all.mjs` builds those two projects
itself. **There is exactly one browser**: the Chrome for Testing that `npm install` downloaded. Playwright
connects over CDP to that same binary rather than to a browser of its own, so no `playwright install` is
needed and no client's numbers are a different Chrome's numbers.

`raw/` holds the per-run JSON-lines logs and the scenario metadata. It is gitignored; only the aggregated
results are checked in.

## The proxy

`proxy.mjs` starts an HTTP server on an ephemeral loopback port and does two things.

**It serves the discovery documents**, `/json/version`, `/json/list`, `/json` and `/json/protocol`, by
fetching Chrome's and rewriting every `ws://host:port` — and every `ws=host:port` inside a
`devtoolsFrontendUrl` — to point at itself. Without that rewrite a client that connects by URL
(`connect({ browserURL })`, `connectOverCDP(url)`) reads the endpoint out of `/json/version` and connects
straight to Chrome, and the recording is empty.

**It relays WebSockets**, accepting a connection on any path and opening a matching one to Chrome on the
same path, so both the browser endpoint (`/devtools/browser/<id>`) and any page endpoint work. Frames are
relayed byte for byte: the proxy never rewrites a frame, never answers a method itself, and never reorders
anything.

Every frame becomes one JSON line:

```json
{"seq": 41, "t": 1832, "direction": "c2b", "sessionId": "…", "id": 12,
 "method": "Runtime.callFunctionOn", "paramsKeys": ["functionDeclaration", "objectId", "arguments"],
 "paramsValues": {"awaitPromise": true, "returnByValue": false}}
```

- **Requests record parameter *keys*.** Values are recorded only where the value is itself the finding:
  the session model (`Target.setAutoAttach`'s `flatten`, `waitForDebuggerOnStart`, `autoAttach`, `filter`),
  the shape of a result (`Runtime.evaluate` / `callFunctionOn`'s `awaitPromise`, `returnByValue`,
  `generatePreview`, `userGesture`), and the names a client expects an implementation to accept
  (`Page.createIsolatedWorld`'s `worldName`, `Runtime.addBinding`'s `name`). No script source, no page
  content, no cookie values, no URLs.
- **Responses are correlated to requests by `(sessionId, id)`**, so each request row carries the
  `resultKeys` it got back, or the `{code, message}` it failed with. The pair is the key, not the id alone:
  a browser-level id and a session-level id collide otherwise.
- **A frame carried inside `Target.sendMessageToTarget` / `Target.receivedMessageFromTarget` is unwrapped
  and recorded too**, so the pre-flattened session model would show up as protocol traffic rather than as
  one opaque command. No client recorded here uses it — every one of them asks for flattened sessions —
  but a client that did would not go unrecorded.

Scenario scripts mark step boundaries with `GET /__mark?name=<step>` on the same server, which puts the
mark in the same log as the frames; slicing per step is then a matter of reading the log in order, with no
clock comparison. Each mark waits 250 ms first, so the events a step *caused* are attributed to that step
rather than to the one after it.

## The scenario

`fixture/index.html` is deliberately ordinary: a heading, three links (one `#hash`, one to a second page,
one external-looking one that is never followed), a form with a text input, a checkbox, a `<select>` and a
`<textarea>`, a button that sets `document.title` and appends `#late` after 300 ms, an interval that
mutates a `<div>`, a cookie, a `localStorage` write, a `console.log` and a `console.error`, and a
`fetch('/api.json')` on load. `page2.html` is a plain second document so that `goBack` crosses a real
navigation.

Every client walks the same twenty-five steps in the same order (`steps.mjs` is the list). A client that
cannot do a step records it as skipped rather than substituting something else — the per-step columns of
the matrix only mean something if they compare like with like.

### Which steps are not what their names suggest

This is the single most important caveat in the output, and it is what makes the matrix readable:

- **`$`, `$$`, `waitForSelector`, `type`, `select`, `content`, `title`, `evaluate` and the `localStorage`
  read are all `Runtime.callFunctionOn`.** Both client families ship a JavaScript "injected script" into an
  isolated world and call *into* it; the query, the wait loop and the text extraction happen in the page,
  not in the protocol. `DOM.*` never appears for any of them. So a `DOM` domain that only implements
  `getDocument`/`querySelector` satisfies nobody, and a `Runtime.callFunctionOn` that handles object
  handles, `returnByValue` both ways and `awaitPromise` satisfies almost everybody.
- **`DOM` appears only to turn a JavaScript handle into something clickable.** `DOM.describeNode` +
  `DOM.resolveNode` for Puppeteer; `DOM.scrollIntoViewIfNeeded` + `DOM.getContentQuads` for Playwright.
  Neither client ever sends `DOM.enable` or `DOM.getDocument`.
- **`click` and `type` are `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent` at coordinates**, resolved
  by the step above. That is why the campaign's flat-box renderer has to answer a box for every element.
- **`cookies` is `Storage.getCookies` / `Storage.setCookies`**, not `Network.getCookies`, for all four
  clients.
- **`goBack` is `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`.** It is also the step where a
  waiter has to be careful: Chrome restores the previous page from the back/forward cache, which fires no
  `load` event, so Playwright's default `waitUntil: 'load'` times out and the scenario asks for `'commit'`.
- **`pdf` is `Page.printToPDF` returning a *stream handle*, then `IO.read` until done and `IO.close`.**
  Both client families take the stream, not the inline base64.
- **`screenshot` is `Page.captureScreenshot` for Puppeteer, and additionally `Page.getLayoutMetrics` plus
  two evaluations for Playwright.**
- **Request interception is `Fetch`, not `Network`.** `Fetch.enable` with URL patterns, `Fetch.requestPaused`,
  `Fetch.continueRequest` — plus `Network.setCacheDisabled` on the way in.
- **`newContext` is `Target.createBrowserContext`, and every page lives in a flattened session.** No client
  recorded here ever sends `Target.attachToTarget`; all four use `Target.setAutoAttach` with
  `flatten: true` and let the browser attach for them.

## The best-effort capture

`devtools-frontend.json` is marked `bestEffort` and is **not** the canonical scenario. The Chrome DevTools
frontend is a user interface, not a client library: it has no `goto`/`click`/`evaluate` API to drive. It is
also pointed at a **Node inspector** target rather than a page, because a Node-flavoured target is what
`Jint.DevTools` will look like. What is recorded is its passive handshake plus whatever the Sources panel
asks for, obtained by loading the frontend with `&panel=sources`; no UI was driven and no breakpoint was
set, because driving DevTools' own shadow roots would be a brittle test of its markup rather than of the
protocol.

Two things make it work at all, and both are worth knowing before re-running it:

- Chrome refuses to navigate an ordinary page to `devtools://`, so the frontend is loaded from
  `chrome-devtools-frontend.appspot.com`. That host serves by devtools-frontend commit and only serves
  revisions that were rolled into a Chromium release, so the revision is read from the **DEPS file of the
  exact Chrome being driven** (`devtools_frontend_revision` at that version tag) rather than from the tip
  of the frontend repository, which the host does not serve. `CDP_FRONTEND_REVISION` or
  `CDP_FRONTEND_URL` override the lookup.
- Chrome 148 blocks a request from a public origin to a loopback one
  (`net::ERR_BLOCKED_BY_LOCAL_NETWORK_ACCESS_CHECKS`), and the frontend's socket to the proxy is exactly
  that request, so the throwaway browser that hosts the frontend page runs with Local Network Access
  disabled. Without it the recording is silently empty and reads as "the frontend sends nothing".

If no frontend build answers, the file says so in a `notRecorded` field instead of holding a fake
recording.

## The .NET harnesses

`dotnet/Histogram.PuppeteerSharp` and `dotnet/Histogram.Playwright` are two console projects that walk the
same scenario from .NET. They are **deliberately outside `Jint.slnx`** — they need a browser and a network,
which is not what the solution builds — and the `dotnet/` folder carries its own empty
`Directory.Build.props`/`.targets` and a `Directory.Packages.props` that turns central package management
off, so the MSBuild walk stops before it reaches Jint's own build and neither project can be broken by a
change to it. The client package version lives in one MSBuild property per project, is used by the
`PackageReference` and is stamped into the assembly, because both packages ship an assembly whose version
reads `1.0.0` and "which client version sent this" is the whole point.

Playwright for .NET needs no browser install: `Playwright.CreateAsync()` starts the Node driver bundled in
the package, and `ConnectOverCDPAsync` then attaches to the Chrome the proxy fronts. If that driver cannot
start, the harness writes the reason into its metadata and exits rather than reporting an empty client.

## Reading the results, and their shelf life

`tools/devtools-protocol/handshakes/<client>.json` holds, per client: the client and Chrome versions, the
scenario sliced into steps with per-method counts, parameter keys, result keys and errors, `allMethods` and
`allEvents` in order of first appearance, and the `sessionModel`.
`tools/devtools-protocol/handshakes/matrix.md` is generated from those files and holds the cross-client
table, the minimum must-answer set, what the element path adds on top, the commands Chrome answered with an
error, and the per-step breakdown.

- **Counts are indicative; the method sets are the answer.** A count moves with timing — how many
  `Page.lifecycleEvent`s a load produces, whether a flaky `Inspector.targetCrashed` shows up — and a diff
  in the counts alone is not a finding. A method appearing or disappearing is.
- **A client version bump changes the files**, sometimes visibly: Puppeteer's isolated world is named
  `__puppeteer_utility_world__<its own version>`, so the recorded `worldName` carries the version number.
- **Step attribution is best-effort at the edges.** The 250 ms settle before each mark catches most trailing
  events, but an event that arrives later lands in the next step. Responses do not have this problem: they
  are correlated back to the request's row by `(sessionId, id)` no matter when they arrive.
- **The proxy is not a security boundary.** It is a loopback recorder for a browser this tool launched, with
  no authentication, and it exists for the duration of one scenario.

---

### Overview (`tools/cldr-dates/README.md`)

# CLDR date and time patterns

`Jint.CldrDates.Generator` writes [`Jint/Native/Intl/Data/DateTimePatterns.bin`](../../Jint/Native/Intl/Data/DateTimePatterns.bin),
an embedded resource with CLDR's Gregorian date and time patterns and names for every locale cldr-json carries, and
[`DateTimePatternData.Data.cs`](../../Jint/Native/Intl/Data/DateTimePatternData.Data.cs), the header that says where
it came from. [`DateTimePatternData`](../../Jint/Native/Intl/Data/DateTimePatternData.cs) reads it. It writes the other
calendars' patterns and names beside them, into
[`DateTimeCalendarPatterns.bin`](../../Jint/Native/Intl/Data/DateTimeCalendarPatterns.bin) and its header
[`DateTimeCalendarPatternData.Data.cs`](../../Jint/Native/Intl/Data/DateTimeCalendarPatternData.Data.cs), which
[`DateTimeCalendarPatternData`](../../Jint/Native/Intl/Data/DateTimeCalendarPatternData.cs) reads
([the other calendars](#the-other-calendars)). It is the
`[[LocaleData]]` [issue #4158](https://github.com/sebastienros/jint/issues/4158) moves `Intl.DateTimeFormat` onto:
a component bag is resolved against it by the format matcher,
[`DateTimePatternGenerator`](../../Jint/Native/Intl/DateTimePatternGenerator.cs), which resolves a `dateStyle` and
`timeStyle` too, and the ranges of both are written with their interval patterns by
[`DateTimeIntervalFormat`](../../Jint/Native/Intl/DateTimeIntervalFormat.cs); their reference models and golden tables
are under [`reference/`](#the-format-matchers-reference-model).

**The build never runs the generator.** Its outputs are committed, and regenerating them is a manual step taken
when the pinned release changes.

## Inputs

[`pin.json`](pin.json) pins CLDR 48.2 (`release-48-2`):

| Input | What it gives | Pinned by |
| --- | --- | --- |
| [`cldr-dates-full-48.2.0.tgz`](https://www.npmjs.com/package/cldr-dates-full/v/48.2.0) | `main/<locale>/ca-gregorian.json` and `dateFields.json` for 766 locales, each already resolved through CLDR's inheritance | npm integrity `sha512-zMrKvjMO414HDftLr6vKH1fNQHakFNe+F1h4S0eg+zmL4WRi9pI41fxB3UrTJDY42MEM87jCuAw/qlqCgYKcAQ==` |
| [`cldr-core-48.2.0.tgz`](https://www.npmjs.com/package/cldr-core/v/48.2.0) | `availableLocales.json` (the locale list, cross-checked), `defaultContent.json`, `supplemental/likelySubtags.json`, `supplemental/parentLocales.json` (cross-checked) and `supplemental/dayPeriods.json` | npm integrity `sha512-zfmLothncSwfv2jlevoSrgI2VGgH8SDGHXst6jUEotHA8nq9Igeg9jzdJDFtBso9pgJuG89DK16TzrmZDdo2Bg==` |
| [`common/supplemental/supplementalData.xml`](https://github.com/unicode-org/cldr/blob/release-48-2/common/supplemental/supplementalData.xml) at `release-48-2` | `<parentLocales>`, the parent table, as the other CLDR tables under `Jint/Native/Intl/Data` are read from it | SHA-256 `cd2af39aef82fdbfba4d591c87548203350538ad2318486d104b3b38b8d62f1a` |
| `cldr-cal-{buddhist,coptic,ethiopic,hebrew,indian,islamic,japanese,persian,roc}-full-48.2.0.tgz` | `main/<locale>/ca-<calendar>.json` for the other calendars, resolved as `cldr-dates-full` is | each package's npm integrity, in `pin.json` |
| [`cldr-common-48.2.zip`](https://unicode.org/Public/cldr/48.2/cldr-common-48.2.zip), 36 MB | `common/main/*.xml`, CLDR's unresolved locale files: which calendar values a locale defines itself, which ICU's data is built from and resolved JSON cannot show ([the other calendars](#the-other-calendars)) | SHA-256 `d2844f9dbf6124d11a7b047f5381a467902d82a673be3d658f4c0791ffa0b83b` |

Each file is downloaded into a cache directory (by default `jint-cldr-dates` under the temporary directory; `--cache`
changes it) and checked against its pin before anything reads it. A cached copy that does not match is fetched again,
and a download that does not match fails the run: do not update the pin to whatever came back without finding out why
it changed.

## What is extracted

For each locale, from the `gregorian` calendar:

- `dateTimeFormats/availableFormats`, less the skeletons with a quarter (`Q`) or week (`w`, `W`) field, which ECMA-402
  has no option for, and CLDR's `-count-` and `-alt-` variants. That leaves 49 to 61 per locale.
- `dateTimeFormats` `full`/`long`/`medium`/`short`, and `dateTimeFormats-atTime/standard`, which ICU 72 and later join a
  date and a time with ("at", "um", "à").
- `dateFormats` and `timeFormats`, the four style patterns each.
- `dateTimeFormats/appendItems`, and the wide display name of each field they name (`dateFields.json`'s
  `fields/<field>/displayName`), which is what ICU puts in their `{2}`.
- `months` and `days`, `format` and `stand-alone`, every width; `eras` (`eraAbbr`, `eraNames`, `eraNarrow`); and the
  `format` `am`/`pm` day periods in three widths.
- The flexible day periods a `B` field writes, `format` context, in the same three widths: `noon` and `morning1` to
  `night2`, empty where CLDR has no name, and a missing wide or narrow one filled with the abbreviated one, as ICU's
  `DateFormatSymbols` fills it. ICU writes only the `format` context and has not written `midnight` since ICU 57, so the
  stand-alone names and `midnight` are left out.
- The day period rules that pick a flexible day period for an hour, from cldr-core's `supplemental/dayPeriods.json`,
  looked up for each locale as ICU's `DayPeriodRules::getInstance` looks them up: the locale, then each truncation of
  it, not the CLDR parent chain, so `zh-Hant` (whose parent is the root) has `zh`'s. A locale whose language has none
  gets the root's, `am` and `pm`, which ICU writes the same as no rules. Each is written as one character per hour —
  the period's position in the names (`1` `morning1` to `8` `night2`), or `a`/`p` — then `n` where the rules have
  `noon` at 12:00; English's is `111111111111222222333444n`. A rule set fails the run unless every hour of the day is
  in exactly one period, every period is whole hours, and only `midnight` at 00:00 and `noon` at 12:00 are `at` rules.
- `dateTimeFormats/intervalFormats`: `intervalFormatFallback`, and each skeleton's pattern for each greatest-difference
  letter ICU reads (`G y M d a B h H m`; CLDR's `-alt-` variants are left out). They are stored as ICU's
  `DateIntervalInfo` ends up holding them, one pattern per skeleton and field (`a` and `B` are one field, and so are `h`
  and `H`): each from the nearest locale of the parent chain that has it — a locale's own being the letters whose
  pattern differs from its parent's — and within one locale the first letter in binary order, so `zh-Hant`'s `B`
  pattern for `h` is read and its `a` one is not.

The other calendars are extracted into a resource of their own ([the other calendars](#the-other-calendars)).

The run fails, listing every problem at once, unless: cldr-dates-full and `availableLocales.json` name the same
locales; every value is present and non-empty (a flexible day period may be missing), with twelve months, seven weekdays, two eras and am/pm; every pattern
parses (every quote closed) and uses only the letters ECMA-402's fields map to, `GyMLdEcabBhHKkmsSzvO`; every join
and appendItems pattern places `{0}` and `{1}` (and appendItems only `{2}` besides) with no letter outside quotes; the
XML's and cldr-core's parent tables agree; every locale's parent chain reaches the root through locales cldr-json
carries; and every interval pattern splits into its two dates where a field letter recurs (ICU's
`splitPatternInto2Part`), and every fallback places both. Then it decodes what it wrote, independently of the writer, and requires every locale to come back exactly.

CLDR 48.2 itself breaks the letter rule six times — `de-CH` `GyMEd` and `gd` `yMMM`, `ksh` `yM` and `sc` `yM` use the
week-numbering year `Y`, and `fa` and `fa-AF` have an `HHmmZ` skeleton — and writes one numbering-system override,
`haw`'s short date (`M=romanlow`). They are kept as CLDR writes them (the override is dropped: the resource has
nowhere to put it) and are listed by name in `CldrLocale.cs`, so a new one fails the run, and so does a listed one
that no longer occurs. `Jint.Tests` pins the same list. So is `fa`'s (and `fa-AF`'s) `GyMMM` month pattern,
`LLL تا MMM y G`, which repeats no letter (`KnownUnsplittableIntervals`).

## The other calendars

`DateTimeCalendarPatterns.bin` carries ten calendars, for every locale: `buddhist`, `japanese`, `roc`, `hebrew`,
`persian`, `indian`, `coptic`, `ethiopic`, `ethiopic-amete-alem` (`ethioaa`) and `islamic`, which serves all five Islamic
identifiers (the generator requires `ca-islamic-civil.json`, `-rgsa`, `-tbla` and `-umalqura` to be `ca-islamic.json`
in every locale, and their XML to define nothing). `chinese` and `dangi` are not carried: they write a related year and
a year name, in a lane of their own. For each locale and calendar it holds the calendar's `availableFormats`,
`dateFormats`, `timeFormats`, atTime joining patterns, `appendItems`, `intervalFormats` and fallback, its month names
(none for the three calendars that write the Gregorian months; thirteen for the Coptic and Ethiopic ones; thirteen and
Adar II for the Hebrew one) and its eras (the Japanese calendar keeps Meiji to Reiwa, CLDR's 232 to 236). Weekdays, day
periods, field names and the `dateTimeFormats` a range joins its date and time with are the Gregorian locale's, as in ICU.

cldr-json resolves every element through CLDR's inheritance, which is what ICU reads for most of this. Three things ICU
decides by which locale defines a value (icu4c `dtptngen.cpp`, `smpdtfmt.cpp`), and the generator reads those from
`cldr-common`'s XML ([`CldrXml.cs`](Jint.CldrDates.Generator/CldrXml.cs)), counting an element as ICU's data build does:
a pattern of its own, not an alias, not the inheritance marker `↑↑↑`, not `draft="unconfirmed"` or `"provisional"`.
So read, CLDR 48.2's XML says which locales have each calendar's `DateTimePatterns` and `DateTimePatterns%atTime`
exactly as ICU 78.3's data does, 2,724 of 2,724.

- **Style patterns.** ICU keeps a calendar's date, time and joining patterns in one array, `calendar/<name>/DateTimePatterns`,
  which the root aliases to the generic calendar's. A locale has it only where it defines one of them, so a calendar's
  time styles are those of the nearest locale defining its (or the generic calendar's) date patterns: `en-GB`'s Buddhist
  `h:mm a` is `en-001`'s. The date and time formats are written from that locale.
- **Joining patterns.** A date and a time are joined with the calendar's `%atTime`, which the root aliases to nothing;
  failing that with the Gregorian `%atTime` of the nearest locale defining one (`zh-Hant`'s `{1}\u2009{0}`, identical to
  its standard pattern); failing both, with the calendar's own joining patterns (`lv`'s `{1}, {0}`).
- **availableFormats order.** ICU adds a calendar's own entries from every locale of the chain before the generic
  calendar's it inherits; each record lists the skeletons its locale defines for the calendar itself, and the format
  matcher adds those first. The order decides ties (`en-GB`'s Hebrew `Sat, 30 Kislev`).

The validation lists are their own: `CalendarExtraction.KnownNumberingOverrides` (`haw`'s Roman months, `ja`'s 元年,
`he` and `yi`'s Hebrew numerals, all dropped as the Gregorian `haw` override is), `KnownLetterExceptions` (the generic
calendar's week-numbering `Y` in six locales, `fa`'s `Z` and `ko`'s `e`) and `KnownUnsplittableIntervals` (`fa`'s), each
keyed by locale, calendar (none for every calendar) and path, each failing the run when it no longer occurs.

## Regenerating

From the repository root:

```pwsh
dotnet run --project tools/cldr-dates/Jint.CldrDates.Generator -c Release
```

It prints each resource's size, in total and for the largest blocks, and leaves a resource whose content is the
committed one as it is. `--check` regenerates in memory and compares the content with the committed headers instead of
writing anything.

The deflated bytes depend on the runtime's zlib, so a different .NET can write a different `.bin` from the same
inputs. The header therefore records two hashes: the file's, and the **payload's** — the inflated index followed by
every inflated block, which does not depend on the encoder. `--check` compares the payload, and
`IntlDateTimePatternDataTests` recomputes it from the embedded resource, so a `.bin` and a header from different runs
fail the test.

## The resource

All integers are unsigned LEB128 varints; a string is a varint byte length followed by UTF-8.

```text
file:   "JDTP"  version=2  varint indexLength  varint indexDeflatedLength  index (raw deflate)  blocks
index:  string cldrVersion
        varint slotCount, slotCount x string slotName
        varint blockCount, blockCount x { string language, varint offset, varint deflatedLength, varint length,
                                          varint localeCount, localeCount x string locale }
        varint parentCount, parentCount x { string locale, string parent }
block:  one record per locale, in the index's order (raw deflate; offset counted from the end of the index)
record: varint n, n x { varint gap, string value }        slots: the index is the previous one + gap + 1
        varint n, n x { string skeleton, string pattern } availableFormats entries new or different, in ordinal order
        varint n, n x string skeleton                     availableFormats entries the parent has and this locale drops
        varint n, n x { string skeleton, byte field, string pattern }
                                                          interval patterns new or different, in ordinal order
        varint n, n x { string skeleton, byte field }     interval patterns the parent has and this locale drops
```

An interval pattern's field is one of `G y M d a h m`, the letter ICU's interval index names it by. Version 1 had no
interval lists.

A **slot** is one fixed-position value; the 207 of them are listed in
[`SlotLayout.cs`](Jint.CldrDates.Generator/SlotLayout.cs), each named by its JSON path (the day period rules by
`supplemental/dayPeriodRuleSet`), and the index carries the
names so the loader can check them against its own offsets. Every locale but the root (`und`) stores only what
differs from its **CLDR parent** ([TR35](https://www.unicode.org/reports/tr35/#Parent_Locales)): the parent table's
entry, else the root for a language-and-script locale whose script is not the language's likely one, else the
locale with its last subtag removed (a language's parent is the root). cldr-json leaves out the default-content
locales, so a parent it does not carry is skipped for that parent's own (`ca-ES-valencia` inherits from `ca`, past
`ca-ES`). The index lists every parent that is not plain truncation; the loader truncates for the rest.

There is one deflated block per language (the locale's first subtag), so a process inflates only the languages it
uses — plus the root's, which every language's records are relative to, and a parent's in another language where
CLDR has one (`nb` and `nn` inherit from `no`, `hi-Latn` from `en-IN`, `ht` from `fr-HT`).

`DateTimeCalendarPatterns.bin` follows `DateTimePatterns.bin`'s language blocks, locales and parents without repeating
them:

```text
file:   "JDTC"  version=1  varint indexLength  varint indexDeflatedLength  index (raw deflate)  blocks
index:  string cldrVersion
        varint calendarCount, calendarCount x { string cldrName, varint n, n x string identifier, varint monthCount,
                                                varint n, n x string eraKey, varint slotCount, slotCount x string slotName }
        varint blockCount, blockCount x { string language, varint offset, varint deflatedLength, varint length,
                                          varint localeCount }
block:  each locale of the language in DateTimePatterns.bin's order, and for each, each calendar in the index's order:
record: a DateTimePatterns.bin record over the calendar's own slots, relative to the parent's record of the calendar,
        then varint n, n x string skeleton                the availableFormats the locale defines for the calendar
```

A calendar's slots are its date, time and atTime formats, appendItems and interval fallback (24), its months by context
and width, and its eras by width; the index names them. Holding a locale's records of every calendar side by side is
what lets deflate find what the calendars share, which are all the generic calendar's patterns under other eras and
months: 175 KB deflated for the ten, where one block per calendar and language costs 860 KB. A process inflates a block
the first time a calendar is asked for in its language, and reads only that calendar's records out of it.

## The format matcher's reference model

[`reference/format_matcher.py`](reference/format_matcher.py) is a Python model of ICU's `DateTimePatternGenerator` as
V8 drives it, over the same cldr-json inputs, and `DateTimePatternGenerator` is its C# port. Neither the build nor the
tests run it; it writes [`reference/golden.tsv`](reference/golden.tsv), which `Jint.Tests`
(`IntlDateTimeFormatMatcherTests`) embeds straight from here and checks the port against: 51 locales by 25 option bags,
each formatted at two instants, with the pattern the model chose and the options `resolvedOptions()` reports.

The table is the model's output over CLDR 48.2, not an engine's, so a difference between the CLDR an ICU build carries
and the one Jint embeds cannot hide in it. Its last column is what ICU writes where it differs, taken from
[`reference/probe-golden.js`](reference/probe-golden.js) run under Node: with Node 24.19 (ICU 78.3, CLDR 48.0) every
row's text agrees, and 21 rows' `resolvedOptions()` differ, deliberately — 20 where V8 reads a letter inside a quoted
literal as a field (Spanish `MMMM 'de' y` "has" a day), and Japanese `hour12: true`, which test262 puts on `h11` and V8
on `h12`. `IntlDateTimeFormatMatcherTests.KnownIcuDifferences` lists them.

To regenerate, with cldr-json unpacked as the model's docstring describes:

```sh
node reference/probe-golden.js > icu-golden.tsv
python reference/format_matcher.py compare <cldr-json-root> icu-golden.tsv
python reference/format_matcher.py golden <cldr-json-root> icu-golden.tsv > reference/golden.tsv
```

`compare` prints every row where the model and ICU disagree. Run with the design's wider set — every cldr-json
locale ICU resolves to itself, 641 of them, in the comma-separated `PROBE_LOCALES` both scripts read — the model agreed
with Node on 16,023 of 16,025 formatted strings. The two that differ are data rather than matching: CLDR 48.2 joins an
`fr-ML` date and time with `{1}, {0}` where Node's CLDR 48.0 writes `{1} {0}` (its `dateStyle` + `timeStyle` output
shows the same), and `tok` (Toki Pona) writes a date and time from data that differs between the two releases. A change
to the matcher goes into the model first, then into the port, and the table is regenerated in the same pull request.

## The interval format's reference model

[`reference/interval_format.py`](reference/interval_format.py) is a model of ICU's `DateIntervalFormat` as V8 drives it,
on top of `format_matcher.py`, and `DateTimeIntervalFormat` is its C# port: V8 creates the interval format for the
skeleton of the pattern a single date is written with — the one the matcher chose, or a `dateStyle`/`timeStyle`'s —
with the resolved hour cycle; the skeleton is split into a date and a time
and normalized, the nearest interval skeleton is found and its pattern widened to the request, a date skeleton is
extended by the field that differs (`MMMd` to `yMMMd`), a date and a time on one day are the date once joined to the
time's interval by the medium `dateTimeFormats`, and any other range is the two whole dates in the fallback. It writes
[`reference/range-golden.tsv`](reference/range-golden.tsv), which `IntlDateTimeFormatRangeTests` embeds: the 51 locales,
23 bags — 15 component bags, the four date styles, the `short` and `medium` time styles and two pairs of them — and up
to six pairs of dates, each differing first in the field it is named after, with the text and every part's type, source
and length. A range keeps the U+2009 THIN SPACE CLDR writes around its dash, as V8 does; U+202F is U+0020, as in every
lane (V8 keeps it in a range).

```sh
node reference/probe-range-golden.js > icu-range-golden.tsv
python reference/interval_format.py compare <cldr-json-root> icu-range-golden.tsv
python reference/interval_format.py golden <cldr-json-root> icu-range-golden.tsv > reference/range-golden.tsv
python reference/interval_format.py ties <cldr-json-root>
```

With Node 24.19 every one of the table's 5,610 rows has the same text, and 5,600 the same parts. The other ten are
V8 reporting as `shared` a part ICU writes from the end date, which the specification does not allow a shared part:
`fa` writes the month stand-alone before the dash and in the format context after it, two fields to ICU, and `sw`
writes the month of a month range once, after the second day; the model and Jint report those parts as the end date's.
Over the 641 locales the model agrees with Node on 70,443 of 70,510 rows; the others are those two shapes (`fa`,
`fa-AF`, `sw` and its regions, `wae`), `fr-ML` and `tok`'s data as above, `haw`'s short date, whose `M=romanlow` override
the resource drops, and `yo`'s (and `yo-BJ`'s) `yMMMEd` year pattern,
which repeats the year inside its first date, so that ICU splits it there and writes part of the start from the end
date where Jint writes each part from the date its source names. `ties` reports every interval skeleton lookup with
two candidates at the least distance, where ICU's hash-table order would decide: there is none, so walking the
skeletons in ordinal order is exact.

### The other calendars' table

`format_matcher.py` reads a calendar's data from its `ca-<calendar>.json` and, for the three things ICU decides by which
locale defines a value, from `cldr-common`'s XML, as the generator does ([the other calendars](#the-other-calendars)); it
expects the calendar packages unpacked under `<cldr-json-root>/cal/<package>/package` and `cldr-common-48.2.zip` beside
them. It writes [`reference/golden-calendars.tsv`](reference/golden-calendars.tsv), which `IntlDateTimeFormatCalendarTests`
embeds: 11 locales chosen to exercise ICU's rules, every calendar but `islamic-tbla` (whose data is `islamic-civil`'s), and
21 component bags and styles, at the two instants, each instant dated in the calendar as ICU dates it
(`CALENDAR_DATES`).

```sh
python reference/format_matcher.py calendar-cases > calendar-cases.tsv
node reference/probe-calendars.js calendar-cases.tsv > icu-calendars.tsv
python reference/format_matcher.py compare-calendars <cldr-json-root> icu-calendars.tsv
python reference/format_matcher.py golden-calendars <cldr-json-root> icu-calendars.tsv > reference/golden-calendars.tsv
```

With Node 24.19, 2,535 of the table's 2,541 rows have the same text; the six others are `he`'s Hebrew date styles,
which ICU writes in Hebrew numerals (`numbers="hebr"`) and Jint in the formatter's digits. 574 rows' `resolvedOptions()`
differ, in two ways V8 reads a format record that the specification does not: it recognizes an era only in a run of three
or more `G`, so the `M/d/y G` every one of these calendars writes reports no era, and it reads a letter inside a quoted
literal as a field. `calendar-cases` and `compare-calendars` take a file of locales as an extra argument, and then run
every calendar and all 31 cases: over the 641 cldr-json locales ICU resolves to themselves the model agreed with Node on
238,395 of 238,452 rows' text. The 57 others are `he` and `yi`'s Hebrew numerals, `haw`'s Roman months, the
week-numbering `Y` that `gl`, `ksh`, `my` and `te` patterns use (ICU writes it as the Gregorian week year in these
calendars), and `fr-ML`'s joining pattern, which differs between CLDR 48.0 and 48.2.

### `dateStyle` and `timeStyle`

The model also writes the style table, [`reference/golden-styles.tsv`](reference/golden-styles.tsv), which
`IntlDateTimeFormatStyleTests` embeds the same way: the 51 locales by 53 cases — each date and time style alone and in
all sixteen pairs, twelve with an `hourCycle` or `hour12` the locale does not write, and seventeen Temporal values —
with the pattern, the text at the two instants, the UTC zone name the text writes, and what `resolvedOptions()`
reports. A style is modelled as V8 drives ICU: the locale's `dateFormats`/`timeFormats`, a date and a time joined by
the `atTime` connector of the date's width, and where the resolved hour cycle is not the pattern's, its skeleton
without the day period and with the cycle's hour letter matched again. A Temporal value without all of a style's
fields gets Temporal's AdjustDateTimeStyleFormat: the matcher over the fields it has. For the text only, the model
reads one more cldr-json file than the generator, `timeZoneNames.json` (the `Etc/UTC` name), and writes the flexible
day period `zh-Hant`'s time patterns use from `supplemental/dayPeriods.json` as the generator reads it.

Node's V8 does not ship Temporal, so the case list the probe reads is the model's own, and a Temporal case is asked of
ICU as the component bag AdjustDateTimeStyleFormat matched:

```sh
python reference/format_matcher.py style-cases <cldr-json-root> > style-cases.tsv
node reference/probe-styles.js style-cases.tsv > icu-styles.tsv
python reference/format_matcher.py compare-styles <cldr-json-root> icu-styles.tsv
python reference/format_matcher.py golden-styles <cldr-json-root> icu-styles.tsv > reference/golden-styles.tsv
```

With Node 24.19 every row's text agrees, and 22 rows' `resolvedOptions()` differ for the reasons above: 20 quoted
literals V8 reads as fields, in the component bags the Temporal cases match to, and Japanese `hour12: true`. Jint's
text differs from the table only in the long zone name, which it writes in English in every locale; the test counts
those rows. `style-cases` and `compare-styles` take a file of locales as an extra argument: over the 641 cldr-json
locales ICU resolves to themselves the model agreed with Node on 33,951 of 33,973 rows, and the 22 that differ are data
— `fr-ML`'s connector as above, `haw`'s short date, whose `M=romanlow` override the resource drops (ICU writes `xii`),
`sr-Cyrl-ME`'s long UTC name (ICU's is in Latin script), and `tok`.

## Moving to a later CLDR release

1. Update `pin.json`: the versions and tag, each tarball's URL and npm `dist.integrity` (from
   `https://registry.npmjs.org/<package>/<version>`), the XML's URL and SHA-256, and `cldr-common`'s.
2. Run the generator. A validation failure is upstream data to read, not a list to widen: decide for each new entry
   whether it belongs in `KnownLetterExceptions` or `KnownNumberingOverrides`, with a comment saying what it is.
3. Update the expectations in `Jint.Tests/Runtime/IntlDateTimePatternDataTests.cs`: CLDR's values for a handful of
   locales, the locale count and the list of known letter exceptions.
4. Regenerate [`reference/golden.tsv`](#the-format-matchers-reference-model),
   [`reference/golden-styles.tsv`](#datestyle-and-timestyle),
   [`reference/range-golden.tsv`](#the-interval-formats-reference-model) and
   [`reference/golden-calendars.tsv`](#the-other-calendars-table) from the new release.
5. Run `Jint.Tests`.

Update from a release, never entry by entry: the data is CLDR's own, under the Unicode License v3
([`CREDITS.txt`](../../CREDITS.txt)).

---

### Overview (`tools/devtools-frontend-smoke/README.md`)

# DevTools front-end smoke test

Everything else in this repository that claims Chrome DevTools works against Jint claims it from the
protocol side: a test sends a command and asserts the answer. That proves the server answers. It does not
prove that a person who opens DevTools sees anything.

This drives the other half. It launches `Jint.Repl --inspect`, loads the **real Chrome DevTools front
end** — unmodified, at the revision that shipped in the Chrome being driven — points it at that Jint
process, and then reads the panels: the Sources navigator, the editor, the Console, the Scope pane, the
call stack. Every assertion is made against what the front end rendered, through its own shadow roots.

It is [`Jint.DevTools/docs/manual-checklist.md`](../../Jint.DevTools/docs/manual-checklist.md) minus the
manual part. Run this when the protocol surface changes; walk the checklist by hand for the steps the table
below says are not driven.

## Running it

```bash
cd tools/devtools-frontend-smoke
npm install                 # also downloads Chrome for Testing (puppeteer's postinstall)
node smoke.mjs
```

Puppeteer is pinned to the same version [`tools/cdp-histogram`](../cdp-histogram/) pins, so both tools use
one Chrome for Testing download and neither one's evidence is a different browser's.

The Jint side has to be built first — this tool never builds anything, because a second MSBuild in a working
tree somebody else is building is its own kind of flake:

```bash
dotnet build -c Release Jint.Repl/Jint.Repl.csproj
```

It looks for `artifacts/bin/Jint.Repl/release/Jint.Repl.exe`, then the extensionless apphost, then
`Jint.Repl.dll` (run through `dotnet`), and says all three plus the build command when it finds none.

| Environment variable | What it does |
| --- | --- |
| `JINT_REPL` | Use this binary instead of the built one. A `.dll` is run through `dotnet`. |
| `CDP_FRONTEND_REVISION` | Load this devtools-frontend commit instead of the one Chromium's DEPS names. |
| `CDP_FRONTEND_URL` | Load this `js_app.html` instead, skipping the lookup entirely. |
| `SMOKE_HEADED=1` | Show the browser, which is how you watch it drive. |

The recording proxy is `tools/cdp-histogram/proxy.mjs`, **imported rather than copied**. Node resolves that
file's `import 'ws'` from *its* directory, not from this one, so it needs a copy of `ws` next door:

```bash
node -e "await (await import('node:fs/promises')).cp('node_modules/ws', '../cdp-histogram/node_modules/ws', { recursive: true })"
```

Both paths are gitignored, so this changes nothing that is checked in, and `smoke.mjs` prints exactly that
command if the import fails. A full `npm install` in `tools/cdp-histogram` works too and does more.

One thing is **not** imported: the devtools-frontend revision lookup. It lives in
`tools/cdp-histogram/scenarios/devtools-frontend.mjs`, which is a script that runs a whole capture on import
and exports nothing, so `frontend.mjs` re-derives it — same DEPS file, same rule, same two overrides.

## What it drives

`fixture/app.js` is what the engine runs, and it is short on purpose. It logs a string and an object, so the
Console has an object preview to render; it declares named functions, so the editor and the gutter have
something worth looking at; a `setTimeout` inside a one-second `setInterval` throws, so an uncaught exception
is reachable; and the function the breakpoint lands in holds a function-valued local, so the Scope pane has a
*script* function of its own to render rather than only the native ones on `Object.prototype`.

The heartbeat re-arms rather than firing once, and that is deliberate. The script itself finishes in
milliseconds while the front end needs seconds to download and connect, so a single `setTimeout` would have
thrown before anybody was listening. The same heartbeat is what makes a breakpoint hittable at all:
`computeTotal` is called once a second forever, so the front end can set a breakpoint whenever it likes and
be paused within a second of doing it.

## The steps, and what each one proves

Each step gets a 60-second budget, a screenshot, a DOM dump, and a row in `out/summary.json` carrying what it
observed. A step that could not be driven is recorded as `not-driven` with what was seen instead — never as a
pass, and never as a weaker assertion wearing the strong one's name.

| # | Step | What a pass means |
| --- | --- | --- |
| 1 | `frontend-connected` | The hosted front end loaded, opened a socket to Jint, and the handshake completed: `Runtime.enable` was answered and `Debugger.scriptParsed` arrived. |
| 2 | `sources-lists-script` | The Sources navigator has a **visible** entry named `app.js` under a **`file://` domain** — every folder down to it is expanded first, because a collapsed tree keeps its children in the document and asserting on one of those would be reporting the DOM rather than the panel. The domain is what says Jint published a `file://` URL rather than a bare path. |
| 3 | `sources-shows-source-text` | Clicking that entry opens the editor and the retained source is in it: `lineTotal`, `computeTotal`, `describeOrders`, `heartbeat` and `reconcile` are all read back out of the CodeMirror content. That is `Debugger.getScriptSource` end to end. |
| 4 | `console-shows-log-with-object-preview` | The Console shows `console.log('order book ready', …)` **with its object preview expanded into properties**, not as a bare `Object`. |
| 5 | `console-message-has-a-source-anchor` | That message carries an `app.js:NN` link on the right, which the front end draws from `Runtime.consoleAPICalled`'s `stackTrace`. A message with no anchor links to nothing. |
| 6 | `uncaught-exception-shows-its-stack` | The throw from inside the `setTimeout` callback is rendered with **its stack expanded, naming `reconcile`** — the callback itself, which is a frame only because a timer callback owns one. |
| 7 | `console-evaluates-expression` | `1+1` typed at the Console prompt and submitted comes back as `2` in a result row. |
| 8 | `breakpoint-set-in-the-gutter-is-hit` | A breakpoint set by clicking the editor gutter — the front end's own `Debugger.setBreakpointByUrl` — stops the engine, the front end says "Paused on breakpoint", and the Call Stack reads `computeTotal` / **`heartbeat`** / `(anonymous)` with an `app.js:NN` link on each row. `heartbeat` is the frame the engine was entered at. |
| 9 | `scope-pane-names-its-functions` | The Scope pane lists the frame's scopes and bindings, **no function renders as `ƒ undefined()`**, and the frame's own `describe` reads `ƒ describeLine(order)` — a name and a parameter that can only have come from the declaration Jint retained. |
| 10 | `resume-continues-execution` | The breakpoint is cleared from the gutter, Resume is clicked, and **the heartbeat in the Console advances past the tick it was paused on**. Execution continuing is what proves a resume; the paused indicator going away only proves the indicator went away. |

## How it reads a user interface

DevTools is built out of custom elements, and nothing interesting is reachable from
`document.querySelector`. `frontend.mjs` installs one helper, `window.__smoke`, before the page loads; it
walks every open shadow root and answers four questions — which elements match a selector, what they say,
their whole text, and where one is on screen. Everything else goes through it, and clicks are real mouse
events at the coordinates it returns rather than synthetic `el.click()` calls, because the gutter and the
tree both listen for the real thing.

The `UI` table at the top of `smoke.mjs` is every selector the tool bets on. When DevTools' markup moves,
that table is the list to read.

Three things make loading the front end possible at all, and all three are inherited from
[`tools/cdp-histogram`](../cdp-histogram/README.md#the-best-effort-capture):

- **Chrome refuses to navigate an ordinary page to `devtools://`**, so the front end comes from
  `chrome-devtools-frontend.appspot.com`.
- **That host serves by devtools-frontend commit and only serves commits that were rolled into a Chromium
  release**, so the revision is read from the DEPS file of the exact Chrome being driven rather than from the
  tip of the front-end repository.
- **Chrome blocks a request from a public origin to a loopback one**, and the front end's socket to Jint is
  exactly that request, so the browser runs with Local Network Access checks disabled. Without it the page
  loads, connects to nothing, and every assertion fails for a reason that has nothing to do with Jint.

## What a run leaves behind

`out/` is gitignored; a run is evidence for a human, not an artefact to check in.

- `out/NN-<step>.png` — a screenshot after every step, pass or fail.
- `out/dom/NN-<step>.txt` — a compact outline of the whole rendered tree, shadow roots included. This is what
  to read when a selector stops matching.
- `out/summary.json` — the environment (Chrome version, front-end revision, the front end's own console
  errors, Jint's stdout), every step's verdict and what it observed, and the protocol traffic sliced per step.
- `out/protocol.jsonl` — every frame between the front end and Jint, one JSON line each, written by
  `tools/cdp-histogram/proxy.mjs`. Step boundaries are in the same log, because each step marks itself on the
  proxy's `/__mark` endpoint before it runs.

## Honesty: what these passes are not

Every step in the checked-in run drove the real user interface and none was faked. What follows is the list
of places where a pass means something narrower than its name, and the things that were not attempted.

- **The navigator's tree is walked by expanding whatever folder it currently shows**, one round at a time,
  rather than by knowing the shape in advance: a `file://` domain has the folders of the path under it, and
  how many there are depends on where the checkout is.
- **The Console's message list is virtualized.** Only the visible tail is in the DOM, and the heartbeat
  pushes everything else off it, so the message logged *before* the front end connected cannot be read
  directly. Step 4 types it into the Console's Filter box first. That is still driving the UI, and it has a
  bonus: a filtered-in message that arrived before the socket existed is proof that the console journal Jint
  replays on `Runtime.enable` reached the front end.
- **The editor is virtualized too**, though not at this file's size. `deepTexts` truncates each entry at 400
  characters for readability; the editor is read with `deepFullText`, which does not. A step that asserts on
  a long text and uses the wrong one silently reads a prefix — that is how the first run of step 3 failed.
- **Step 9 asserts that no function renders as `ƒ undefined()` and that one script function renders with its
  own name and parameter; it does not assert that everything else in the pane is right.** What the pane was
  showing at the moment of the assertion is recorded in `summary.json` under `functionValuedBindings` either
  way. The fixture's `describe` local exists for this step: everything else visible at that pause is native,
  and a native function would only prove the `[native code]` half.
- **`--inspect-brk`, stepping (over / into / out), watch expressions, conditional breakpoints, `Debugger.pause`,
  the Profiler and coverage, and the Memory and Performance panels are not driven at all.** Nothing here says
  anything about them.
- **The screenshots are of a headless Chrome.** `SMOKE_HEADED=1` renders it in a window instead.
- **The protocol recording names methods, result keys and errors — not argument values.** That is
  `proxy.mjs`'s deliberate design, and it is enough to show that a command was sent, answered or refused. It
  is not enough to see *what* a command carried, so the defect notes below that turn on a value were
  confirmed with a direct protocol client rather than by this tool.
- **Reading a user interface by class name is a bet on somebody else's markup.** This is why the tool is not
  in CI: a DevTools release can break it without Jint changing, and a failure here is a prompt to look, not a
  regression by itself.

## What this run found in Jint's DevTools server, and what became of it

The first run of this tool found four defects that no protocol test had caught, plus one inconsistency that
needed a decision. All five are fixed, and each is now asserted by the step named beside it rather than only
described here.

- **A frame the engine was entered at had no name and no `functionLocation`** (step 8). The Call Stack pane
  showed `computeTotal` over an unnamed row where V8 shows the timer callback's own name. Two defects wore
  that one symptom: a host `Invoke` of an anonymous function produced a frame named with the empty string,
  and a `setTimeout` / `setInterval` callback had no frame *at all*, because the timer queue reached its
  callback through `ICallable.Call`, which pushes nothing onto the engine's call stack. The pane now reads
  `computeTotal` / `heartbeat` / `(anonymous)`, each row linking to its own line.
- **`RemoteObject.description` for a function was a rendered label where the front end expects source text**
  (step 9). Jint sent `ƒ computeTotal()`; the front end treats that field as `Function.prototype.toString`
  output and parses the name back out of it, so every function in the Scope pane and in
  `Runtime.getProperties` rendered as **`ƒ undefined()`** — eleven of them in the first run's screenshot. It
  now sends the declaration, and a `PropertyPreview` of a function carries `value: ""`, which is what a real
  Chrome sends and was recorded rather than assumed.
- **`Runtime.consoleAPICalled` carried no `stackTrace`** (step 5), so no console message had a source anchor
  where V8 puts the file and line the call came from. Every message now carries its call site, resolved back
  to a script identifier so the anchor is clickable.
- **The front end called `Runtime.getExceptionDetails` once per uncaught exception and was refused every
  time** (`-32601`, step 6). The command was listed as deliberately absent on the grounds that nothing
  retains an exception's details past the report that carried them — which turned out to be answering the
  wrong question: the front end asks about an error *object* it is holding, and the details are
  reconstructed from that.
- **`/json/list` reported the target's `url` as `file:///D:/…/app.js` while `Debugger.scriptParsed` reported
  the same file as `D:/…/app.js`** (step 2), which is why the navigator filed the script under "(no domain)"
  with its whole path for a name. The decision taken: the engine's source names stay exactly what the host
  passed — a stack trace prints them, and they are the host's — and the protocol maps a name that is an
  absolute filesystem path to a `file://` URL on the way out. Both documents now name one location.

Two refusals still show up in the recording and are working as designed, listed so a reader does not file
them twice: `Network.*` is `-32601` because an engine target has no Network domain, and `Runtime.evaluate`
with `throwOnSideEffect` is `-32000 "Side-effect free evaluation is not supported"`, which costs the Console
prompt its eager-evaluation preview and nothing else.

One thing a reader of a screenshot should not file either: the *text* of a rendered error in the Console
still carries the host's own source names — `at reconcile (D:/…/app.js:55:15)` — because that text is
`Error.prototype.stack`, which is the engine's and the host's. The clickable anchor beside it is the mapped
`app.js:55`.

---

### Overview (`tools/devtools-protocol/README.md`)

# Vendored Chrome DevTools Protocol

`js_protocol.json` and `browser_protocol.json` are copied verbatim from the `json/` directory of
[ChromeDevTools/devtools-protocol](https://github.com/ChromeDevTools/devtools-protocol), at commit

    ea39a11d80de9a08ce2af03f52125ed2e462cf84

which is npm `devtools-protocol@0.0.1687809`, retrieved 2026-09-01. `pin.json` is that record in
machine-readable form and is what the generator stamps into the code it emits; this file and it must
say the same thing.

`devtools-protocol-LICENSE` is that commit's `LICENSE`. The protocol description is
Copyright The Chromium Authors and is redistributed here under the 3-Clause BSD License it carries.
Neither vendored file is edited — a fetch at the pinned commit has to produce no diff, which is
why `.gitattributes` pins both to LF. The one file here that is *not* upstream's is `jint_protocol.json`,
below.

## The domain that is ours

`jint_protocol.json` sits beside the two vendored files, in the same format, and is **not** part of the
fetch: it is this repository's own, and a re-fetch at the pinned commit still produces no diff in the two
files above. It describes the `Jint` domain — `getMarkdown`, `getText`, `getAccessibilitySnapshot` — the way
Lightpanda describes its `LP` one, and the generator reads all three files alike, so a `Jint` command gets
data transfer objects, a dispatch base and a manifest entry exactly as a Chrome command does. The one place
they differ is the citation: a member of a domain Chrome does not have is cited against this file rather
than against Chrome's documentation, because `ProtocolCitationTests` resolves every `chromedevtools` URL
against the vendored JSON and a `Jint` anchor would name nothing there.

It declares the same protocol version as the vendored files, which the reader checks. Adding a domain to it
is the same three steps as any other: describe it here, add its commands to `manifest.json`, regenerate, and
override the virtuals.

## A bump is a code change

Re-pointing the pin is not a configuration edit that lands on its own. Upstream renames methods, moves
them between domains, changes a parameter from optional to required and retires deprecated commands, and
every one of those changes the generated dispatch surface. So a bump is:

1. Fetch the two files at the new commit, update `pin.json` and the commit above.
2. Regenerate (below) and **read the diff of `Jint.DevTools/Protocol/Generated/`**. That diff is the
   upstream change, stated in the vocabulary this repository compiles.
3. Fix whatever the diff broke, in the same pull request.

The same discipline `Jint.Tests.Test262/Test262Harness.settings.json`'s `SuiteGitSha` carries, and for
the same reason: a pin that moves without anybody reading what moved is an upstream normative change
landing unread.

## Regenerating

```bash
dotnet run --project tools/devtools-protocol/Jint.DevTools.ProtocolGenerator -c Release -- \
    --protocol tools/devtools-protocol \
    --manifest tools/devtools-protocol/manifest.json \
    --output Jint.DevTools/Protocol/Generated
```

The output is **checked in**, and `Jint.Tests.DevTools/Protocol/GeneratedProtocolIsCurrentTests.cs` runs
the same emitter in memory and compares it byte for byte with what is on disk. So an edit to
`manifest.json` without a regeneration fails the build, and a hand edit of a `.g.cs` file fails it too.

A Roslyn source generator was considered and rejected: the `System.Text.Json` context has to be generated
*over* the DTOs and generators do not chain, `Jint.SourceGenerators` is `netstandard2.0` without
`System.Text.Json`, and a protocol surface is exactly the kind of thing whose diff a reviewer wants to
read.

## The manifest

`manifest.json` is the boundary between what is *described* and what is *answered*.

- `generatedDomains` — the domains that get DTOs, a `<Domain>DomainBase` and a `<Domain>Events` factory
  class. A generated command gets a virtual, and its default answers
  `-32601 'Domain.method' wasn't found`. An entry is **either a domain name or an object**:

  ```jsonc
  "Runtime",                                                  // the whole domain
  { "domain": "Audits", "commands": ["disable", "enable"] },   // these, and nothing else
  ```

  The object form generates the commands and events it names and nothing else — including nothing for a
  list it leaves out — and the types it gets are the closure of what those members reach, computed rather
  than listed. A command that is not generated has no virtual and is answered by the dispatch base's
  `default` case, which is the same `-32601` it answered before: the object form changes what is checked
  in, not what a client is told, and `ACommandAPartialDomainDoesNotGenerateIsStillMethodNotFound` is what
  says so. Which form to use is a judgement about the domain — one whose surface is being filled in stays
  whole, so the next command is an override and a manifest line; one that answers a handful of commands out
  of dozens, and whose remainder describes machinery this engine does not have, names them. Nine domains
  do today, and it took **30%** off the generated tree: `Audits` alone went from 143 KB to 4 KB.
- `implementedMethods` — the commands Jint.DevTools answers. Each is checked to exist in the vendored
  protocol, and `ProtocolManifestTests` checks each is overridden on a registered domain and that
  nothing else is.
- `implementedEvents` — the events Jint.DevTools emits, checked the same way.
- `reportedDomains` — what `Schema.getDomains` answers. A domain may only appear here once it has an
  implemented command, so a client feature-detecting through `Schema.getDomains` is never told about a
  domain that answers nothing.

The generator fails rather than emitting broken code when an entry names something the vendored protocol
does not have, when a `$ref` crosses into a domain that is not generated and does not resolve to a
primitive alias, and when `implementedMethods` names a command its own `generatedDomains` entry does not
generate — which would otherwise be a command the manifest and `Schema.getDomains` claim and nothing can
override.

## What a generated file says about itself

Every `.g.cs` carries three lines of provenance:

```
//     source:   tools/devtools-protocol/browser_protocol.json
//     protocol: version 1.3, ChromeDevTools/devtools-protocol@ea39a11… (devtools-protocol@0.0.1687809)
//     manifest: tools/devtools-protocol/manifest.json, Audits entries, sha256:5893b4009485
```

`source` is the description **that file** was read from, which is why it is per file: `Jint.g.cs` comes from
`jint_protocol.json`, ours, and used to cite the Chrome commit its neighbours come from — provenance naming
the wrong document, which is what a reader has instead of a build when they have only a diff in front of
them. A file generated from our own description names no Chrome commit at all, because a bump cannot move
it.

The `manifest` digest is over the *part* of the manifest that shaped the file — that domain's
`generatedDomains` entry, its implemented commands and its implemented events — rather than over the whole
file. A whole-file digest would rewrite all twenty-four headers whenever any domain gained a command, and
say nothing true about the other twenty-three. `ProtocolJsonContext.g.cs` and `ProtocolManifest.g.cs` are
generated from all of it and carry a digest of all of it. Line endings are normalised before hashing, so a
Windows checkout and a Linux one stamp the same digest.

## The map type

The protocol has no map keyword. What it writes instead is a **named type declared as `"type": "object"`
with no `properties`**, and `Network.Headers` — "request / response headers as keys / values of JSON
object" — is the one every generated domain uses. The emitter reads that shape as a map: no record is
emitted for the type, and every `$ref` to it resolves to
`global::System.Collections.Generic.Dictionary<string, string>`. An empty record was what came before, and
it meant a client's headers were not in the request object at all.

Two things about the rule are deliberate:

- **Only a *named* type is a map.** An **inline** `"type": "object"` member stays a `JsonElement`, because
  those carry values of mixed shapes rather than strings: `Debugger.paused`'s `data` holds a remote object,
  an execution context's `auxData` holds a boolean and two strings, and a dictionary of strings would drop
  the lot silently.
- **A repeated header is joined with a newline**, which is what Chrome sends, because the Fetch Standard
  keeps every value of a repeated header apart and the protocol's map cannot. That join is the domain's
  business rather than the generator's; see `Jint.Browser/DevTools/NetworkDomain.Events.cs`.

## The handshake recordings

`handshakes/` is what the described protocol looks like from the *client* side: every CDP method and event
Puppeteer, PuppeteerSharp, Playwright, Playwright for .NET and the Chrome DevTools frontend actually send
and receive while driving one Chrome build through one canonical scenario, one file per client plus
`matrix.md`. `manifest.json` decides what is answered; the matrix says what has to be. It is recorded, not
written, by [`tools/cdp-histogram`](../cdp-histogram/README.md).

---

### Overview (`tools/dom-bindings/README.md`)

# The DOM binding contract

`contract.json` is the checked-in source for `Jint.Browser/Dom/Generated/*.g.cs`. It explicitly records every interface, parent, wrapper kind, member body, descriptor, method length, constant, collection accessor and string enum. The emitter reads this file without loading or reflecting over AngleSharp assemblies. A change to a browser binding is a change to this contract, followed by regeneration and review of the generated C# diff. Never hand-edit a `.g.cs` file.

Regenerate and verify with:

```bash
JINT_DOM_BINDINGS=update dotnet test -c Release --project Jint.Tests.Browser/Jint.Tests.Browser.csproj --filter FullyQualifiedName~DomBindingsStalenessTests
dotnet test -c Release --project Jint.Tests.Browser/Jint.Tests.Browser.csproj --filter FullyQualifiedName~DomBindingsStalenessTests
```

The command-line emitter also accepts `--contract tools/dom-bindings/contract.json --output Jint.Browser/Dom/Generated`. `DomBindingsStalenessTests` compares its output byte for byte with the checked-in files and checks that the contract reports no diagnostics. `overrides.json` and `pin.json` retain historical decisions and assembly provenance; they neither load assemblies nor constrain benchmark package versions. The one-time upstream assembly extraction switches have been retired. Generation uses `contract.json` alone, with native `Jint.HtmlParser` adapters keeping each JavaScript-visible interface and member reviewable.

`BindingGenerator.Run` loads `BindingContract` and passes its model directly to `Emitter`.
The loader rejects unsupported contract versions, duplicate interface/field/member names and a child
listed before its parent. Extraction-era `ModelBuilder`, `Inventory`, `Conversions` and `Nullability`
sources are not on that path: their attribute-reading rules are not the current generation contract.
Editing `overrides.json` alone therefore changes no emitted binding.

AngleSharp packages are retained only in `Jint.Benchmark` for comparisons with `Jint.HtmlParser`.
`ParserDependencyTests` enforces this boundary. Binding and parser regression tests use the explicit
contract and expected native tree structures rather than loading an upstream implementation.

---

### Overview (`tools/html-parser-inventory/README.md`)

# Historical HTML parser replacement inventory (A1)

`inventory.lock.json` preserves the reviewed AngleSharp migration inventory referenced by the parser
architecture designs. It records the source and binding surface at that stage of the replacement;
it is historical evidence, rather than a current dependency, API or conformance baseline.

The migration scanner and its tests have been retired. Current checks live beside the contracts they
verify: `Jint.Tests.Browser/ParserDependencyTests.cs` polices the dependency boundary,
`DomBindingsStalenessTests` verifies generated bindings, public API snapshot tests approve shipped
surfaces, and the WPT suites own their corpus and exclusions. The unsigned fresh-feed consumer under
`tools/html-parser-package-consumer` checks packaged parser APIs and Browser integration.

Do not refresh this historical lock when changing current code. Run the relevant contract tests instead.

---

### Overview (`tools/html-parser-package-consumer/README.md`)

# Jint.HtmlParser packed consumer

This unsigned application consumes the newly packed `Jint.HtmlParser` NuGet package. It has no project
reference, friend access, signing key, or dependency on AngleSharp. The parser-only mode has no engine
dependency. Its own build configuration stops repository-wide MSBuild inheritance. Package source mapping restricts
`Jint` and `Jint.*` to the local feed and allows third-party dependencies from nuget.org,
so no Jint package can resolve from nuget.org.
The local-feed paths use forward slashes so NuGet retains the source's mapping identity on Unix as
well as Windows; a restored cache must not hide a broken fresh-package resolution.

The explicit `5.0.0-consumer-smoke` prerelease below is preserved by the parser version policy.
Stable `Version` or `PackageVersion` inputs instead acquire `experimental-<BuildNumber>` (default `0`),
even for a Jint GA tag. Use the resulting version in consumer references; do not assume the parser
package version equals the stable Jint/Browser version. This probe supplies acceptance evidence but
cannot by itself close ACC-06 or freeze all public APIs.

From the repository root on macOS or Linux, run:

```sh
rm -rf artifacts/html-parser-package-consumer
dotnet pack Jint.HtmlParser/Jint.HtmlParser.csproj -c Release \
  -o artifacts/html-parser-package-consumer/feed \
  -p:PackageVersion=5.0.0-consumer-smoke \
  -p:RestoreSources=https://api.nuget.org/v3/index.json
dotnet restore tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj \
  --configfile tools/html-parser-package-consumer/nuget.config
dotnet run --project tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj -c Release -f net8.0
dotnet run --project tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj -c Release -f net10.0
```

Each run prints `ALL PARSER PACKAGE PROBES PASSED` only after checking HTML, XML, strict SVG, fragment
ownership, mutation records/drains, whole-sheet CSS syntax, all owned XPath overloads and result kinds,
typed IDs and imports, namespace contexts and stale bindings, detached attribute axes, and all five
serialization routes including shadow acquisition/selection, templates, output limits and cancellation.
It also checks public DTD processing-instruction metadata, immutable snapshot/clone behavior and
original-input offsets without relocating instructions into DOM children. Live-traversal probes cover
node/attribute identity, character-data edits, live and static range endpoints and contents, adoption,
iterators and tree walkers. These checks replace the standalone D6r6 consumer and run in both the
shared packaged-consumer CI job and its parser-only Native AOT leg.
Clearing the disposable feed and package cache before packing prevents a prior package with the
same version from answering the test. Inspect the actual package dependency list and shipped assets:

```sh
dotnet list tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj package --include-transitive
unzip -p artifacts/html-parser-package-consumer/feed/Jint.HtmlParser.*.nupkg '*.nuspec'
unzip -l artifacts/html-parser-package-consumer/feed/Jint.HtmlParser.*.nupkg
```

The package must contain both `lib/net8.0` and `lib/net10.0` assets, declare `System.IO.Hashing`,
include `licenses/ValueStringBuilder.LICENSE.txt`, and have no `Jint` or `AngleSharp` dependency.
The application also checks that its own assembly is unsigned and that it loaded the
`Jint.HtmlParser` assembly. The package manifest and restored graph are the dependency checks, since
reflection-based assembly-reference enumeration is unsafe in a trimmed Native AOT consumer.

Where a Native AOT toolchain is installed, publish and run one target (replace the RID for the host):

```sh
dotnet publish tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj \
  -c Release -f net10.0 -r osx-arm64 -p:PublishAot=true \
  -p:RestoreConfigFile=tools/html-parser-package-consumer/nuget-aot.config
./tools/html-parser-package-consumer/bin/Release/net10.0/osx-arm64/publish/HtmlParserPackageConsumer
```

The AOT restore config also permits compiler and runtime packs from nuget.org. Its specific
Jint package mappings take precedence over the dependency wildcard, so the parser package keeps
its unique `5.0.0-consumer-smoke` version and can only restore from the local feed.

## Continuous integration

The shared `html-parser-package-consumer.yml` workflow runs from both PR and main builds. Each Linux
job starts with an empty feed and consumer package cache, then packs Jint, Jint.DevTools, Jint.HtmlParser
and Jint.Browser. Its two version inputs cover a prerelease and a GA Browser whose parser remains
experimental. `verify_packages.py` checks both shipped framework assets, the parser's package policy,
and Browser's exact dependency on the actual parser version before supplying consumer version properties.

The consumer builds and runs on net8.0 and net10.0 with `IncludeBrowser=true`. This adds a packaged
Browser smoke probe that creates a page, parses HTML, runs script and reads DOM text. For example,
after packing all four packages with `Version=5.0.0`, run:

```sh
python3 tools/html-parser-package-consumer/verify_packages.py feed \
  --version 5.0.0 --framework net8.0 --framework net10.0
dotnet build tools/html-parser-package-consumer/HtmlParserPackageConsumer.csproj -c Release \
  -p:IncludeBrowser=true -p:HtmlParserPackageVersion=5.0.0-experimental-0 -p:BrowserPackageVersion=5.0.0
python3 tools/html-parser-package-consumer/verify_packages.py restore \
  --version 5.0.0 --framework net8.0 --framework net10.0 --browser
```

Restore verification checks exact versions, framework assets, the source recorded by NuGet and the
SHA-512 of each restored Jint package against the fresh feed. CI then clears the consumer outputs and
cache again, publishes the parser-only probe as Linux net10.0 Native AOT, checks its restore provenance,
and runs the native binary. Trim/AOT analysis warnings fail this parser leg. Browser's general AOT
compatibility remains unclaimed; its integration probe runs under the regular .NET runtimes.

---

### Overview (`tools/whatwg-encoding/README.md`)

# Encoding Standard data

`indexes.json` and `encodings.json` are vendored verbatim from the
[Encoding Standard's repository](https://github.com/whatwg/encoding), at commit

    a985b62a9b45c17da3e17a9f0a0b4e30c34c4a8a

`encodings.json` is the machine-readable form of the standard's
[names-and-labels table](https://encoding.spec.whatwg.org/#names-and-labels); `indexes.json` holds the
[indexes](https://encoding.spec.whatwg.org/#indexes) the legacy encodings decode through. `whatwg-encoding-LICENSE.txt`
is that commit's `LICENSE`: the standard is licensed under Creative Commons Attribution 4.0 International and,
in its own words, "to the extent portions of it are incorporated into source code, such portions in the source
code are licensed under the BSD 3-Clause License instead" — which is what the generated tables are.

Both files are checked in unmodified, including their line endings (`.gitattributes` pins them to LF), so
verifying them is `curl` plus `git diff` and nothing else. Only the single-byte indexes are consumed today;
the multi-byte ones are carried along because trimming the file would make that check a judgement call
instead of a comparison.

## Regenerating the tables

`generate-encoding-tables.ps1` reads both files and writes
[`Jint/WebApi/Encoding/EncodingTables.Data.cs`](../../Jint/WebApi/Encoding/EncodingTables.Data.cs) — the label
table `TextDecoder` resolves a label through, and the `ushort[128]` index each single-byte encoding decodes
through.

```pwsh
pwsh tools/whatwg-encoding/generate-encoding-tables.ps1
```

**The build never runs it.** The generated file is committed, regeneration is a manual step, and its header
records the commit above along with the SHA-256 of the exact bytes it was generated from. The script validates
what it reads — 128 entries per index, every code point inside the BMP and never a surrogate, U+0000 unused so
it can mean "unmapped", labels unique and ASCII-lowercase, every encoding's lowercased name among its own
labels — and fails rather than emitting something subtly wrong.

`Jint.Tests` embeds both JSON files (from here, not from a copy) and checks the generated tables against them:
every label the standard lists resolves to the encoding it names, and all 28 single-byte encodings decode all
256 byte values exactly as their index says. So a regeneration that changes anything is visible either in the
diff or in a failing test.

## Refreshing the data

1. Resolve `main` in `whatwg/encoding` to a concrete commit.
2. Download `indexes.json`, `encodings.json` and `LICENSE` at that commit and replace the files here.
3. Update the commit in this file and in `$sourceCommit` at the top of the script.
4. Re-run the script and commit its output together with the data.
5. Run `Jint.Tests`. New labels or encodings show up as failures in `LegacyEncodingTests` — the counts it
   asserts are deliberate, so that an encoding added upstream has to be looked at rather than absorbed.

---

### Overview (`tools/wpt-scoreboard/README.md`)

# `wpt run` against `Jint.Browser`

A [wptrunner](https://web-platform-tests.org/tools/wptrunner/README.html) **product** called
`jint_browser`, and the script that turns the run's `wptreport.json` into
[the published scoreboard](https://github.com/sebastienros/jint/blob/wpt-scoreboard/docs/wpt-scoreboard.md)
— a page that exists once the first nightly run has written the branch it lives on, and never before.

This is the *other* web-platform-tests number in this repository, and the difference is the point:

| | measured by | runs | is it a gate? |
| --- | --- | --- | --- |
| **The scoreboard** (here) | upstream's `wpt run`, over upstream's `wptserve` | every case the manifest generates for ten suites | **no** — `.github/workflows/wpt-scoreboard.yml` is nightly and publishes a page |
| **The census** ([`Jint.Tests.Browser/Wpt/`](../../Jint.Tests.Browser/Wpt/README.md)) | our own driver, in process | a vendored subset, one exclusion row per failure | **yes** — the not-passing column only ever goes down |

Neither replaces the other. The census is the one an engine change has to keep; the scoreboard is the one
that can be compared with what other engines report, because upstream measured it.

## The spike: why this is a product plugin and not `wpt run chrome`

`wpt run chrome` cannot drive this browser, and the reason is structural rather than a missing flag.

* `wptrunner/browsers/chrome.py` calls `require_arg(kwargs, "webdriver_binary")`, and its browser class is a
  `WebDriverBrowser` that launches that `chromedriver` process and talks **WebDriver classic** to it.
* Its executors (`ChromeDriverTestharnessExecutor` and friends) reach CDP only through
  `chromedriver`'s `goog/cdp/execute` extension command — a WebDriver endpoint. There is no
  `debuggerAddress` capability anywhere in the wpt tree, so there is no supported way to point that product
  at a browser somebody else started.
* `Jint.Browser` speaks CDP and nothing else. Lightpanda ships a WebDriver front end *as well as* CDP for
  exactly this reason; this package is the other way of closing the same gap.

So: a custom product. It needs no fork of wpt, because `wptrunner.products` loads external products from the
`wptrunner.products` [entry-point group](https://web-platform-tests.org/tools/wptrunner/README.html) —
`pip install` this package into the virtualenv `wpt run` uses and `wpt run jint-browser …` finds it.
`wpt run` maps the hyphen to an underscore itself, and an unknown-but-loadable product gets
`GenericBrowserSetup`, which requires no binary and installs no driver.

**What is not re-implemented:** the harness, the report format, the result conversion, the server, the
manifest, the timeouts, and the decision about whether a subtest passed. `wptrunner`'s own
`testharnessreport.js` is what the server hands each page, and `CallbackHandler` +
`testharness_result_converter` are what turn its output into results. What this package adds is a transport.

## How a result gets out of a page

1. `Page.addScriptToEvaluateOnNewDocument` installs [`bridge.js`](wpt_jint_browser/bridge.js) on every
   document. `Jint.Browser`'s `PageTarget` commits a document by announcing the navigation, swapping the
   engine, re-installing the `Runtime.addBinding` bindings and *then* running these scripts — all before the
   document's first inline script — which is the only ordering in which any of this works.
2. `bridge.js` **is** upstream's `message-queue.js`. That file opens by returning if the queue already
   exists, because "another script already set up the testdriver infrastructure"; this is that script. So
   `testharnessreport.js` still calls `add_completion_callback` and still pushes
   `{type: "complete", tests, status}`, unchanged.
3. Instead of handing the item to an asynchronous WebDriver script, the bridge hands it to a
   `Runtime.addBinding` binding as one JSON string. It captures the binding function and deletes the global,
   so a document that enumerates `window` does not find the runner's plumbing among its results.
4. `Runtime.bindingCalled` arrives on the executor's CDP connection; `CallbackHandler` turns it into the
   tuple `testharness_result_converter` wants, with the URL the executor navigated to rather than the one
   the page reports — which is what upstream's `__wptrunner_url` dance exists to get right.

The CDP client is [`cdp.py`](wpt_jint_browser/cdp.py) over [`websocket.py`](wpt_jint_browser/websocket.py),
about 200 lines of RFC 6455 with no dependencies. That is deliberate: `wpt run` builds its own virtualenv,
and every published CDP client for Python would add a dependency chain to it. The frame codec is unit-tested
over a socket pair, because the two ways it can be wrong — an unmasked client frame and a mis-taken length
branch — both look like the browser hanging up rather than like a bug.

## What is deliberately left out

* **Reftests.** `Jint.Browser` renders nothing. `JintBrowserRefTestExecutor` answers `PRECONDITION_FAILED`
  with a sentence rather than failing the run or, worse, reporting a pass nothing measured. The workflow
  passes `--test-types testharness` so one is never reached in practice.
* **`testdriver.js` actions.** `supports_testdriver` is `False`, so wptrunner **skips** a test that needs
  one and says why, rather than failing it. Turning an element into a point needs `getClientRects` inside
  the page, which is real work; the in-process lane already maps `test_driver` onto the same
  `InputDispatcher` the `Input` domain reaches, so this is a gap in the scoreboard rather than in the
  engine. `send_message` is implemented anyway, so a document that reaches `test_driver` without the
  manifest flag gets upstream's "not implemented" answer instead of hanging until its timeout.
* **HTTPS.** `Jint.Browser` has no way to be told about an extra certificate authority, so a run over wpt's
  pregenerated certificates would fail every `https` case for the environment's reason. The workflow passes
  `--ssl-type=none`, and wptrunner then leaves https tests out of the run entirely
  (`include_https=ssl_enabled`) rather than running them into a wall. Installing wpt's CA into the runner's
  trust store would lift this; it is the obvious next step and is not in v1.
* **`--untrusted`.** The nightly does not pass it. `BrowserOptions.ForUntrustedContent` applies
  `UntrustedCodeLimits.Default` to every page engine — one second per engine entry, 50 000 statements,
  10 000-element arrays, recursion depth 64, a 250 ms regex budget — and the command line has no switch for
  any of the five. Those are the right numbers for a page nobody vouches for and the wrong ones for a
  conformance measurement: a scoreboard taken under them would be a report on the hardened profile. What is
  served is vendored, pinned content on loopback.

## Running it yourself

The nightly is `.github/workflows/wpt-scoreboard.yml` and it is the specification; this is the same thing
by hand.

```
/* Detailed source-code truncated for AI context efficiency. */
```

### What the run costs, and the flag that decides it

The wall time is dominated by the cases that fail by **not reporting**: a document that never completes
costs its whole timeout, and a `<meta name=timeout content=long>` file's timeout is 60 seconds.
`html/semantics/scripting-1/the-script-element/moving-between-documents/` is 52 of those, and every one of
them waits for a message posted by script running inside an `<iframe>` — and iframes run no script in this
version, by design (`docs/design/headless-browser.md` keeps that for a later phase), so the harness never
finishes — so that one directory is most of an hour, and will be until iframes run script.

**There is therefore no `--timeout-multiplier`**, and the reason is not only cost. Every per-test timeout in
the corpus is the test author's, and wpt.fyi's published figures are taken at the default of 1; a scoreboard
measured at 2 would be systematically more generous than the numbers it invites comparison with. Raising it
doubles exactly the cases that are already failing and changes nothing about the ones that pass.

### If the nightly gets too long

The run restarts the test runner after almost every test, and that is not a defect: `restart_on_unexpected`
defaults to on, this repository ships no wpt expectation metadata, so *every* failure is unexpected. It
costs about a second per failing test — measured at roughly 15% of the wall time. `--no-restart-on-unexpected`
removes it, and is safe here for a reason that is specific to this browser rather than general: a navigation
already disposes the previous document's engine and cancels everything it had in flight, and a runner restart
only mints a new page target rather than a new process, so the isolation the flag buys is isolation the next
`Page.navigate` gives anyway. It is not passed today because upstream's default is what a reader expects.

### `wpt-config.json`, and why it exists

It is copied into the checkout as its `config.json`, which is the override
`TestEnvironment.build_config` reads before it starts the servers. All it does is null three ports out:
`ConfigBuilder._get_ports` skips the bare `https` and `wss` schemes when SSL is off, but **not**
`https-local`, `https-public` or `h2`. Left in, those three try to start with no certificate and the whole
environment dies with `Servers failed to start: https-local:8445, https-public:8446`. The four `.h2.` test
files are excluded by name in the workflow for the same reason, so that they are absent rather than internal
errors.

### On a machine whose hosts file you cannot write

`wpt run` refuses to start without the hosts entries, and that refusal is right — a third of `fetch/api` and
`xhr` need a second origin. If you cannot have them, you can get a run out of the tree by pointing the
*server* at a wildcard-loopback domain in the same `config.json`, and calling `wptrunner` directly rather
than through `wpt run`:

```json
{
  "browser_host": "web-platform.localtest.me",
  "alternate_hosts": { "alt": "not-web-platform.localtest.me" },
  "ports": { "https-local": [null], "https-public": [null], "h2": [null] }
}
```

Every name under `localtest.me` resolves to `127.0.0.1` in public DNS. It is correct and it is *slow* —
every subresource is a DNS round trip, which multiplies a page load by about ten — so it is a way to
develop, never a way to measure.

## The tests

`tests/` runs under `pytest` and is what the workflow runs before anything else.

* `test_websocket.py` — the frame codec over a socket pair. No browser, no server, no environment
  variables; it always runs.
* `test_scoreboard.py` — a report in, a page out. Likewise.
* `test_executor_smoke.py` — the whole path against a real `jint-browser serve`, on one document with three
  passing subtests and one that fails on purpose. It needs `WPT_ROOT` (a wpt checkout, for `testharness.js`
  and `wptrunner`'s report script) and `JINT_BROWSER_COMMAND` (how to start the built tool), and **skips**
  naming the missing one when it does not have them.

```bash
WPT_ROOT=$PWD/wpt \
JINT_BROWSER_COMMAND="dotnet $PWD/artifacts/bin/Jint.Browser.Tool/release_net10.0/Jint.Browser.Tool.dll" \
  .wpt-venv/bin/python -m pytest tools/wpt-scoreboard/tests -q
```

## What this is pinned against

`wptrunner`'s external-product contract is young: `products.Product`, `get_all_products()` and the
`wptrunner.products` entry-point group are what `product.py` builds against, and `Product` is constructed
directly rather than through the older `__wptrunner__` dict so that a field it grows is a `TypeError` here
rather than a silent default. The workflow always checks wpt out at the corpus pin, so an upstream change to
that contract arrives with a deliberate pin bump and not on a random night.

---

### Overview (`Jint.Tests.HtmlParser/Html/TreeConstruction/README.md`)

# Bounded HTML tree fixture census

`Html5libSubsetTests.cs` transcribes 14 complete-document `#data` / `#document`
cases from `html5lib/html5lib-tests/tree-construction` at commit
`9329e64694e7835d0dcff9811e22856ef6ad16f9` (the last revision before those
files moved to WPT). Case IDs name the source file and one-based `#data` ordinal.
The original license is copied in `UPSTREAM-LICENSE.txt`. The subset tests assert
the tree, while tokenizer and tree diagnostic tests assert their separate codes.
The independent H4 algorithm reference is the HTML Standard tree construction
snapshot updated 2026-09-22.

Executed: 14 of 14 selected historical document cases. H7a foreign dispatch is
covered by authored native-tree fixtures in `HtmlForeignTreeTests` and the complete
37 SVG tag, 58 SVG attribute, and 11 foreign namespace adjustment rows in
`HtmlForeignAdjustmentTests`. Their primary source is the
[WHATWG HTML source at `2f441941fc523877bd9d5cd7de3b91a81a00ca2e`](https://github.com/whatwg/html/blob/2f441941fc523877bd9d5cd7de3b91a81a00ca2e/source),
inspected 2026-09-25, specifically the tree-construction dispatcher, creating and
inserting nodes, and foreign-content rules. These authored fixtures are not WPT
passes and do not increase the historical corpus census. They cover namespace
identity, integration exceptions and accepted-token encoding facts, breakouts,
foreign scripts, diagnostics, CDATA context, every short split at quotas 1/3/large,
destination ownership/IsValue, and charged resumable long-name scans. No
`ForeignContent` stop remains reachable; contextual fragments remain H7b and
active parser scripting remains H8. Frameset and tail modes are covered in
`HtmlFramesetTreeTests` against the HTML Standard updated 2026-09-25. Ordinary templates,
content patching, and applicable declarative-shadow parsing are covered in
`HtmlTemplateTreeTests` and `HtmlTemplatePatchingTests` against the current HTML Standard.
No Templates stop remains reachable. Default standalone declarative-shadow permission is false;
internal calling algorithms can select it and supply cached engine-free Browser host facts.
Patch coverage includes native PI pseudo-attributes, ordinary descendant and sibling scope,
actual fragment/template ownership, captured removals from live parents, moved markers, close
versus EOF, quota-one work scaling, every short fragment split, and coherent cancellation or
notification failure around atomic native removal. Declarative coverage uses real shadow roots,
flags/registry ownership, actual fragment hosts, permission/host/existing-root fallback, and
host-provider lookup gates and original infrastructure exceptions. This is authored native
coverage, not an expansion of the historical WPT census. Atomic native work boundaries and the
public HTML promotion amendment are recorded in
[the promotion checkpoint](../../../docs/design/html-parser-html-promotion.md).
`HtmlTableStructureTests` and `HtmlTableTextTests` cover H5a/H5b
against the 2026-09-22 HTML Standard; the historical corpus pin above is comparison
evidence, not the rule for a changed algorithm. Table text tests cover pending runs,
foster locations, input splits, quota continuations, and coherent cancellation.
The original subset deliberately excludes historical PI cases whose bogus-comment
expectations conflict with the current Standard's processing-instruction tokens,
and select cases whose insertion rules changed. Current-spec PI behavior is
asserted independently in `ProcessingInstructionsRetainPlacementAndCase`;
current select trees are asserted in `HtmlSelectTreeTests`. Fragment-context
select cases remain with H7b.

H7b contextual fragments use the same native session/tokenizer/tree builder with a
private parser document, a synthetic HTML stack root, and a target-owned detached
root insertion fragment. `HtmlFragmentTreeTests` contains 57 authored cases against
HTML Standard §13.4 and the tree-construction rules inspected 2026-09-25. This is
an internal seam; it does not promote `MarkupParser` or complete Browser cutover.
The matrix covers HTML/table/select/template contexts, initial text states without
an appropriate end tag, SVG/MathML integration and CDATA, all document modes,
form ancestry, template/shadow targets, script flags/source metadata, ownership and
formatting reconstruction/adoption including `IsValue`. Short contexts/text/foreign
cases run every split at quotas 1/3/large. Seven structural differential cases use
pinned AngleSharp 1.8.2 only for conforming intersection behavior; current select,
PI and patch rules retain independent expectations. No existing corpus pin or
exclusion changed. Document form-pointer association now resolves roots and commits
fresh insertion cooperatively; quota-one, deterministic depth-plus-control scaling
and host-adoption cases cover the cache invalidation contract.

---

### Overview (`docs/benchmarks/binding-comparison-2026-09/README.md`)

# D0.3: reproduced DOM binding spike comparison

Two independent complete hosted runs reproduce lower measured costs for the small hand-shaped binding
spike than for AngleSharp.Js reflection on the three DOM workloads. This completes the measurement and
scoped conclusion for [campaign #3575 D0.3](https://github.com/sebastienros/jint/issues/3575), whose harness
was tracked in [#3898](https://github.com/sebastienros/jint/issues/3898).

This is an end-to-end comparison of two explicitly different embedding paths. It does **not** isolate the
causal benefit of shapes, measure the production generated browser bindings, or establish equivalent-browser
startup savings. The interpreter versions and binding surfaces differ. The cold interpreter control makes
the setup difference especially visible; controls are reported without subtraction.

## Provenance and acceptance

Both runs measured committed source `c0725301810ad1454d7d1fbc0757659af1542f9b`, with a clean checkout,
.NET SDK 10.0.401, runtime .NET 10.0.12, and Ubuntu 24.04.5 LTS x64. The runner image was
`ubuntu24 / 20260907.300.1`. Each job ran serially on its own GitHub-hosted VM.

| Run | Workflow evidence | Guest CPU model | Maximum recorded idle-check load |
| --- | --- | --- | ---: |
| 1 | [35671058293, attempt 1](https://github.com/sebastienros/jint/actions/runs/35671058293) | AMD EPYC 9V74 80-Core Processor | 1.0% of one core |
| 2 | [35677667091, attempt 1](https://github.com/sebastienros/jint/actions/runs/35677667091) | AMD EPYC 7763 64-Core Processor | 5.0% of one core |

These CPU model names describe the guest-visible processor, not a reservation of all its named cores.
The VMs expose four logical processors. The unchanged idle gate allows at most 40% of one core; every
pre-arm check passed. Guest observations do not establish physical-host isolation or continuous idleness
during each benchmark. The different CPU models are another reason to keep the runs' estimates separate.

| Component | Baseline | Candidate |
| --- | --- | --- |
| Binding | AngleSharp.Js 1.1.0 reflection, complete Window/DOM setup | Small hand-shaped Node/Element/Document prototypes |
| Interpreter | NuGet Jint 4.15.3 | Jint 5.0.0-beta at the measured source revision |
| Parser | Acornima 1.6.2 | Acornima 1.8.0 |
| DOM | AngleSharp 1.8.0 | Identical AngleSharp 1.8.0 assembly |
| BenchmarkDotNet | 0.15.8 | Identical 0.15.8 assembly |

The candidate covers only the members used by the spike; it is not the complete generated WebIDL surface.
Exact assembly informational versions and SHA256 digests are in each `raw/verification.json`; both arms'
provenance and workload checksums match exactly between the two runs. The shared workload SHA256 is
`14D52412A48DF26C52A656E8163B5F50644AC2C52EBD9962CFD5147C1DEB6AC6`.

Each run completed six alternating baseline/candidate pairs, three BDN launches per row, all eight
workload/lane rows, and 24 independent correctness checks per arm. Production tiering/PGO and the gate
configuration remained enabled. No short job, idle override, control subtraction, or post-hoc row selection
was used. Reanalysis with the measured revision's analyzer exactly reproduces all eight retained summaries
in both runs. All 28 analyzer-recorded file digests per run and all 132 archived files were independently
verified after extracting the checked-in archives.

## Results

Values are the median of six paired `100 * (candidate - baseline) / baseline` time differences. Negative
means lower candidate time. Brackets are pointwise 95% percentile bootstrap intervals: 5,000 resamples,
seed 20260816 reset per row, Python Mersenne Twister, sorted indices 125 and 4875. Launches are not treated
as independent rounds. These exploratory intervals have no multiple-comparison adjustment and are not
pooled across the two VMs. Full precision, round means, allocation diagnostics, and raw BDN output remain
in the archives.

| Workload / lane | Run 1 median Δ [95% CI], % | Run 2 median Δ [95% CI], % |
| --- | ---: | ---: |
| DocumentLookup / cold | −70.76 [−71.45, −70.38] | −69.16 [−71.82, −66.40] |
| ElementReadWrite / cold | −56.68 [−57.42, −56.28] | −53.06 [−54.48, −52.30] |
| NodeRead / cold | −74.07 [−74.49, −73.85] | −73.57 [−73.85, −72.79] |
| InterpreterControl / cold | −81.46 [−81.69, −80.98] | −80.43 [−80.66, −80.20] |
| DocumentLookup / warm | −60.70 [−61.03, −60.24] | −60.20 [−62.89, −58.78] |
| ElementReadWrite / warm | −35.07 [−36.83, −34.23] | −31.37 [−33.39, −29.38] |
| NodeRead / warm | −61.47 [−62.14, −61.21] | −63.20 [−63.55, −59.83] |
| InterpreterControl / warm | −2.17 [−4.24, +0.0003] | −2.97 [−4.58, −2.61] |

All six DOM rows improve in every paired round of both runs. Warm DOM improvements reproduce in direction
and remain much larger than the warm interpreter-control differences. The warm control's direction is
unresolved in run 1 and lower in run 2; it is not evidence of equal interpreter costs and cannot be used to
correct the DOM rows. The different interpreter versions remain a confounder.

Cold includes DOM parsing, engine/binding setup, first prepared-script execution, and disposal. It excludes
script parsing and process startup; BDN has already warmed process-wide initialization. The cold control
improves by roughly 80–81% even without DOM calls, demonstrating that unequal setup costs dominate an
important part of this comparison. Warm excludes DOM/engine setup, script parsing, and disposal and warms
each engine only with its own workload.

The supported conclusion is that the small binding spike has reproducibly lower costs for these embedding
paths under the two recorded hosted environments. Attribution to shape storage alone, equivalent production
browser performance, or generalization to dedicated physical hardware requires a different matched experiment.

## Durable evidence and reanalysis

Both gzip archives contain the complete downloaded Actions artifact directories, including driver logs,
host metadata, verification logs/JSON, per-round CSV/Markdown/HTML reports, all BDN logs, and `analysis.json`.
No file contents were rewritten. Packaging normalizes tar ownership, permissions and timestamps and uses
an empty gzip filename and timestamp zero, so identical inputs produce identical archives. The archives
are approximately 0.6 MB each and remain in git rather than depending on Actions artifact retention.

`SHA256SUMS` covers both archives and `files.sha256`; the latter covers every extracted artifact file.
From the repository root, verify and reanalyze without running any benchmarks:

```sh
evidence="$PWD/docs/benchmarks/binding-comparison-2026-09"
(cd "$evidence" && sha256sum -c SHA256SUMS)
extracted=$(mktemp -d)
for run in 35671058293 35677667091; do
  tar -xzf "$evidence/binding-measurement-$run-1.tar.gz" -C "$extracted"
done
(cd "$extracted" && sha256sum -c "$evidence/files.sha256")
git show c0725301810ad1454d7d1fbc0757659af1542f9b:Jint.Benchmark/BindingComparison/analyze.py > "$extracted/analyze.py"
for run in 35671058293 35677667091; do
  python3 "$extracted/analyze.py" \
    --artifacts "$extracted/binding-measurement-$run-1/raw" \
    --driver-log "$extracted/binding-measurement-$run-1/driver.log" \
    --expected-head c0725301810ad1454d7d1fbc0757659af1542f9b \
    > "$extracted/reanalysis-$run.json"
done
```

On macOS, replace `sha256sum -c` with `shasum -a 256 -c`. Reanalysis changes machine-local file paths and
Python version metadata; its `summary` must equal the retained analysis exactly. To collect new measurements,
use the [harness instructions](https://github.com/sebastienros/jint/blob/c0725301810ad1454d7d1fbc0757659af1542f9b/Jint.Benchmark/BindingComparison/README.md) and the
[benchmark requirements](https://github.com/sebastienros/jint/blob/c0725301810ad1454d7d1fbc0757659af1542f9b/Jint.Benchmark/AGENTS.md) at the measured revision. Use a clean isolated
checkout and a new output directory, retain refused attempts, and do not weaken the idle guard to obtain a run.

---

### Overview (`docs/benchmarks/browser-comparison-2026-09/README.md`)

# Reproduced X4 browser comparison: independent audit

Both complete independent runs pass the pinned analyzer and independent provenance checks. The evidence supports completing X4's measurement/reproduction requirement with the scoped conclusions below, with the locally retained raw results and the complete text tables published here. This is not a universal browser ranking or a claim about arbitrary websites, rendering, network latency, or dedicated physical hardware.

## Runs and validation

- First run: [35738112188](https://github.com/sebastienros/jint/actions/runs/35738112188)
- Independent repeat: [35764021366](https://github.com/sebastienros/jint/actions/runs/35764021366)
- Both measured source `6363436848365979b2201ed4ec9892ac140c13bc`.
- Both used Ubuntu image `20260907.300.1`, Linux `6.17.0-1022-azure`, SDK 10.0.401, and four guest-visible processors identifying as AMD EPYC 7763. Separate hosted jobs do not establish separate physical hosts or physical-host idleness.
- Both used the same measured executable hashes, harness binary, 18 source hashes, Chrome for Testing 153.0.8010.52 and Lightpanda 0.4.1 asset 565737341. No Lightpanda source was read.

Each run completed 360 Jint A/A rows followed by 540 comparison rows on the same VM: six rounds, three nested independent launches, five workloads, two lanes. Each exact pinned analyzer rerun matched all retained 110 A/A and 330 cross-browser records, including manifest digests. Kernel accounting and all four actual-adapter cleanup tests passed per phase, with no skips.

All 1800 before/after host-idle checks per run passed, alongside the shared idle validator. Maximum loads, as percentages of one core, were 33.499/10.499 for first calibration/comparison and 13.999/9.000 for repeat calibration/comparison, below the unchanged 40% ceiling. These are bracketing guest observations, not continuous or physical-host measurements.

All 900 lifecycle/collector cleanups per run passed, including unpopulated terminal scopes and consistent retained CPU/memory accounting. Forced termination is explicitly recorded throughout; successful cleanup does not mean exclusively graceful browser exit.

Before/after union snapshots were unchanged within every phase: 160593 dependency files for calibration and 160901 for comparison. Every calibration dependency remained unchanged into its comparison. Across runs, dependency path sets are identical and only two setup-record files differ: the Chromium AppArmor policy's run-specific profile name, and the setup manifest recording its hash/name. Policy permissions and executable path are otherwise identical. The browser sandbox remained enabled. All binary and runtime library contents match; the declared private-key-directory exclusion remains explicit.

The full comparison has 328 of 330 metric rows with the same resolved interval direction in both runs. There are no opposite resolved directions. Exactly two rows change uncertainty status, listed below. The A/A tables are not folded into these counts or treated as corrections.

## Calibration limits

The first calibration has seven of 110 intervals excluding zero on identical arms; the repeat has fourteen. Only one A/A metric has the same resolved direction in both; nineteen change between resolved and unresolved, and ninety remain unresolved. This is evidence against treating a nonzero pointwise interval as sufficient proof of an implementation difference.

Repeat warm parse-extract has an identical-arm wall-time offset of −11.40% [−13.58%, −6.91%] and CPU offset of −13.08% [−17.38%, −7.58%]. Repeat warm fetch-update has a −4.64% wall-time offset [−5.87%, −2.30%]. These must remain visible when interpreting browser comparisons; there is no universal noise floor.

The warm interpreter control remains particularly noisy: wall-time A/A is +14.76% [−7.02%, +69.10%] in the first run and −5.58% [−34.74%, +99.96%] in the repeat. CPU A/A is +18.53% [−7.56%, +68.10%] and +1.51% [−36.42%, +101.68%]. Neither supports a precise control correction. No control is subtracted and no observations or confidence intervals are pooled across runs.

## Page-work wall time, including controls

Each value is the median paired percentage difference `100*(other browser - Jint)/Jint`; negative means less time for the other browser. Brackets are separate per-run pointwise 95% percentile bootstrap intervals over six paired rounds (10,000 resamples, seed 3575), after taking the median of three launches per arm/round. These are exploratory intervals without multiple-comparison correction.

### Chromium relative to Jint

| Workload / lane | First median % [95% CI] | Repeat median % [95% CI] |
| --- | ---: | ---: |
| parse-extract / cold | -61.87 [-63.78, -60.17] | -62.19 [-62.66, -61.83] |
| parse-extract / warm | -37.61 [-44.02, -30.45] | -43.44 [-48.39, -36.24] |
| mutate-query / cold | -74.38 [-75.40, -73.24] | -75.35 [-75.93, -73.63] |
| mutate-query / warm | -68.12 [-72.14, -66.59] | -70.45 [-70.79, -65.55] |
| event-form / cold | -63.33 [-64.47, -61.88] | -63.85 [-64.89, -63.12] |
| event-form / warm | -27.99 [-32.10, -24.51] | -30.98 [-32.81, -26.43] |
| fetch-update / cold | -64.17 [-65.57, -61.91] | -63.65 [-64.91, -63.17] |
| fetch-update / warm | -18.33 [-21.57, -13.30] | -22.00 [-24.50, -20.15] |
| interpreter-control / cold | -64.02 [-65.82, -62.65] | -64.05 [-66.02, -63.05] |
| interpreter-control / warm | -53.09 [-61.98, -46.71] | -60.25 [-80.15, -56.47] |

### Lightpanda relative to Jint

| Workload / lane | First median % [95% CI] | Repeat median % [95% CI] |
| --- | ---: | ---: |
| parse-extract / cold | -71.13 [-71.84, -70.61] | -72.52 [-72.78, -71.35] |
| parse-extract / warm | -82.56 [-84.37, -81.96] | -84.21 [-86.05, -82.99] |
| mutate-query / cold | -79.90 [-80.32, -79.45] | -80.18 [-80.61, -79.70] |
| mutate-query / warm | -91.13 [-92.24, -91.01] | -91.55 [-91.74, -91.30] |
| event-form / cold | -72.31 [-72.66, -71.81] | -72.29 [-73.17, -71.54] |
| event-form / warm | -79.86 [-81.32, -78.96] | -79.78 [-80.38, -79.52] |
| fetch-update / cold | -71.30 [-71.87, -70.72] | -71.38 [-71.80, -71.18] |
| fetch-update / warm | -80.72 [-81.43, -78.51] | -79.48 [-80.02, -78.95] |
| interpreter-control / cold | -71.21 [-71.44, -69.05] | -71.19 [-71.91, -70.45] |
| interpreter-control / warm | -85.75 [-88.36, -84.48] | -87.20 [-93.60, -86.56] |

All four DOM workloads have lower page-work wall time for both other browsers in both cold and warm lanes. This large directional result reproduces. Cold results include navigation and first-use work inside a new process lifecycle; warm rows use the declared warmups and repeated navigation. They do not isolate a DOM binding mechanism or imply equivalence of rendering work.

## CPU and memory tradeoffs

Chromium's lower wall time is not a universal CPU improvement. Its warm event-form and fetch-update page-work CPU are higher than Jint in both runs, and most lifecycle CPU rows are higher. Warm mutate-query lifecycle CPU is lower in both. The warm interpreter-control lifecycle CPU difference does not reproduce as resolved. Lightpanda page-work and lifecycle CPU are lower across all recorded rows in both runs; retain the controls and calibration limitations rather than attributing the difference to one subsystem.

Kernel lifetime peak charged memory is higher for Chromium and lower for Lightpanda in every row in both runs. Across DOM rows, Chromium median increases range approximately 156–290% first run and 149–285% repeat; Lightpanda median reductions range approximately 87–93% and 89–93%. This is kernel-accounted cgroup usage, including applicable cache/kernel charges, not portable RSS. It must not be merged with sampled stage peaks, whose sampling can miss transients.

### All comparison rows whose interval direction changes

| Metric, other browser relative to Jint | First median % [95% CI] | Repeat median % [95% CI] | Conclusion |
| --- | ---: | ---: | --- |
| Chromium / parse-extract / warm / page-work CPU | −6.09 [−10.47, +11.31] | −14.77 [−23.70, −6.09] | Unresolved first, lower repeat; no claim of a resolved improvement in both |
| Chromium / interpreter-control / warm / lifecycle CPU | +5.21 [+0.68, +11.32] | +2.21 [−22.66, +5.60] | Higher first, unresolved repeat; no reproduced CPU regression claim |

Every other metric retains the same resolved direction. This count describes the measured intervals; it does not erase A/A offsets or establish universality.

## Conclusion

Two independent complete hosted runs reproduce lower page-work wall time for Chromium and Lightpanda than Jint on the four fixed offline DOM workloads in both declared lanes. Chromium trades higher charged peak memory and some higher CPU costs for lower wall time. Lightpanda uses less charged peak memory and CPU in these measurements. These findings are specific to the pinned builds, workloads, lifecycle boundaries and hosted environments. The controls expose substantial warm-run uncertainty; two CPU rows do not have a resolved direction in both runs. The experiment does not establish arbitrary-site performance, rendering equivalence or a causal explanation for the differences.

## Complete tables and audit records

- [All 330 cross-browser comparisons](https://github.com/sebastienros/jint/blob/main/docs/benchmarks/browser-comparison-2026-09/comparison-cross-run.csv), including Chromium versus Lightpanda and all controls.
- [All 110 identical-arm comparisons](https://github.com/sebastienros/jint/blob/main/docs/benchmarks/browser-comparison-2026-09/calibration-cross-run.csv).
- [Cross-run summary](https://github.com/sebastienros/jint/blob/main/docs/benchmarks/browser-comparison-2026-09/cross-run-summary.json), naming every interval-status change in both phases.
- Independent audit summaries for the [first run](https://github.com/sebastienros/jint/blob/main/docs/benchmarks/browser-comparison-2026-09/audit-35738112188.json) and [repeat](https://github.com/sebastienros/jint/blob/main/docs/benchmarks/browser-comparison-2026-09/audit-35764021366.json).

CSV columns `run1` and `run2` identify the first run and repeat above. All numeric differences and interval bounds are percentages, calculated as `100*(right-left)/left`; the metric name identifies the underlying quantity. A negative memory difference means lower charged usage, not a negative byte count. Each CSV retains every comparison, including both unchanged and changed interval directions. The Markdown wall-time tables are rounded to two decimals from these full-precision values.

## Retention and reanalysis

The campaign owner accepted local retention of the complete raw results. No binary files or downloadable compressed archives accompany this report, and external archive publication is not a completion prerequisite. The original [first artifact](https://github.com/sebastienros/jint/actions/runs/35738112188/artifacts/10709031789) and [repeat artifact](https://github.com/sebastienros/jint/actions/runs/35764021366/artifacts/10718274168) are available while GitHub retains them; those artifacts expire. The text tables and audit summaries here are retained in the repository, but do not substitute for the raw observations needed to independently rerun the analyzer.

The audit used these local artifact directories under the campaign worktree (they are ignored by Git):

```text
artifacts/campaign-evidence/35738112188/
artifacts/campaign-evidence/35764021366/
```

Each contains `calibration/`, `comparison/`, `calibration-analysis.json`, `comparison-analysis.json`, and setup evidence. These are local paths, not publicly available downloads. The downloaded ZIPs were SHA-256 verified before extraction:

| Run | Original ZIP SHA-256 |
| --- | --- |
| 35738112188 | `3a61aa63c2d77546aac419ff82009e57c0bc438e08a51edac7d0abe851f5e45d` |
| 35764021366 | `ac1ccf962396eb882d792faea5cace7ce9d714fc969edd2a4e589d281c00bde6` |

Reanalyze each complete local artifact with the exact measured revision's analyzer, without running another benchmark:

```sh
analysis_dir=$(mktemp -d)
for script in analyze.py measure.py; do
  git show "6363436848365979b2201ed4ec9892ac140c13bc:tools/browser-comparison/$script" > "$analysis_dir/$script"
done
for run in 35738112188 35764021366; do
  for phase in calibration comparison; do
    python3 "$analysis_dir/analyze.py" "artifacts/campaign-evidence/$run/$phase" > "/tmp/$run-$phase-analysis.json"
  done
done
```

The independent audit compared the resulting JSON objects with each retained analysis in full, including manifest hashes, and then checked dependency continuity between phases and across jobs. The [pinned harness instructions](https://github.com/sebastienros/jint/blob/6363436848365979b2201ed4ec9892ac140c13bc/tools/browser-comparison/README.md) describe all metrics, fixtures, lane boundaries and collection commands. The [pinned workflow](https://github.com/sebastienros/jint/blob/6363436848365979b2201ed4ec9892ac140c13bc/.github/workflows/browser-comparison-hosted.yml) records the hosted setup. A fresh collection must repeat verification and same-VM calibration, retain all controls and meet the unchanged gates; the numbers here are not a substitute for that validation.

## Earlier refusals and setup failures

Only the two complete validated runs above contribute accepted comparison numbers. Earlier failures remain part of the campaign's feasibility history:

- Preflight found dangling LLDB package links in the hosted image; setup was repaired through a bounded, recorded package-manager operation before measurement. Missing or unreadable dependencies were never silently skipped.
- Browser installation and sandbox feasibility required verified pins and a scoped Chromium AppArmor profile. The browser sandbox was not disabled.
- [Calibration 35686847316](https://github.com/sebastienros/jint/actions/runs/35686847316) refused a 44.498% idle observation after 151 rows; partial observations were not accepted as a calibration.
- [Comparison 35711999339](https://github.com/sebastienros/jint/actions/runs/35711999339) completed its calibration but refused a 50.998% idle observation after 63 comparison rows. The partial comparison was excluded.
- [Comparison 35729896926](https://github.com/sebastienros/jint/actions/runs/35729896926) reached the 1800-second dependency-scan setup limit before collecting rows. The later bounded four-worker hashing change retained full coverage and fail-closed behavior; it did not raise that limit or change timing/accounting gates.

These records document setup feasibility and refusal behavior, not browser performance. No claim that every attempted hosted run succeeds is implied by the two accepted results.

---

### Overview (`Jint.Tests.HtmlParser/Html/TreeConstruction/Corpus/README.md`)

Tree Construction Tests
=======================

Each file containing tree construction tests consists of any number of
tests separated by two newlines (LF) and a single newline before the end
of the file. For instance:

    [TEST]LF
    LF
    [TEST]LF
    LF
    [TEST]LF

Where [TEST] is the following format:

Each test must begin with a string "\#data" followed by a newline (LF).
All subsequent lines until a line that says "\#errors" are the test data
and must be passed to the system being tested unchanged, except with the
final newline (on the last line) removed.

Then there must be a line that says "\#errors". It must be followed by
one line per parse error that a conformant checker would return. It
doesn't matter what those lines are, although they can't be
"\#new-errors", "\#document-fragment", "\#document", "\#script-off",
"\#script-on", or empty, the only thing that matters is that there be
the right number of parse errors.

Then there \*may\* be a line that says "\#new-errors", which works like
the "\#errors" section adding more errors to the expected number of
errors.

Then there \*may\* be a line that says "\#document-fragment", which must
be followed by a newline (LF), followed by a string of characters that
indicates the context element, followed by a newline (LF). If the string 
of characters starts with "svg ", the context element is in the SVG
namespace and the substring after "svg " is the local name. If the
string of characters starts with "math ", the context element is in the
MathML namespace and the substring after "math " is the local name.
Otherwise, the context element is in the HTML namespace and the string
is the local name. If this line is present the "\#data" must be parsed
using the HTML fragment parsing algorithm with the context element as
context.

Then there \*may\* be a line that says "\#script-off" or
"\#script-on". If a line that says "\#script-off" is present, the
parser must set the scripting flag to disabled. If a line that says
"\#script-on" is present, it must set it to enabled. Otherwise, the
test should be run in both modes.

Then there must be a line that says "\#document", which must be followed
by a dump of the tree of the parsed DOM. Each node must be represented
by a single line. Each line must start with "| ", followed by two spaces
per parent node that the node has before the root document node.

-   Element nodes must be represented by a "`<`" then the *tag name
    string* "`>`", and all the attributes must be given, sorted
    lexicographically by UTF-16 code unit according to their *attribute
    name string*, on subsequent lines, as if they were children of the
    element node.
-   Attribute nodes must have the *attribute name string*, then an "="
    sign, then the attribute value in double quotes (").
-   Text nodes must be the string, in double quotes. Newlines aren't
    escaped.
-   Comments must be "`<`" then "`!-- `" then the data then "` -->`".
-   DOCTYPEs must be "`<!DOCTYPE `" then the name then if either of the
    system id or public id is non-empty a space, public id in
    double-quotes, another space an the system id in double-quotes, and
    then in any case "`>`".
-   Processing instructions must be "`<?`", then the target, then a
    space, then the data and then "`>`". (The HTML parser cannot emit
    processing instructions, but scripts can, and the WebVTT to DOM
    rules can emit them.)
-   Template contents are represented by the string "content" with the
    children below it.

The *tag name string* is the local name prefixed by a namespace
designator. For the HTML namespace, the namespace designator is the
empty string, i.e. there's no prefix. For the SVG namespace, the
namespace designator is "svg ". For the MathML namespace, the namespace
designator is "math ".

The *attribute name string* is the local name prefixed by a namespace
designator. For no namespace, the namespace designator is the empty
string, i.e. there's no prefix. For the XLink namespace, the namespace
designator is "xlink ". For the XML namespace, the namespace designator
is "xml ". For the XMLNS namespace, the namespace designator is "xmlns
". Note the difference between "xlink:href" which is an attribute in no
namespace with the local name "xlink:href" and "xlink href" which is an
attribute in the xlink namespace with the local name "href".

If there is also a "\#document-fragment" the bit following "\#document"
must be a representation of the HTML fragment serialization for the
context element given by "\#document-fragment".

For example:

    #data
    <p>One<p>Two
    #errors
    3: Missing document type declaration
    #document
    | <html>
    |   <head>
    |   <body>
    |     <p>
    |       "One"
    |     <p>
    |       "Two"

---

### Overview (`Jint.Tests/Runtime/WebApi/Resources/README.md`)

# Web Platform Tests URL corpus

`urltestdata.json`, `setters_tests.json` and `urlpatterntestdata.json` are vendored verbatim from the
[web-platform-tests](https://github.com/web-platform-tests/wpt) repository, at commit

    6745634413e4153144e23bc3e866d6b14d3e55e6

from `url/resources/urltestdata.json`, `url/resources/setters_tests.json` and
`urlpattern/resources/urlpatterntestdata.json`. `wpt-LICENSE.md` is that commit's `LICENSE.md`; the corpus is
redistributed here under the 3-Clause BSD License it carries.

`UrlCorpusTests` runs every row of the first two files directly against `Jint.WebApi.Url.Parsing`, without
building an engine — the parser is deliberately engine-free, so a conformance row costs a parse and nothing else.

`UrlPatternCorpusTests` runs every row of `urlpatterntestdata.json` through one engine, driven by a port of the
WPT runner (`urlpattern/resources/urlpatterntests.js`) written in JavaScript, because what those rows assert is
the behaviour of the `URLPattern` object itself — the pattern strings its accessors return and the shape of its
`exec()` result — rather than of a parser underneath it.

Both drivers list the rows that do not pass in an exclusion table in the driver file, each with the divergence
category it belongs to, and **fail on a stale exclusion**: a row that starts passing has to be removed from the
table in the same change that fixed it.

To update the corpus, resolve `master` to a concrete commit, fetch the three files at that commit, replace them
here, update the SHA above and re-run the drivers.

---

### Overview (`Jint.HtmlParser/Dom/Html/InputValues/Dtoa/README.md`)

# Parser-private shortest digits

Mechanical copy of `Jint/Native/Number/Dtoa/*.cs` at common commit
`56898c3d8` (2026-09-25). Existing source notices are retained verbatim.
The namespace is parser-private and engine Throw calls are replaced by
DtoaThrow, which raises the same CLR exception kinds. No arithmetic or
formatting algorithms were changed in the copy.

HtmlInputNumberFormatter calls only Shortest; precision/fixed modes remain
internal implementation details and have no parser facade. This reuse was
approved after the platform net10 R primitive failed independent V8
reference vectors (2^-958 and 2^-25 and their negative mirrors).

---

### Index (`docs/index.md`)

---
layout: home

hero:
  name: Jint
  text: JavaScript for .NET
  tagline: Embed a modern ECMAScript interpreter in any .NET application, without a native runtime.
  image:
    src: /favicon.svg
    alt: Jint
  actions:
    - theme: brand
      text: Get Started
      link: /guide/getting-started
    - theme: alt
      text: Additional Packages
      link: /guide/choosing-a-package

features:
  - icon: ⚡
    title: Embed JavaScript
    details: Execute scripts, evaluate expressions, call functions, and exchange values with .NET.
    link: /guide/execution
  - icon: 🔒
    title: Bound Execution
    details: Apply statement, time, memory, recursion, parsing, and cancellation limits.
    link: /guide/untrusted-code
  - icon: 🌐
    title: Opt-in Web APIs
    details: Add console, timers, fetch, streams, crypto, storage, workers, and more when your host needs them.
    link: /guide/web-apis
  - icon: 🧭
    title: Headless Browser
    details: Parse HTML, run page scripts, automate a DOM, and inspect the result without Chromium.
    link: /packages/jint-browser/
  - icon: 🐛
    title: DevTools Protocol
    details: Connect Chrome DevTools, Puppeteer, or Playwright to an engine your application hosts.
    link: /packages/jint-devtools/
  - icon: 📦
    title: Modern .NET
    details: Use the core engine from .NET Framework 4.7.2 through current .NET, with Native AOT support on modern targets.
    link: /guide/supported-platforms
---

## Start with Jint

```bash
dotnet add package Jint
```

[View Jint on NuGet.org](https://www.nuget.org/packages/Jint)

With `using Jint;` in scope:


```csharp
var result = new Engine()
    .SetValue("name", "World")
    .Evaluate("`Hello, ${name}!`")
    .AsString();
```


Jint is the main product and documentation entry point. Add another package only when you need markup parsing, debugging or
browser functionality. Jint is open source under the
[BSD 2-Clause License](https://github.com/sebastienros/jint/blob/main/LICENSE.txt).

## Additional packages

<div class="package-grid">
  <a class="package-card" href="./packages/jint-htmlparser/">
    <strong>Jint.HtmlParser</strong>
    <span>Inert markup parsing, native trees, CSS syntax, XPath and serialization without JavaScript.</span>
  </a>
  <a class="package-card" href="./packages/jint-devtools/">
    <strong>Jint.DevTools</strong>
    <span>A Chrome DevTools Protocol server for an engine hosted by your application.</span>
  </a>
  <a class="package-card" href="./packages/jint-browser/">
    <strong>Jint.Browser</strong>
    <span>An in-process, non-rendering browser built with Jint and Jint.HtmlParser.</span>
  </a>
  <a class="package-card" href="./packages/jint-browser-tool/">
    <strong>Jint.Browser.Tool</strong>
    <span>The <code>jint-browser</code> command for extraction, evaluation, CDP, and MCP.</span>
  </a>
  <a class="package-card" href="./packages/jint-browser-mcp/">
    <strong>Jint.Browser.Mcp</strong>
    <span>An MCP server that lets agents navigate and interact with non-rendered pages.</span>
  </a>
</div>

## Used by

Projects using Jint include
[RavenDB](https://github.com/ravendb/ravendb),
[EventStoreDB](https://github.com/EventStore/EventStore),
[Orchard Core](https://github.com/OrchardCMS/OrchardCore),
[Elsa Workflows](https://github.com/elsa-workflows/elsa-core),
[Docfx](https://github.com/dotnet/docfx), and
[JavaScript Engine Switcher](https://github.com/Taritsyn/JavaScriptEngineSwitcher).

---

### Architecture Index (`docs/architecture/index.md`)

# Architecture

Jint parses JavaScript with Acornima and interprets the resulting abstract syntax tree directly. It does not use
the DLR, generate bytecode, or require a native JavaScript runtime.

```text
JavaScript source
      ↓
Acornima parser
      ↓
ECMAScript AST
      ↓
Jint interpreter
      ↓
Runtime, host interop, and optional platform APIs
```

## Architecture topics

Start with the execution model, then use the detailed design records for substantial runtime work:

- [Execution model](execution-model.md)
- [Async Context](../design/async-context.md)
- [Prepared script serialization](../design/prepared-serialization.md)
- [Chrome DevTools Protocol](../design/devtools-protocol.md)
- [Headless browser](../design/headless-browser.md)
- [Web Workers](../design/web-workers.md)

Each record states its own implementation status. Design-only and feasibility documents describe decisions and
tradeoffs; they are not promises that the proposed API is available.

---

### Guide Getting Started (`docs/guide/getting-started.md`)

# Getting Started

## Install

```bash
dotnet add package Jint
```

The examples below assume `using Jint;`.

## Evaluate an expression


```csharp
var answer = new Engine()
    .Evaluate("6 * 7")
    .AsNumber();
```


`Evaluate` returns a `JsValue`. Use `AsString()`, `AsNumber()`, or `ToObject()` when the host needs a CLR value.

## Expose a host function


```csharp
var engine = new Engine()
    .SetValue("log", new Action<string>(Console.WriteLine));

engine.Execute("log('Hello from JavaScript')");
```


Only expose capabilities the script should have. CLR access is not enabled by default.

## Call JavaScript from .NET


```csharp
var result = new Engine()
    .Execute("function add(a, b) { return a + b; }")
    .Invoke("add", 2, 3)
    .AsNumber();
```


Continue with [Creating an Engine](./creating-an-engine.md) and
[Execute, Evaluate, and Invoke](./execution.md).

---

### Guide Index (`docs/guide/index.md`)

# Jint Documentation

Jint embeds JavaScript in a .NET process. Install the `Jint` package, create an `Engine`, and add only the
capabilities your application intends to expose. The rest of this documentation starts from that core runtime.

## Recommended path

1. [Install Jint and run a script](getting-started.md).
2. Learn how to [create and configure an engine](./creating-an-engine.md).
3. Configure [constraints and untrusted-code protection](./untrusted-code.md) before accepting
   scripts you do not control.
4. Add an [additional package](choosing-a-package.md) only when you need debugging or browser functionality.

Upgrading an existing application? Read [Migrating to Jint 5](migrating-to-v5.md) first.

---

### Reference Index (`docs/reference/index.md`)

# Reference

Use these pages to check compatibility and deployment requirements after learning the package APIs.

| Reference | Covers |
| --- | --- |
| [Package matrix](package-matrix.md) | Package purpose, frameworks, and dependencies |
| [ECMAScript support](ecmascript.md) | Implemented language editions and proposals |
| [Web API features](web-api-features.md) | Opt-in browser and runtime APIs in `Jint` |
| [Browser compatibility](browser-compatibility.md) | What `Jint.Browser` provides and omits |
| [DevTools domains](devtools-domains.md) | Engine and page CDP domains |
| [Native AOT](native-aot.md) | Trimming and native compilation |
| [Security](security.md) | Trust boundaries and hardened hosting |
| [Branches and releases](releases.md) | Supported release lines and package publication |

---

### Packages Jint Browser Mcp Index (`docs/packages/jint-browser-mcp/index.md`)

# Jint.Browser.Mcp

`Jint.Browser.Mcp` exposes `Jint.Browser` through the Model Context Protocol. An agent can navigate, read DOM-derived snapshots, act on controls, inspect requests and cookies, and keep one browsing session.

It runs in one .NET process with no native browser or browser download. It **does not render** and offers no screenshots or PDFs. Agents read accessibility, markdown, or text snapshots instead.

[View Jint.Browser.Mcp on NuGet.org](https://www.nuget.org/packages/Jint.Browser.Mcp)

The quickest entry is the command-line tool:

```bash
dotnet tool install -g Jint.Browser.Tool
jint-browser mcp
```

Applications can embed the server with `AddJintBrowser`:

```csharp
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .AddJintBrowser();
```

Pages are hardened for untrusted content by default. Stdio is the supported command-line transport and gives each client process its own browsing state.

- [Command line](./command-line)
- [Embedding](./embedding)
- [Tools and resources](./tools-and-resources)
- [Sessions and transports](./sessions-and-transports)
- [Security](./security)
- [Limitations](./limitations)

---

### Packages Jint Browser Tool Index (`docs/packages/jint-browser-tool/index.md`)

# Jint.Browser.Tool

`Jint.Browser.Tool` installs the `jint-browser` command. It loads scripted pages, extracts text-first representations, evaluates JavaScript, or serves the browser over CDP or MCP.

```bash
dotnet tool install -g Jint.Browser.Tool
```

[View Jint.Browser.Tool on NuGet.org](https://www.nuget.org/packages/Jint.Browser.Tool)

```bash
jint-browser fetch https://example.org/ --dump markdown
jint-browser eval https://example.org/ "document.title"
jint-browser serve --port 9222
jint-browser mcp
```

The tool is a single .NET process with no browser download. It **does not render** and cannot produce screenshots, PDFs, or visual layout.

Standard output is the command's answer; page and usage diagnostics go to standard error. This makes `fetch` and `eval` safe to pipe. The `mcp` command reserves standard output for protocol messages.

- [Installation](./installation)
- [Fetch](./fetch)
- [Evaluate](./evaluate)
- [Requests and state](./requests-and-state)
- [Serve CDP](./serve-cdp)
- [Serve MCP](./serve-mcp)
- [Untrusted content](./untrusted-content)
- [Exit codes](./exit-codes)
- [Limitations](./limitations)

---

### Packages Jint Browser Getting Started (`docs/packages/jint-browser/getting-started.md`)

# Getting started

Install the package:

```bash
dotnet add package Jint.Browser
```

Create and dispose a browser asynchronously:

```csharp
using Jint.Browser;

await using var browser = new Browser();
var page = await browser.NewPageAsync();

var response = await page.NavigateAsync("https://example.org/");
Console.WriteLine($"{response?.Status} {await page.TitleAsync()}");
Console.WriteLine(await page.TextAsync());
```

`NavigateAsync` accepts `http:`, `https:`, `about:`, and `data:` URLs. To load markup directly, use `SetContentAsync`:

```csharp
await page.SetContentAsync(
    "<p id='answer'></p><script>answer.textContent = 6 * 7</script>",
    "https://app.example/");

var answer = await page.EvaluateAsync<int>(
    "Number(document.querySelector('#answer').textContent)");
```

The optional base URL supplies the document URL and origin, and is used to resolve relative URLs. Without it, the document is `about:blank` with an opaque origin.

Every `Page` owns a thread and a Jint engine. Public page methods post work to that thread and return ordinary CLR values; they never return engine-owned `JsValue` instances or native DOM nodes.

Next, read [Lifecycle](./lifecycle), [Navigation](./navigation), and [DOM and evaluation](./dom-and-evaluation).

---

### Packages Jint Browser Index (`docs/packages/jint-browser/index.md`)

# Jint.Browser

`Jint.Browser` is a headless browser for .NET built from [Jint.HtmlParser](../jint-htmlparser/) and Jint. The native parser supplies the tree and markup/CSS parsing; Jint runs the document's JavaScript. The package adds navigation, an event loop, network and storage state, input, extraction, and automation seams.

It runs in-process and has no browser binary to install. It also **does not render**: there are no pixels, screenshots, PDFs, or browser windows.

```bash
dotnet add package Jint.Browser
```

[View Jint.Browser on NuGet.org](https://www.nuget.org/packages/Jint.Browser)

With `using Jint.Browser;` in scope:


```csharp
await using var browser = new Browser();
var page = await browser.NewPageAsync();

await page.NavigateAsync("https://example.org/");
Console.WriteLine(await page.MarkdownAsync());
```


The package targets .NET 8 and later. The Browser library does not declare general trimming or AOT compatibility; the closed browser tool is verified separately.

## Choose a topic

- [Getting started](./getting-started)
- [Browser, context, and page lifecycle](./lifecycle)
- [Navigation](./navigation)
- [DOM and evaluation](./dom-and-evaluation)
- [Input and forms](./input-and-forms)
- [Network and storage](./network-and-storage)
- [Extraction](./extraction)
- [Events and workers](./events-and-workers)
- [DevTools Protocol](./devtools)
- [Budgets](./budgets)
- [Untrusted content](./untrusted-content)
- [Supported features](./supported-features)
- [Limitations](./limitations)

---

### Packages Jint Devtools Getting Started (`docs/packages/jint-devtools/getting-started.md`)

# Getting started

## Requirements

- .NET 8 or .NET 10
- A Jint engine created with `UseDevTools()`
- A host loop that keeps servicing the engine, or a `LibraryOwned` target

Install the package:

```bash
dotnet add package Jint.DevTools
```

## Create and publish a target

```csharp
using Jint;
using Jint.DevTools;

var engine = new Engine(options => options.UseDevTools());

await using var target = new EngineTarget(engine, new EngineTargetOptions
{
    Title = "Rules engine",
    Url = "rules.js"
});

await using var server = new DevToolsServer(); // loopback, ephemeral port
server.AddTarget(target);
server.Start();

Console.WriteLine($"DevTools: {server.BrowserHttpUrl}");
```

With the default `Port = 0`, the operating system chooses a free port. After `Start()`, use `BoundPort`,
`BrowserHttpUrl`, or `BrowserWebSocketUrl` to discover it.

## Pump the engine

`EngineTarget` defaults to `ThreadMode.HostOwned`. Both script and DevTools commands must run on the engine's
owning thread:

```csharp
while (!stopping)
{
    target.Pump();
    engine.Tasks.WaitForScheduledWork(TimeSpan.FromMilliseconds(50));
}
```

`engine.Tasks.ProcessTasks()` may be used instead of `target.Pump()`. If the host does not pump, clients receive
`Engine is not being pumped` after `CommandTimeout`.

## Run a named script

Source names make scripts easier to find and breakpoint in a client:

```csharp
engine.Execute(
    """
    function total(a, b) {
        return a + b;
    }
    globalThis.result = total(20, 22);
    """,
    "rules.js");
```

Next, [connect a client](connecting.md). For applications without an existing engine loop, see
[LibraryOwned hosting](hosting.md#libraryowned).

---

### Packages Jint Devtools Index (`docs/packages/jint-devtools/index.md`)

# Jint.DevTools

`Jint.DevTools` exposes a Jint engine through the
[Chrome DevTools Protocol (CDP)](https://chromedevtools.github.io/devtools-protocol/).
Chrome DevTools, Puppeteer, PuppeteerSharp, and other CDP clients can inspect, evaluate, debug, and profile
scripts in an application that embeds Jint. No browser is required.

The package targets .NET 8 and .NET 10 and supports Native AOT.

::: warning Unauthenticated endpoint
The endpoint has no authentication. Any client that can reach it can evaluate JavaScript in your process.
Keep the default loopback host (`127.0.0.1`) and do not expose the port to an untrusted network.
:::

## Install

```bash
dotnet add package Jint.DevTools
```

[View Jint.DevTools on NuGet.org](https://www.nuget.org/packages/Jint.DevTools)

## Minimal host

```csharp
using Jint;
using Jint.DevTools;

var engine = new Engine(options => options.UseDevTools());
await using var target = new EngineTarget(engine);
await using var server = new DevToolsServer();

server.AddTarget(target);
server.Start();

Console.WriteLine(server.BrowserHttpUrl);

while (!stopping)
{
    target.Pump();
    engine.Tasks.WaitForScheduledWork(TimeSpan.FromMilliseconds(50));
}
```

The loop is required: the default `HostOwned` target answers protocol commands only when the host pumps the
engine, on the engine's owning thread.

## Guide

- [Getting started](getting-started.md)
- [Hosting and thread modes](hosting.md)
- [Connecting clients](connecting.md)
- [Debugging scripts](debugging.md)
- [Profiling and coverage](profiling.md)
- [Supported domains](domains.md)
- [Native AOT](native-aot.md)
- [Security](security.md)

---

### Packages Jint Htmlparser Index (`docs/packages/jint-htmlparser/index.md`)

# Jint.HtmlParser

`Jint.HtmlParser` parses and manipulates HTML, XML, SVG and CSS from .NET. It provides a native mutable
DOM, XPath 1.0, mutation subscriptions, live ranges and explicit HTML/XML serialization. It targets
.NET 8 and .NET 10 and has no dependency on the Jint JavaScript engine.

Parsing is inert: it does not execute scripts, fetch resources, load CSS imports or resolve external XML
entities. Use [Jint.Browser](../jint-browser/) when you need JavaScript, navigation or a page event loop.
The standalone parser does not compute styles or render a page.

## Install

Follow [Using Jint 5 Preview Packages](../../guide/preview-packages.md) to register the development feed:

```bash
dotnet add package Jint.HtmlParser --prerelease
```

Pin the resolved version in your application. The parser API is **experimental**: public signatures,
behavior and value-type layouts can change between prereleases. Its package remains prerelease even
when Jint releases GA; a stable version input becomes `experimental-<BuildNumber>` (default `0`),
while an existing prerelease suffix is preserved. Browser packages pin their calculated parser dependency.
[External package and API acceptance](../../design/html-parser-completeness.md) remains open.

## Parse your first document

The main APIs and native tree types are in `Jint.HtmlParser`. Add `using Jint.HtmlParser;`:


```csharp
var document = MarkupParser.ParseHtml("<main><p>Hello</p></main>");
var body = document.DocumentElement!.LastChild!;
Console.WriteLine(MarkupSerializer.ToHtml(body.FirstChild!));
```


This prints `<main><p>Hello</p></main>`. HTML parsing creates the implied `html`, `head` and `body`
structure, so the first body child is the `main` element. Parsing a script element would retain its text
without executing it.

## Choose an API

| Task | Entry point | Guide |
| --- | --- | --- |
| Parse HTML, XML, SVG or contextual fragments | `MarkupParser` | [Parsing markup](./parsing.md) |
| Create, traverse, mutate, observe or select a range in a tree | `Document`, `Node`, `Element`, `DomRange` | [Working with trees](./trees.md) |
| Inspect CSS syntax or query a tree with XPath | `MarkupParser.ParseCss*`, `NativeXPath` | [CSS syntax and XPath](./css-and-xpath.md) |
| Emit HTML or XML, with optional shadow roots | `MarkupSerializer` | [Serialization](./serialization.md) |
| Bound work, collect diagnostics and coordinate readers | `ParseLimits`, cancellation tokens | [Limits and threading](./limits-and-threading.md) |

The package is marked AOT-compatible; the unsigned fresh-package consumer also runs as Native AOT in
CI. See [Native AOT and Trimming](../../reference/native-aot.md) for the distinction between this library
and the broader Browser package. API snapshots and internal Browser use do not freeze this provisional API.

---

### Agent Instruction Files (`docs/agent-instruction-files.md`)

# Which agents load which instruction file

The root `AGENTS.md` keeps itself under 24 KiB and every co-located `AGENTS.md` under 32 KiB, and
`Jint.Tests/AgentInstructionFileTests.cs` fails the build when either is crossed. This page holds the evidence
those two numbers rest on, verified 2026-08-23, so the root file does not have to carry it.

| Agent | Root file it loads | Loads a nested `AGENTS.md`? |
| --- | --- | --- |
| OpenAI Codex | `AGENTS.override.md`, then `AGENTS.md` | No — never below the working directory |
| Claude Code | `CLAUDE.md`, which imports this file; it does **not** read `AGENTS.md` | Yes, indirectly — it loads a directory's `CLAUDE.md` when it reads a file there, and each of those is one line importing the `AGENTS.md` beside it; `.claude/rules/*.md` with `paths:` routes to the same files |
| Copilot cloud agent | `AGENTS.md` (since 2025-08-28) *and* `.github/copilot-instructions.md`; both are supplied | Yes, `**/AGENTS.md`, nearest in the tree wins |
| Copilot code review | `AGENTS.md` (since 2026-06-18) | No — root only |
| Copilot CLI | `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md` | Upward only, cwd → git root |
| Copilot in VS Code | `AGENTS.md` (on by default since v1.105) | Off by default; even when on it injects the *path*, not the content |
| Copilot in Visual Studio / JetBrains | `.github/copilot-instructions.md` only — no `AGENTS.md` support | No |
| Cursor | `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/*.mdc` | Yes, combined with parents, more specific wins |
| Amp | `AGENTS.md`, falling back per directory to `AGENT.md` / `CLAUDE.md` | Yes, lazily, when it reads a file in that subtree |
| Devin Desktop (ex-Windsurf) / Devin CLI | `AGENTS.md`, `.devin/rules/*.md`, `.windsurf/rules/*.md` | Yes — a subdirectory file becomes a glob rule for `<dir>/**` |
| Gemini CLI | `GEMINI.md`; `AGENTS.md` only if `context.fileName` names it | Upward from a touched path, just in time |
| Jules | `AGENTS.md` at the repository root | Not documented |
| Aider | nothing automatically; needs `read: [AGENTS.md]` in `.aider.conf.yml` | No |

A one-line `CLAUDE.md` — the single import `@AGENTS.md` — sits beside every co-located `AGENTS.md`, mirroring
the repository root. It carries no content of its own on purpose: the substance is in the `AGENTS.md`, and a
`CLAUDE.md` that grew any would be a second copy nothing holds it to the first. What it buys is a route into
a nested file for the ecosystems the last column says have none — Claude Code above all, which does not read
`AGENTS.md` at any depth, and Copilot CLI, whose walk is upward from the working directory. Amp and Cursor
already descend and simply arrive at the same file by a second path; nothing is duplicated either way,
because the pointer is an import rather than a copy.

A recipe too long to sit in the `AGENTS.md` that governs it moves instead to a plain markdown file beside
the same code — `Jint/Native/Object/host-object-shapes.md` was the first, and `Jint/WebApi/Events/event-dispatch.md`
and `Jint.Browser/Layout/box-model.md` followed it — and is reached only by a pointer from that `AGENTS.md`,
from the `.claude/rules` file for the area, and from the root index row when that row named the material. No agent
loads such a file on its own, which is the point: what an agent breaks *before* it knows which file to open
has to stay in the file it loads, while the recipe for what to do instead is only actionable once that area
is open anyway. `AgentInstructionFileTests` does not weigh these files — nothing truncates a document no
agent loads — but every pointer into one, from a file it does weigh, still has to resolve.

Where a document beside that code already exists, the recipe extends it rather than starting a second one.
The field reference for every list in `tools/dom-bindings/overrides.json` lives in the generator's own
`tools/dom-bindings/README.md`, which already described the file in outline, so the binding surface has one
description instead of two; `Jint.Browser/Dom/divergences.md` is the same shape from the other direction —
a register of data beside the code, pointed at from the `AGENTS.md` that governs it.

**32 KiB is Codex's `project_doc_max_bytes` default**, and it is a running budget across the whole
root-to-cwd chain rather than a per-file allowance, so a fat root file starves a nested one; overflow is a
silent mid-file byte truncation whose only signal is a log line below the level `codex exec` prints at. The
Devin CLI caps an always-on rule file at the same 32 KiB, truncating with a pointer to the source. Devin
Desktop documents 12,000 characters per workspace rule file; whether that applies to an `AGENTS.md` its rules
engine processes is undocumented, so treat it as the tightest plausible budget.

## What makes the budget fail rather than merely be stated

`Jint.Tests/AgentInstructionFileTests.cs` is what makes those numbers fail rather than merely be stated,
since the truncation is silent and nobody was measuring by hand. It counts CRLF line endings — the largest
a checkout can be — reports *every* file's headroom when one crosses, because that is the moment you need
to know where there is room, and holds the routing map together: index, `.claude/rules`, links and anchors.
Only five of the fourteen ecosystems in the table above reach a co-located file on their own — four descend
into a nested `AGENTS.md`, and Claude Code follows the one-line `CLAUDE.md` beside it — so every other one
arrives only because the root index names it, and a relocated rule nothing points at is the same outcome as
truncation.

## Native parser routing and documentation

The root index and `.claude/rules/html-parser.md` route parser changes to
[Jint.HtmlParser/AGENTS.md](../Jint.HtmlParser/AGENTS.md), with the one-line `CLAUDE.md` beside it.
Its [contributor guide](../Jint.HtmlParser/CONTRIBUTING.md) holds regeneration, corpus and package-check
recipes outside the instruction budget. The [NuGet README](../Jint.HtmlParser/README.md) holds embedding
usage, defaults, provisional API/version policy and supported threading. `AgentInstructionFileTests`
discovers the new instruction file and checks its budget, routing, links and anchors automatically.

---

### Package.Json (`docs/package.json`)

{
  "name": "jint-documentation",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview",
    "test:tools": "node --test scripts/*.test.mjs",
    "samples:sync": "node scripts/sync-snippets.mjs",
    "samples:check": "node scripts/sync-snippets.mjs --check",
    "check-links": "node scripts/check-links.mjs .vitepress/dist",
    "lint": "markdownlint-cli2 \"**/*.md\" \"../README.md\" \"../Jint.DevTools/README.md\" \"../Jint.Browser/README.md\" \"../Jint.Browser.Tool/README.md\" \"../Jint.Browser.Mcp/README.md\" \"../Jint.HtmlParser/README.md\" \"../Jint.HtmlParser/CONTRIBUTING.md\" \"../Jint.HtmlParser/AGENTS.md\" \"../Jint.HtmlParser/CLAUDE.md\" \"../.claude/rules/html-parser.md\"",
    "lint:fix": "npm run lint -- --fix"
  },
  "devDependencies": {
    "@types/node": "^24.0.0",
    "markdownlint-cli2": "^0.23.2",
    "vitepress": "^2.0.0-alpha.17"
  },
  "overrides": {
    "markdownlint-cli2": {
      "smol-toml": "1.7.1"
    }
  }
}

---

### Sponsors (`docs/sponsors.md`)

# Sponsors

Jint is sustained by the organizations and products that support its ongoing development.

<div class="sponsor-grid">
  <a class="sponsor-card" href="https://www.elsa-workflows.io/" target="_blank" rel="noopener">
    <img class="sponsor-logo" src="/sponsors/elsa-workflows.png" alt="Elsa Workflows logo">
    <span class="sponsor-copy">
      <strong>Elsa Workflows</strong>
      <span>An open-source workflow engine for .NET for building and running workflow-driven applications.</span>
    </span>
  </a>
</div>

## Thank you

Thank you for sponsoring Jint, and to the individual contributors who donate their time,
expertise, testing, and feedback. Together, they help its maintainers invest in specification conformance,
performance, releases, documentation, and long-term maintenance.

If Jint saves your team time or powers a product your users depend on, please consider sponsoring its
maintainers. Recurring support makes sustained open-source work possible:
[sponsor @lahma](https://github.com/sponsors/lahma) or
[sponsor @sebastienros](https://github.com/sponsors/sebastienros), but really prefer @lahma because he deserves
it more.

## Add, update, or remove a listing

Open a [sponsor listing issue](https://github.com/sebastienros/jint/issues/new?template=03_sponsor_listing.yml)
to request a change. Include:

- whether the listing should be added, updated, or removed;
- the organization or product name and official website;
- a short description of how it supports Jint;
- an official SVG or PNG logo and confirmation that Jint may display it.

Requests should come from an authorized representative or include public evidence supporting the change.
Maintainers may update or remove listings that are outdated, inaccurate, or no longer connected to Jint.

Sponsor names and logos remain the property of their respective owners. A listing identifies project support and
does not imply endorsement of every product or service offered by that sponsor.

---

### Xml Doc Style (`docs/xml-doc-style.md`)

# XML documentation style

Every declaration of Jint's public API surface carries a `<summary>`. A test says which ones do not,
and this document says what a good one looks like.

- The gate is `Jint.Tests.PublicInterface/PublicApiDocumentationTest.cs`.
- The debt it has not been paid down to zero yet is
  `Jint.Tests.PublicInterface/UndocumentedPublicApi.txt`, which may only ever shrink.
- The surface it measures is the approved API baseline,
  `Jint.Tests.PublicInterface/Verify/PublicApiTest_<tfm>.verified.txt`. Not `CS1591`: that warning
  counts the public members of *internal* types too — 1,688 of them on `net10.0` — and no embedder
  reads those.

The audience is an embedder reading IntelliSense, not a maintainer reading the file. Everything
below follows from that one sentence.

## The rules

**`<summary>` is one sentence, at most 25 words, ending in a period.** It says what the member *is*
or *does*. Never why it exists, never what it used to be, never who asked for it.

**`<para>` never appears inside `<summary>`.** A summary is one sentence, so there is nothing to
paragraph. Something that needs two paragraphs is `<remarks>`.

**A property's summary starts `Gets`, `Gets or sets`, or — for a `bool` — `Whether`.** A method's
starts with a third-person verb: `Registers`, `Converts`, `Returns`, `Throws`.

**A default is one clause in the summary**, not a paragraph of its own:
`Defaults to <see langword="true"/>.`

**`<remarks>` holds only what a caller must do or avoid.** One rule per `<para>`, at most four lines
each, **at most four paragraphs**. Past that the material is not caller guidance any more — it is
design notes, and those belong in the co-located `AGENTS.md` for the area, with a `<see href>` back
to it, or in a `//` comment beside the code it explains.

**No history, no benchmark narrative, no rationale-about-the-author.** "The engine's older probe sat
in the call expression", "the benchmark gate measured 1.5–3% slower", "it is stated rather than left
to the implementation" — none of that helps somebody calling the method, and the numbers go stale
where nobody looks.

**`<param>` is a noun phrase.** "The global property name." Not "Pass the name of the global here."

**`<example>` only where the signature does not imply the call**, and at most 8 lines of `<code>`.
A loop shape, a two-call protocol, a factory whose lifetime is not obvious. Not `Evaluate(string)`.

**`<inheritdoc/>` counts as documented.** The declaration it inherits from is in the same surface and
is held to these rules in its own right, so writing the summary twice is worse than pointing at it.

**A doc comment on a `partial` type goes on a part every target framework compiles.** Jint ships five
assemblies; a comment inside `#if NET8_0_OR_GREATER` is invisible to the three that resolve the
`netstandard` and `net472` assets. `PublicApiDocumentationTest.NoTargetFrameworkIsDocumentedLessThanTheNewest`
checks this, and found two.

## Three rewrites

Real members, picked by measuring the longest doc comments in the tree. All three are in this
repository as the *after* column.

### 1. A method: `OptionsExtensions.AddLazyGlobal`

110 lines of XML doc for one method — the longest in `Jint/` — with six `<remarks>` paragraphs, one
of which opened a `<para>` and then nested three more inside it before closing. That nesting is
well-formed XML, so nothing in the build noticed; it is invalid in every renderer downstream,
because a paragraph cannot contain a paragraph.

**Before** (abridged; the omitted paragraphs are more of the same):

```
/* Detailed source-code truncated for AI context efficiency. */
```

**After** — 22-word summary, four paragraphs of at most four lines, the example down to 8 lines:

```xml
<summary>
Registers a global whose value <paramref name="valueFactory"/> produces on the first read, rather
than when the engine is created.
</summary>
<remarks>
<para>
The property itself is installed eagerly, so <c>in</c>, <c>hasOwnProperty</c> and
<c>Object.keys(globalThis)</c> see it without materializing anything. Only the value waits, and
<c>typeof</c> counts as a read of it.
</para>
<para>
The factory runs once per engine, receiving the engine being built, so one <see cref="Options"/>
may be shared by any number of engines. It must not return <see langword="null"/>;
<see cref="JsValue.Undefined"/> is stored if it does.
</para>
<para>
Deleting the global before any read skips the factory. Overwriting or redefining it before any read
still runs the factory once and discards the result, because <c>[[DefineOwnProperty]]</c> reads the
current value before replacing it.
</para>
<para>
<see cref="Engine.AdvancedOperations.RestoreGlobalSnapshot"/> re-arms a global whose factory had not
run when the snapshot was taken, so it runs again — a host pooling engines depends on that. Whether
the second run produces a fresh value is the factory's business: one that hands back something it
holds gives the next cycle exactly what the previous one mutated.
</para>
</remarks>
```

What went: the "sharing an `Options` instance" essay (the surviving clause is the one a caller acts
on — one `Options`, any number of engines), the paragraph explaining why the restore contract is
stated at all, and the paragraph restating it from the other side.

### 2. A property: `Engine.AdvancedOperations.TimeUntilNextScheduledWork`

83 lines: five `<remarks>` paragraphs, a `<list>` inside one of them, and two `<code>` blocks.

**Before** (abridged):

```xml
<summary>
How long this engine may be left alone before <see cref="ProcessTasks"/> has something to run, or
<see langword="null"/> when it has nothing scheduled at all.
</summary>
<remarks>
<para>
<b>The canonical host loop is this property plus <see cref="ProcessTasks"/>, and there is
deliberately no third method that drains for a budget.</b> Jint never starts a thread to run script:
a <c>setTimeout</c> callback, a <c>scheduler.postTask</c> task, a settled <c>Atomics.waitAsync</c>
all run on whichever thread calls into the engine, and nowhere else. What a host driving its own
loop was missing is not another way to pump but the answer to <i>when</i> to pump, which is this.
</para>
<para>
The three answers:
<list type="bullet"> ... three <item><description> blocks ... </list>
</para>
... two more paragraphs, then a 15-line <example> and a second <code> block ...
</remarks>
```

**After** — `Gets`, four paragraphs, one 8-line example:

```xml
<summary>
Gets how long this engine may be left alone before <see cref="ProcessTasks"/> has work to run, or
<see langword="null"/> when nothing is scheduled.
</summary>
<remarks>
<para>
<see cref="TimeSpan.Zero"/> means there is work to run now — call <see cref="ProcessTasks"/>. A
positive value is how long until the earliest <em>timed</em> work comes due: a <c>setTimeout</c>,
an <c>AbortSignal.timeout()</c>, a delayed <c>scheduler.postTask</c>, an <c>Atomics.waitAsync</c>
deadline.
</para>
<para>
Do not treat <see langword="null"/> as "nothing will happen". A CLR task a script awaits through
interop, and an <c>Atomics.waitAsync</c> another agent notifies, both simply enqueue and have no
due time to report, so a host loop needs a cadence of its own.
</para>
<para>
The answer is a snapshot taken on the calling thread and may be stale the instant it is returned,
so do not sleep on it indefinitely. Use <see cref="WaitForScheduledWork(TimeSpan, CancellationToken)"/>
instead when there is no frame to keep — it also wakes on a job arriving from another thread.
</para>
<para>
Reading it is allocation-free, so a per-frame read costs nothing.
</para>
</remarks>
```

The `<list>` of "three answers" became the first paragraph, because a bullet per return value of a
`TimeSpan?` is three bullets saying zero, positive, and null. The second `<code>` block went: it
demonstrated a *different* member, and that member's own documentation is where it belongs.

### 3. A `bool` property: `Options.ConstraintOptions.StackOverflowGuard`

The summary was already the right shape — `Whether …`, `Defaults to <see langword="true"/>.` — and
only needed trimming to the 25-word cap. The `<remarks>` were the offender: six paragraphs, of which
one was a benchmark result and two were history.

**Before** (the two paragraphs that went, verbatim):

```xml
<para>
It measures the stack rather than counting calls, so it covers every route into a function body
rather than only a call expression: <c>new</c>, a getter or setter, a <c>valueOf</c>/<c>toString</c>
coercion, a Proxy trap, a callback a built-in invokes, and a host delegate that re-enters the
engine. Eighteen distinct routes reproduce the overflow, and the engine's older probe sat in the
call expression, so it saw one of them. Measuring also means the depth follows the stack of the
thread the engine runs on, instead of a frame count the host had to guess.
</para>
<para>
It is enabled by default because the alternative on an unbounded recursion is termination of the
host process. The probe is not free: the benchmark gate measured the recursion rows (<c>Fib</c>,
<c>DeepSum</c>, <c>Tak</c>) roughly 1.5–3% slower with it on, while hot shallow calls stayed within
run-to-run noise. A host whose scripts are all trusted and independently bounded can explicitly
set this property to <see langword="false"/> to recover that cost. A host sandboxing untrusted
input generally cannot, and a couple of percent on deep recursion is the price of staying alive.
</para>
```

**After** — the same two rules, without the archaeology or the number:

```xml
<summary>
Whether every entry into an interpreted function probes the remaining native stack and throws a
catchable <c>RangeError</c> when it runs low. Defaults to <see langword="true"/>.
</summary>
...
<para>
It measures the stack rather than counting calls, so it covers every route into a function body:
<c>new</c>, an accessor, a <c>valueOf</c>/<c>toString</c> coercion, a Proxy trap, a callback a
built-in invokes, and a host delegate that re-enters the engine.
</para>
...
<para>
It is a backstop, not a policy: where <see cref="MaxRecursionDepth"/> is also set that limit fires
first, and <see cref="MaxExecutionStackCount"/> takes precedence over this flag. Set it to
<see langword="false"/> to recover the probe's cost when every script is trusted and independently
bounded. Read once, while the engine is being constructed.
</para>
```

"Eighteen distinct routes reproduce the overflow" is a fact about a pull request, not about the
property. "1.5–3% slower" is a measurement of a machine that no longer exists; what the caller needs
is that the probe costs something on deep recursion and can be turned off for trusted script.

## What this is worth today

Measured on `net10.0` at the commit that added the gate.

| | |
| --- | ---: |
| Declarations in the approved public surface | 2,149 |
| …of which a person writes a `<summary>` for (the rest are overrides) | 1,919 |
| …of those, documented | 1,199 |
| **…of those, undocumented** | **720** |
| Summaries containing a `<para>` (public surface) | 35 |
| Summaries containing a `<para>` (whole assembly) | 452 |
| Documentation comments nesting a `<para>` | 0 (was 1) |

The 720, by namespace — this is the table the documentation wave works down:

| Namespace | Types | Methods | Properties | Fields | Total |
| --- | ---: | ---: | ---: | ---: | ---: |
| `Jint` | 24 | 92 | 25 | 68 | **209** |
| `Jint.Native` | 16 | 62 | 24 | 7 | **109** |
| `Jint.Runtime` | 13 | 22 | 40 | 16 | **91** |
| `Jint.Runtime.Modules` | 8 | 30 | 12 | 6 | **56** |
| `Jint.Runtime.Debugger` | 11 | 11 | 10 | 8 | **40** |
| `Jint.Runtime.Descriptors` | 3 | 8 | 10 | 17 | **38** |
| `Jint.Native.Intl` | | 30 | | | **30** |
| `Jint.Native.TypedArray` | 12 | 13 | | | **25** |
| `Jint.Runtime.Interop` | 2 | 13 | 5 | 1 | **21** |
| `Jint.Native.Object` | 2 | 12 | 3 | 1 | **18** |
| `Jint.Native.Symbol` | 1 | | | 15 | **16** |
| `Jint.Native.Array` | 2 | 9 | 3 | | **14** |
| `Jint.Native.Temporal` | | 1 | 12 | | **13** |
| `Jint.WebApi` | | 8 | | | **8** |
| `Jint.Native.Function` | 3 | | 2 | 2 | **7** |
| `Jint.Constraints` | 2 | 2 | | | **4** |
| `Jint.Native.Json` | 1 | 3 | | | **4** |
| `Jint.Native.ShadowRealm` | | 4 | | | **4** |
| `Jint.Native.Error` | 2 | 1 | | | **3** |
| `Jint.Native.RegExp` | 1 | 2 | | | **3** |
| `Jint.Native.Map` | 1 | 1 | | | **2** |
| `Jint.Native.Set` | 1 | 1 | | | **2** |
| `Jint.NodeCompat` | | 2 | | | **2** |
| `Jint.Native.Global` | 1 | | | | **1** |
| **total** | **106** | **327** | **146** | **141** | **720** |

Only the last row of the first table is enforced today. The `<para>`-in-`<summary>` counts are
measurements, not a gate: the wave removes them area by area, and a rule with 35 open violations
would need an allowlist of its own to be worth turning on.

## Working the allowlist down

Document an area, then regenerate and read the diff:

```bash
JINT_PUBLIC_API_DOCS=update dotnet test -c Release \
    --project Jint.Tests.PublicInterface/Jint.Tests.PublicInterface.csproj -f net10.0
```

The count on the first line of `UndocumentedPublicApi.txt` goes down by exactly as many lines as
were removed, which is the whole point of it being there. Three things fail the gate, and all three
are fixed the same way — review, then regenerate:

- a public declaration shipped with no summary that the file does not name;
- a declaration the file names that is now documented, so the file has to shrink;
- a declaration the file names that is no longer in the public surface at all.

Adding a line is the one edit the file does not accept. A new public declaration ships documented.

## What no summary is demanded of

Metadata holds 2,404 public and protected declarations. The approved baseline writes down 2,149 of
them — it already omits the compiler-synthesized and delegate members below — and of those 2,149 the
gate skips the 230 overrides, leaving 1,919. Each category is skipped because another declaration in
the same surface carries the documentation an IDE actually shows:

- **compiler-synthesized record members** — `Equals`, `GetHashCode`, `ToString`, `op_Equality`,
  `op_Inequality`, `PrintMembers`, `<Clone>$`, `EqualityContract`, the copy constructor, and a
  positional `Deconstruct`. 207 of them;
- **delegate members** — `Invoke`, `BeginInvoke`, `EndInvoke` and the constructor. The `delegate`
  declaration carries the summary and the `<param>` tags. 48 of them;
- **overrides** — 230 of them. The declaration being overridden is in the surface and is held to the
  rule; an IDE falls back to it;
- **explicit interface implementations** — 0 of them, because the compiler emits one as `private` and
  the surface is public and protected members only. The enumerator names the case anyway, so that a
  future change to the visibility filter does not silently start demanding a summary of a member
  nothing can call without a cast to the interface — whose declaration is in the surface already.

Everything else is in: types, constructors, fields, enum members, properties, indexers, events,
methods and user-written operators. An *implicitly* declared constructor is in too, and the only way
to document one is to declare it — which is a real improvement, not a workaround. See
`Jint/Constraints/OperationDeadlineConstraint.cs` for one done that way.

---

### Jint.DevTools Docs Manual Checklist (`Jint.DevTools/docs/manual-checklist.md`)

# Attaching the Chrome DevTools front end by hand

The front end is a user interface, and what it does with a target is its own business rather than this
server's. So this is the one claim in the package that a test cannot make on its own: everything under
`Jint.Tests.DevTools/` asserts what *this server answers*, and none of it asserts what *Chrome shows*.

Most of the walk below is now driven automatically by
[`tools/devtools-frontend-smoke/`](../../tools/devtools-frontend-smoke/README.md), which launches Chrome for
Testing, loads the hosted front end against a real Jint process and asserts against the front end's own
DOM. That tool is not in CI — it downloads a browser and reaches the network — so run it when the protocol
surface changes, and run this checklist when you want to see it with your own eyes, or when the tool says a
step could not be driven.

## Start something to attach to

```bash
dotnet run --project Jint.Repl -c Release -- --inspect -f script.js -t 60
```

The REPL prints three things: the WebSocket endpoint, the `devtools://` address of the front end, and what
to type into `chrome://inspect`. `--inspect=0` takes an ephemeral port and the banner says which one;
`--inspect-brk` runs nothing until a client has attached and sent `Runtime.runIfWaitingForDebugger`, which
is what you want when the interesting thing happens in the first line of the script.

A host embedding the package does the same with a `DevToolsServer` and an `EngineTarget`; the REPL is only
the shortest way to have one.

## Attach

Either address works and they are worth knowing apart:

- **`chrome://inspect`** — click **Configure…**, add `127.0.0.1:9229` (or whichever port the banner named),
  close the dialog, and the engine appears under **Remote Target** with the title the host gave it. Click
  **inspect**. This is the path that reads `/json/list` off the server, so a target that does not appear
  here is a discovery-document problem rather than a protocol one.
- **`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:9229/devtools/page/<targetId>`**
  — paste it into the address bar of a Chrome tab. `v8only=true` is what makes the front end open its
  JavaScript-only layout rather than asking for a page that does not exist. Chrome refuses to navigate to
  `devtools://` from a link or from automation, so this one has to be typed.

## What each panel should show

| Panel | What answers, and what it proves |
| --- | --- |
| **Sources** → navigator | Every script the engine has parsed, by the name the host passed to `Execute`/`Evaluate` — except that a name which *is* an absolute filesystem path appears as a `file://` URL, so the tree groups it under a domain and names it by its file name. An empty tree means `Debugger.enable` was refused — check the engine was built with `UseDevTools()`. |
| **Sources** → editor | The script's own text, from `Debugger.getScriptSource`. Text that reads `// source not available` means `Options.RetainFunctionSourceText` is off. |
| **Sources** → gutter | Clicking a line number sets a breakpoint, and the marker snaps to the nearest position the engine will stop at. A marker that lands on a blank line or a comment is a `getPossibleBreakpoints` defect. |
| **Sources** → paused | Run something that reaches the line. Execution stops, the line highlights, and the **Call Stack**, **Scope** and **Watch** panes fill. The Scope pane is a snapshot: it lists the bindings but never calls a getter, and a binding still in its temporal dead zone is absent rather than `undefined`. |
| **Sources** → step and resume | The three step buttons and Resume all move the engine on. While it is paused the host process is held on the thread that owns the engine, which is why `DevToolsServerOptions.PauseTimeout` exists. |
| **Sources** → pause on exceptions | The ⏸ button stops the engine **at** the throw, before anything unwinds, with the thrown value in the sidebar; both "uncaught" and "caught" work. Two divergences from Chrome are the engine's rather than this package's: a throw crossing an `async` function's body boundary counts as uncaught whatever the caller wrapped it in, and `Promise.reject(value)` stops nothing because nothing was thrown. Filtering a throw out in `caught` mode also cancels a step that was in flight — [#3631](https://github.com/sebastienros/jint/issues/3631). |
| **Console** | What the script logged, as it logged it: an object arrives as an expandable preview rather than as a string, and expanding it asks the server for the properties. Every line carries an `app.js:NN` anchor on the right, and an error expands into its frames. Nothing here runs script that the script did not run. |
| **Console** → a logged function | Expanding a function — in the Console or in the Scope pane — shows a `[[FunctionLocation]]` row whose `app.js:NN` link opens the editor at the declaration. A function with no such row is either a built-in, which has no declaration, or a script function whose source name no registered script claims. |
| **Console** → prompt | Typing an expression evaluates it in the engine — including while it is paused, where it evaluates in the selected call frame. The eager-evaluation preview above the prompt stays empty on purpose: it asks for `throwOnSideEffect`, which this server refuses rather than answers by running the code the front end asked not to run. |
| **Performance** | **Start profiling and reload** does not apply; use the record button. The profile is the engine's own sampler, so the flame chart is JavaScript frames and nothing else. |
| **Coverage** | Answers only if the engine was built with `UseDevTools(o => o.Coverage = true)`; the REPL's `--inspect` turns it on. A function the script never called is listed with a zero count rather than being absent. |
| **Memory** | Nothing. There is no `HeapProfiler` here and there deliberately never will be; the heap is the CLR's, and what a host wants instead is `engine.Diagnostics` and `Options.Constraints`. |

## What is expected to be missing

Not defects, and not worth reporting again: **Elements**, **Network**, **Application** and **Lighthouse**
have no page behind them — that is what `Jint.Browser` is for — and the front end shows them empty rather
than erroring. **Async call stacks** are absent because the engine retains no stack across a promise
reaction, and **blackboxing** and **source maps** are accepted and ignored. Editing a paused program
(**Save**, "Restart frame") is refused: the interpreter is standing inside an abstract syntax tree it has
already walked into. `Jint.DevTools/AGENTS.md` carries the full list and the reason for each.

---

### Architecture Execution Model (`docs/architecture/execution-model.md`)

# Execution Model

Jint is a tree-walking interpreter. It does not emit bytecode or use the Dynamic Language Runtime.

## From source to execution

1. Acornima parses JavaScript into an ECMAScript abstract syntax tree.
2. Jint prepares reusable, engine-neutral analysis for that tree.
3. Expression and statement handlers interpret the nodes.
4. Runtime objects implement ECMAScript realms, environments, values, intrinsics, and jobs.
5. Host APIs convert values and expose explicitly configured .NET capabilities.

`Execute` runs a script and returns the engine for chaining. `Evaluate` runs an expression or script and returns
its resulting `JsValue`. `Invoke` converts CLR arguments and calls a JavaScript function.

## Prepared code

```csharp
var script = Engine.PrepareScript(
    "function square(x) { return x * x; }",
    source: "math.js",
    strict: true);

var engine = new Engine();
engine.Execute(in script);
```

`Prepared<Script>` and `Prepared<Module>` can be cached and shared across engines. They contain no engine-owned
objects. Runtime caches and values remain attached to the engine executing them.

## One engine, one active operation

An engine is not thread-safe and admits one operation at a time. Host callbacks can re-enter synchronously on the
owning thread. Work arriving from another thread must be queued through the engine task API and executed by the
thread pumping that engine.

Promises, timers, module continuations, and browser work all use the same queued-work model. Jint schedules jobs
but never starts a thread merely to run script.

---

### Design Async Context (`docs/design/async-context.md`)

# AsyncContext in Jint — design

**Status: design only. No implementation exists, and none should land before the maturity decision in
[§6.3](#6-3-proposal-maturity) is taken.**

This document maps the TC39 [AsyncContext proposal](https://tc39.es/proposal-async-context/) onto Jint's async
machinery: where the agent-level state lives, what the mapping looks like, every point at which the mapping is
captured or restored, what it costs an engine that never uses it, how a .NET host participates, and what has to
be true before any of it is worth merging.

Everything normative here was read from the proposal's own specification source
(`tc39/proposal-async-context@master`, `spec.html`) and from
[`WEB-INTEGRATION.md`](https://github.com/tc39/proposal-async-context/blob/master/WEB-INTEGRATION.md), not from
secondary documentation. Jint line references are against `upstream/main` at
`46615b663` ("Add opt-in WHATWG URL and URLSearchParams (#3094)").

---

## 1. What the proposal is, and why an embedder would want it

`AsyncContext` is implicit, flow-following state: a value set before an asynchronous operation starts is still
readable inside the callback that eventually runs, without threading a parameter through every frame in
between. It is the standardisation of Node's `AsyncLocalStorage`.

```js
const requestId = new AsyncContext.Variable({ name: "requestId", defaultValue: null });

requestId.run("req-42", () => {
  setTimeout(() => {
    console.log(requestId.get());   // "req-42" — a different event-loop turn entirely
  }, 100);
});

console.log(requestId.get());       // null — the mapping is restored when run() returns
```

The surface is two classes on one namespace object
([`#sec-asynccontext-object`](https://tc39.es/proposal-async-context/#sec-asynccontext-object)):

| Member | Spec anchor |
| --- | --- |
| `new AsyncContext.Variable({ name, defaultValue })` | [`#sec-asynccontext-variable`](https://tc39.es/proposal-async-context/#sec-asynccontext-variable) |
| `AsyncContext.Variable.prototype.run(value, func, ...args)` | [`#sec-asynccontext-variable.prototype.run`](https://tc39.es/proposal-async-context/#sec-asynccontext-variable.prototype.run) |
| `AsyncContext.Variable.prototype.get()` | [`#sec-asynccontext-variable.prototype.get`](https://tc39.es/proposal-async-context/#sec-asynccontext-variable.prototype.get) |
| `get AsyncContext.Variable.prototype.name` | [`#sec-asynccontext-variable.prototype.name`](https://tc39.es/proposal-async-context/#sec-asynccontext-variable.prototype.name) |
| `new AsyncContext.Snapshot()` | [`#sec-asynccontext-snapshot`](https://tc39.es/proposal-async-context/#sec-asynccontext-snapshot) |
| `AsyncContext.Snapshot.prototype.run(func, ...args)` | [`#sec-asynccontext-snapshot.prototype.run`](https://tc39.es/proposal-async-context/#sec-asynccontext-snapshot.prototype.run) |
| `AsyncContext.Snapshot.wrap(fn)` | [`#sec-asynccontext-snapshot.wrap`](https://tc39.es/proposal-async-context/#sec-asynccontext-snapshot.wrap) |

**Why an embedder cares.** Jint's answer to "which request is this engine serving" is already good:
`Engine.HostDefined` (`Jint/Engine.Globals.cs`) holds the principal realm's `[[HostDefined]]`, and
a pooled engine keeps it across a `RestoreGlobalSnapshot`. What it cannot answer is "which *logical operation
within* this request is running right now", because a pooled engine serving one request may still interleave
several script-initiated flows across event-loop turns — a `setTimeout` callback, a promise reaction, a resumed
`await`. A host `ConsoleSink` (`Jint/WebApi/ConsoleSink.cs`) writing a correlation id today can only read the
per-engine `HostDefined`; with AsyncContext it can read the per-*flow* value that the script — or the host —
installed. That is the same motivation the campaign issue records, and it is the reason the host-facing surface
in [§4](#4-host-facing-surface) is part of the design rather than an afterthought.

---

## 2. Spec mapping

### 2.1 Where the agent slot lives

The proposal adds one field to the **Agent Record**
([`#sec-agents`](https://tc39.es/proposal-async-context/#sec-agents)):

> `[[AsyncContextMapping]]` — an Async Context Mapping — "A map from the AsyncContext.Variable instances to the
> saved ECMAScript language value. The map is initially empty."

Jint already models the Agent Record, and it already stores exactly one other Agent Record field there:

```csharp
// Jint/Agent.cs
/// <summary>
/// https://tc39.es/ecma262/#sec-agents , still a work in progress, mostly placeholder
/// </summary>
internal sealed class Agent
{
    private readonly List<JsValue> _keptAlive = new();   // the Agent Record's [[KeptAlive]]
    ...
}
```

`Engine._agent` is a readonly field, one `Agent` per `Engine`, reached through `Engine.AddToKeptObjects`
(`Jint/Engine.cs:1044`) and `Engine.ClearKeptObjects` (`Jint/Engine.cs:1744`). So the mapping belongs on
`Jint.Agent`, next to `[[KeptAlive]]`, and not on a new `Engine` field:

```csharp
internal sealed class Agent
{
    /// <summary>
    /// The Agent Record's [[AsyncContextMapping]]. <see langword="null"/> *is* the empty mapping — see
    /// AsyncContextMapping — which is what every engine carries until script first calls
    /// AsyncContext.Variable.prototype.run, and what it goes back to as soon as that call unwinds.
    /// </summary>
    internal AsyncContextMapping? AsyncContextMapping;
}
```

One `Engine` is one agent. `ShadowRealmConstructor` calls `_engine._host.CreateRealm()`
(`Jint/Native/ShadowRealm/ShadowRealmConstructor.cs:38`) — a new Realm Record inside the *same* Engine — so a
shadow realm shares the mapping, which is what the spec requires and is analysed in [§6.2](#6-2-shadowrealm).

The engine-level accessors are two `AggressiveInlining` methods on `Engine`, so that no call site outside
`Jint/Native/AsyncContext/` ever touches `_agent` directly:

```csharp
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal AsyncContextMapping? AsyncContextSnapshot() => _agent.AsyncContextMapping;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal AsyncContextMapping? AsyncContextSwap(AsyncContextMapping? mapping)
{
    var agent = _agent;
    var previous = agent.AsyncContextMapping;
    agent.AsyncContextMapping = mapping;
    return previous;
}
```

Those are literally
[`AsyncContextSnapshot()`](https://tc39.es/proposal-async-context/#sec-asynccontextsnapshot) and
[`AsyncContextSwap(snapshotMapping)`](https://tc39.es/proposal-async-context/#sec-asynccontextswap), two steps
each, so keeping the spec names is free and makes every call site checkable against the algorithm it implements
(the **Spec references** convention in `AGENTS.md`).

### 2.2 The mapping representation: copy-on-write array, `null` for empty

The spec type is a *List* of records with unique keys
([`#sec-asynccontext-mapping-record-specification-type`](https://tc39.es/proposal-async-context/#sec-asynccontext-mapping-record-specification-type)),
and `Variable.prototype.run` builds a **new** list on every call — steps 4-7 copy every record whose key is not
this variable, then append one:

> 4. Let _asyncContextMapping_ be a new empty Async Context Mapping.
> 5. For each Async Context Mapping Record _p_ of _previousContextMapping_, do
>    1. If SameValueZero(_p_.[[AsyncContextKey]], _asyncVariable_) is *false*, then … Append _q_ to _asyncContextMapping_.
> 6. Let _p_ be the Async Context Mapping Record { [[AsyncContextKey]]: _asyncVariable_, [[AsyncContextValue]]: _value_ }.
> 7. Append _p_ to _asyncContextMapping_.

Copy-on-write over a flat array *is* that algorithm, and the expected size supports it. The champions'
[`MEMORY-MANAGEMENT.md`](https://github.com/tc39/proposal-async-context/blob/master/MEMORY-MANAGEMENT.md) says a
context is expected to hold "a very limited number of entries (a single-digit amount in most cases)". A
persistent hash-array-mapped trie only starts to pay for itself well above that, and below it costs an
allocation per node plus pointer-chasing on a lookup that a linear scan of four to eight references answers from
one cache line.

```csharp
/// <summary>
/// An Async Context Mapping: https://tc39.es/proposal-async-context/#async-context-mapping
/// Immutable — every mutation produces a new instance, which is what makes a captured mapping a snapshot
/// rather than a live view.
/// </summary>
internal sealed class AsyncContextMapping
{
    private readonly AsyncContextEntry[] _entries;   // never empty: the empty mapping is represented by null

    internal static AsyncContextMapping With(AsyncContextMapping? mapping, JsAsyncContextVariable key, JsValue value);
    internal bool TryGet(JsAsyncContextVariable key, out JsValue value);
}

[StructLayout(LayoutKind.Auto)]
internal readonly record struct AsyncContextEntry(JsAsyncContextVariable Key, JsValue Value);
```

Three properties this shape gives us, each load-bearing later:

- **`null` is the empty mapping.** Nothing allocates an empty mapping object; an engine that never touched
  AsyncContext holds `null` on the agent, in every captured field, and in every job. This is the same disarm
  shape as `Engine._atomicsWaiterDeadlines` (`Jint/Engine.Pump.cs:26`) and `Engine._webApi`
  (`Jint/Engine.WebApi.cs:17`), and for the same reason: the field being null is the whole cost of the check.
- **Keys compare by reference.** `SameValueZero` on an `AsyncContext.Variable` instance — always an object — is
  reference equality, so a lookup is `ReferenceEquals` over an array with no hashing and no `JsValue`
  comparison semantics involved.
- **A snapshot is one reference.** Capturing is a field read; there is no copy, no defensive clone, and no
  lifetime concern beyond the retention analysed in [§6.4](#6-4-retention).

`Variable.prototype.get` on a `null` mapping is one null test and a return of
`[[AsyncVariableDefaultValue]]`.

### 2.3 The swap: a null-mapping fast path, and no `try`/`finally` on it

Every restore point has the same shape, and the shape matters more than it looks. Wrapping the reaction-job body
in an unconditional `try`/`finally` would put a protected region on Jint's hottest asynchronous path for the
benefit of a feature almost no engine uses. The fix is to split the arm:

```csharp
// PromiseOperations.RunReactionJob
var mapping = reaction.AsyncContextMapping;
if (mapping is null && engine.AsyncContextSnapshot() is null)
{
    RunReactionJobCore(engine, reaction, value);      // byte-identical to today's body, no protected region
    return;
}

RunReactionJobWithAsyncContext(engine, reaction, value, mapping);   // [MethodImpl(NoInlining)], has the try/finally
```

Two predictable null tests on an engine that never uses the feature; the slow arm is out of line so it does not
inflate the caller's frame or inhibit inlining. The same two-arm split applies at each of the restore points in
[§3](#3-capture-and-restore-inventory).

The restore must be a **`finally`**, not a `catch`, and not a post-call statement. The spec expresses this as
`Completion(Call(...))` followed by an unconditional `AsyncContextSwap` — an abrupt completion still swaps back.
Jint has exception classes the spec does not model and which must not be allowed to leave a mapping installed:
`TimeoutException`, `ExecutionCanceledException`, `RecursionDepthOverflowException`, `MemoryLimitExceededException`,
and `PromiseRejectedException`. Note also that `RunReactionJob` already *swallows* `JavaScriptException` on two
of its three arms (`Jint/Native/Promise/PromiseOperations.cs:44-49,60-64`); the swap-back has to happen for the
swallowed case too, which a `finally` gives for free and a `catch` would not.

### 2.4 The one-way arming question, and why we do not need a flag

An obvious optimisation is a one-way `Engine._asyncContextArmed` bool, set the first time a `Variable` is
constructed, so that capture sites can skip their field read entirely. It is not worth it: the capture is a
single load of a field that is already in cache next to `[[KeptAlive]]`, and the flag would buy nothing at the
restore sites, which must test the reaction's own field regardless — a job created while a mapping was installed
must restore it even though the agent field has since gone back to `null`.

Where "only pay when it is in use" *does* buy something real is the storage question in
[§7.2](#7-2-the-eight-bytes-on-promisereaction), and there the lever is allocation shape rather than a flag.

---

## 3. Capture and restore inventory

This is the exhaustive list. Each row names the spec clause, the Jint site, and whether the point is a capture
(snapshot taken and stored) or a restore (swap in, run, swap back).

### 3.1 Points the spec defines

| # | Spec clause | Capture / restore | Jint site |
| --- | --- | --- | --- |
| 1 | [`PerformPromiseThen`](https://tc39.es/proposal-async-context/#sec-performpromisethen) step *"Let mapping be AsyncContextSnapshot()"* | capture, onto both reaction records | `PromiseOperations.PerformPromiseThen` — **both** overloads, `Jint/Native/Promise/PromiseOperations.cs:136` and `:195` |
| 2 | [`NewPromiseReactionJob`](https://tc39.es/proposal-async-context/#sec-newpromisereactionjob) step *"Let previousContextMapping be AsyncContextSwap(reaction.[[PromiseAsyncContextMapping]])"* | restore | `PromiseOperations.RunReactionJob`, `Jint/Native/Promise/PromiseOperations.cs:33` |
| 3 | [`NewPromiseResolveThenableJob`](https://tc39.es/proposal-async-context/#sec-newpromiseresolvethenablejob) | capture at job creation, restore inside the job | `PromiseOperations.NewPromiseResolveThenableJob`, `Jint/Native/Promise/PromiseOperations.cs:99` — the returned `Action` closes over the mapping |
| 4 | [`GeneratorStart`](https://tc39.es/proposal-async-context/#sec-generatorstart) | capture onto the generator | `GeneratorInstance.GeneratorStart`, `Jint/Native/Generator/GeneratorInstance.cs:128` |
| 5 | [`GeneratorResume`](https://tc39.es/proposal-async-context/#sec-generatorresume) / [`GeneratorResumeAbrupt`](https://tc39.es/proposal-async-context/#sec-generatorresumeabrupt) | restore | `GeneratorInstance.GeneratorResume` `:142`, `GeneratorResumeAbrupt` `:170` |
| 6 | [`AsyncGeneratorStart`](https://tc39.es/proposal-async-context/#sec-asyncgeneratorstart) / [`AsyncGeneratorResume`](https://tc39.es/proposal-async-context/#sec-asyncgeneratorresume) | capture / restore | `Jint/Native/AsyncGenerator/AsyncGeneratorInstance.cs` |
| 7 | [`YieldExpression` evaluation](https://tc39.es/proposal-async-context/#sec-generator-function-definitions-runtime-semantics-evaluation) | restore the generator's own mapping after the yield returns | **Nothing needed in Jint** — see [§3.4](#3-4-jint-shapes-that-change-the-answer) |
| 8 | [`ExecuteModule`](https://tc39.es/proposal-async-context/#sec-source-text-module-record-execute-module) — *"Let moduleContextMapping be a new empty list"* | swap to the **empty** mapping for the whole evaluation | `SourceTextModuleRecord.ExecuteModule`, `Jint/Runtime/Modules/SourceTextModuleRecord.cs:396` |
| 9 | [`FinalizationRegistry ( cleanupCallback )`](https://tc39.es/proposal-async-context/#sec-finalization-registry-cleanup-callback) — *"Set finalizationRegistry.[[FinalizationRegistryAsyncContextMapping]] to AsyncContextSnapshot()"* | capture at construction | `FinalizationRegistryInstance` ctor, `Jint/Native/FinalizationRegistry/FinalizationRegistryInstance.cs:16` — **blocked, see [§3.3](#3-3-finalizationregistry-is-blocked-in-jint)** |
| 10 | [`HostEnqueueFinalizationRegistryCleanupJob`](https://tc39.es/proposal-async-context/#sec-host-cleanup-finalization-registry) | restore the construction-time mapping around the cleanup | same file, `Observer` finalizer at `:64` — **blocked** |
| 11 | [`HostPromiseRejectionTracker`](https://tc39.es/proposal-async-context/#sec-host-promise-rejection-tracker) requirements list | snapshot at the call, swap around the notification | `Host.HostPromiseRejectionTracker` `Jint/Runtime/Host.cs:473` → `Engine.OnPromiseRejectionTracker`. Host territory; see [§4.4](#4-4-hostpromiserejectiontracker) |
| 12 | The API itself: `Variable.run`, `Snapshot` ctor, `Snapshot.prototype.run`, `Snapshot.wrap` | capture and restore | new `Jint/Native/AsyncContext/` |

**Row 9 corrects a widespread misreading.** Earlier accounts of the proposal — and more than one summary still
in circulation — say the finalization-registry cleanup callback runs under an *empty* mapping. The current draft
does not: it captures at construction and restores that.

> 1. Let _previousContextMapping_ be AsyncContextSwap(_finalizationRegistry_.[[FinalizationRegistryAsyncContextMapping]]).
> 1. Let _cleanupResult_ be Completion(CleanupFinalizationRegistry(_finalizationRegistry_)).

Empty-mapping semantics appear exactly once in the proposal, and it is **module evaluation** (row 8), not the
finalization registry.

**Row 1 is the whole `await` story.** Jint implements `await` through the engine-internal
`PerformPromiseThen(Engine, JsPromise, IPromiseContinuation)` overload
(`Jint/Native/Promise/PromiseOperations.cs:195`), which is the spec's `Await` steps 3-10 with the observable
handler closures elided. Because the mapping rides on the `PromiseReaction` record and `RunReactionJob` swaps it
in around `continuation.Invoke(...)`, the async function resumes under the mapping that was current at its
`await` — with no change at all to `JintAwaitExpression`, `AsyncFunctionInstance` or `AsyncFunctionResume`. That
also composes for a function with several awaits: the reaction job installs the mapping, `AsyncFunctionResume`
(`Jint/Runtime/Interpreter/Expressions/JintAwaitExpression.cs:218`) runs the body forward under it, the next
`await`'s `PerformPromiseThen` captures the then-current mapping, and the job's `finally` swaps back.

`AsyncFunctionStart` / `AsyncBlockStart` (`Jint/Runtime/Interpreter/JintFunctionDefinition.cs:241,281`) need
nothing: the proposal does not patch them, so the synchronous prefix of an async function inherits the caller's
mapping, which is what running it on the caller's stack already gives.

### 3.2 Points the spec deliberately does **not** define

Getting these wrong is worse than missing a capture, because an unwanted capture silently pins state and
produces plausible-but-wrong values.

- **`addEventListener` / `dispatchEvent` do not touch the mapping.** From
  [`WEB-INTEGRATION.md`](https://github.com/tc39/proposal-async-context/blob/master/WEB-INTEGRATION.md), verbatim:

  > `.addEventListener` does _not_ take any snapshot, it just stores the callback as it's already doing today;
  > `.dispatchEvent` does _not_ read or set the current active context.

  For synchronous dispatch "the context that will be active when the event listeners are called is the same as
  the one active when the event is dispatched", which falls out of doing nothing. So the `JsEventTarget` work
  in the still-open events PR (#3096) must add **no** AsyncContext code. Where the web platform wants the
  originating context available it exposes it explicitly as an `originSnapshot` property on the event, which is
  a per-event-type decision and out of scope here. This is worth a comment in the events code so that a later
  agent does not "fix" the omission.

- **`HostEnqueuePromiseJob` does not capture.** The spec captures per *scheduler call site*
  (`PerformPromiseThen`, `NewPromiseResolveThenableJob`), never generically at enqueue.

- **Therefore `Engine.AddToEventLoop(Action)` must not capture.** The generic `Action` job stays
  mapping-transparent. `Jint/Engine.cs:995` is used both by spec-driven paths that capture at their own site and by
  engine-internal bookkeeping that must stay transparent; a capture there would be wrong for the second group
  and redundant for the first. **`EventLoopJob` and `EventLoop` are unchanged by this design** — nothing is
  added to the struct, the queue, the generation fence or `RunAvailableContinuations`. A caller that needs a
  mapping carried closes over it in its own delegate, exactly as `NewPromiseResolveThenableJob` does. That is a
  property worth protecting: the event loop is shared by every engine and this feature should not appear in it.

- **`CreateResolvingFunctions`, the `Promise` executor, and `Promise.prototype.finally`** are not patched. The
  executor runs synchronously and inherits the ambient mapping; `finally` is defined in terms of `then` and
  inherits row 1.

### 3.3 FinalizationRegistry is blocked in Jint

Rows 9 and 10 cannot be implemented as specified, and the reason is pre-existing:

```csharp
// Jint/Native/FinalizationRegistry/FinalizationRegistryInstance.cs:64
~Observer()
{
    try { _callable.Callback.Call(Undefined); }
    catch { /* ... */ }
}
```

Jint runs the cleanup callback from the **CLR finalizer thread**, directly in a finalizer, rather than as a job
on the event loop; `CleanupFinalizationRegistry` at `:22` is an empty stub. Writing
`_agent.AsyncContextMapping` from that thread would race the engine thread with no synchronisation, and the
`finally` that restores it would race the engine thread's own restore — a mapping torn in half is exactly the
class of bug that would be undebuggable.

So the design's position is: **implement rows 9 and 10 only once FinalizationRegistry cleanup runs as an
event-loop job on the engine thread**, which is a separate correctness fix worth doing on its own merits. When
it is done, the capture has an obvious home — `JobCallback` (`Jint/Runtime/Host.cs:492`) already carries an
`object? HostDefined` slot, and `Host.MakeJobCallBack` (`:461`) is already the single construction point. Until
then the registry simply does not participate, which is a documented gap and not a silent one.

> **Since written:** both halves of that precondition exist. Cleanup runs as an event-loop job on the engine
> thread (`Host.HostEnqueueFinalizationRegistryCleanupJob`), and HostMakeJobCallback / HostCallJobCallback are
> implemented for every promise reaction, every thenable job and the registry's `[[CleanupCallback]]`, with a
> public host side — `JobCallbackHooks`, installed through `Options.Host.JobCallbacks`
> ([guide](../guide/async.md#carry-host-state-across-await)). Rows 1-3 and 9-10 are exactly the sites that seam
> already visits, so phase 1 can capture its mapping there rather than finding the sites again; the
> `PromiseReaction` storage question of [§7.2](#7-2-the-eight-bytes-on-promisereaction) was answered the same way
> for the hooks — a wrapper continuation only when something was captured, nothing added to the record.

### 3.4 Jint shapes that change the answer

Four places where Jint's implementation strategy makes the naive translation wrong.

**(a) `yield` needs no swap of its own.** The spec's `YieldExpression` steps snapshot before `Yield(...)` and
swap back after, because in the specification's model the generator's execution context is *suspended in place*
and resumed in place, so the resumer's mapping would still be installed when control returns into the body.
Jint's generators are replay-driven: control re-enters the body from `GeneratorResume` /
`GeneratorResumeAbrupt` (`Jint/Native/Generator/GeneratorInstance.cs:142,170`), which are row 5's restore
points. The swap there already covers "the body runs under the generator's mapping", so
`JintYieldExpression` (`Jint/Runtime/Interpreter/Expressions/JintYieldExpression.cs`) needs nothing. **This
equivalence must be pinned by a test rather than asserted** — see [§8](#8-test-strategy), case G3.

**(b) Jint has extra event-loop hops with no spec counterpart, and each one drops the mapping.** These are
places where Jint enqueues a *continuation of an ongoing spec algorithm* through the raw `AddToEventLoop(Action)`
path. The reaction job that scheduled them has already restored the previous mapping by the time the queued
delegate runs, so without an explicit carry the flow is silently severed mid-algorithm:

| Site | What it continues |
| --- | --- |
| `Jint/Runtime/Interpreter/Statements/JintForInForOfStatement.cs:1227` | `for await` resumption inside an async generator — `ForAwaitGeneratorContinuation.Invoke` runs inside a reaction job and then re-enqueues |
| `Jint/Native/Array/ArrayConstructor.cs:451`, `:476` | `Array.fromAsync` loop step after an element resolves |
| `Jint/Native/Array/ArrayConstructor.cs:350`, `:624`, `:640` | the same, for the async-iterator and array-like arms |

The remedy is one internal helper, used only at these sites, that closes over the mapping current at enqueue:

```csharp
// Engine
internal void AddToEventLoopPreservingAsyncContext(Action continuation);
```

It must not be spelled as an overload of `AddToEventLoop` and must not become the default, for the reason in
[§3.2](#3-2-points-the-spec-deliberately-does-not-define). Each use site gets a comment naming the spec algorithm
whose continuation it is.

`Atomics.waitAsync`'s settle paths (`Jint/Native/Atomics/AtomicsInstance.cs:346,815`) are deliberately **not**
on this list: they resolve a promise, and the reactions attached to that promise carry their own mappings from
row 1. Resolving a promise is not a capture point in the spec and must not become one here.

**(c) The replay interpreter can run `run()` twice.** `JintStatementList.Execute` resumes at a saved statement
index (`Jint/Runtime/Interpreter/JintStatementList.cs:139`) and **re-executes that whole statement**; the await
inside it short-circuits to its cached value (`AsyncFunctionInstance._completedAwaits`), but sibling
sub-expressions evaluated before the suspension are evaluated again. So in

```js
async function f() { const x = v.run(1, sideEffect) + await g(); }
```

`sideEffect` runs twice. This is a pre-existing Jint quirk with nothing to do with AsyncContext, but AsyncContext
gives scripts a new and very visible way to trip over it, and a bug report will arrive phrased as an AsyncContext
bug. The swaps themselves stay balanced (each `run` restores in its own `finally`), so the *mapping* cannot be
corrupted by the replay — only the user callback is re-entered. Document it; do not attempt to fix it here.

**(d) `run` must not be a tail-call site.** `AsyncContext.Variable.prototype.run` and
`Snapshot.prototype.run` invoke a user function and must regain control to restore. They are built-in
(`ClrFunction`) frames, so `ScriptFunction.ContinueTailCalls` returns through them normally, but the
implementation must not be "cleverly" routed through the tail-call trampoline.

---

## 4. Host-facing surface

The proposal anticipates hosts needing this. `CreateAsyncContextSnapshotObject`
([`#sec-createasynccontextsnapshotobject`](https://tc39.es/proposal-async-context/#sec-createasynccontextsnapshotobject))
exists solely for them:

> This abstract operation is meant for hosts to use, and it is not used in this specification.

### 4.1 The minimal pair

Two members on `Engine.AdvancedOperations`, modelled on the existing
`WithRestoredGlobals(GlobalSnapshot, Action)` shape in `Jint/Engine.GlobalSnapshot.cs` — which restores in a
`finally` and documents that "skipping it on the throwing path is precisely the mistake this method exists to
prevent":

```csharp
/// <summary>
/// Captures the engine's current async context — the Agent Record's [[AsyncContextMapping]] — so that a
/// host-initiated callback can later be run under it.
/// </summary>
public AsyncContextSnapshot CaptureAsyncContext();

/// <summary>
/// Runs <paramref name="action"/> with <paramref name="snapshot"/> installed as the engine's async context,
/// restoring the previous one afterwards — including when <paramref name="action"/> throws.
/// </summary>
public void RunWithAsyncContext(AsyncContextSnapshot snapshot, Action action);
public T RunWithAsyncContext<T>(AsyncContextSnapshot snapshot, Func<T> func);
```

`AsyncContextSnapshot` is a `public readonly struct` wrapping the internal `AsyncContextMapping?` — opaque, with
no members beyond equality, so the internal representation stays free to change. `default(AsyncContextSnapshot)`
is the empty mapping, which makes "run this callback in a clean context" spellable without capturing first.

The motivating shape, which is what an embedder actually writes:

```csharp
// script registered a callback while inside requestId.run("req-42", ...)
var snapshot = engine.Advanced.CaptureAsyncContext();

// ... later, from the host's own loop, in a different turn
engine.Advanced.RunWithAsyncContext(snapshot, () => callback.Call(JsValue.Undefined, args));
```

Without this, a host that invokes a stored JS callback itself — the single most common embedding shape, and the
one `Jint.Tests.PublicInterface/HostCallLoopConstraintTests.cs` already documents for constraints — runs it
under whatever mapping happens to be installed, which is the empty one. AsyncContext's whole value proposition
fails at exactly the boundary embedders live on.

### 4.2 Materialising a snapshot for script

For a host that wants to hand the captured context *to script* — so the script can `snapshot.run(...)` itself —
the spec's host operation maps to one more member:

```csharp
/// <summary>
/// https://tc39.es/proposal-async-context/#sec-createasynccontextsnapshotobject
/// </summary>
public JsValue CreateAsyncContextSnapshotObject(AsyncContextSnapshot snapshot);
```

This is the only member that materialises an intrinsic, so it is also the only one that would force
`%AsyncContext.Snapshot%` to be built on an engine that never mentioned `AsyncContext`. Keep it in a later
phase; the pair in §4.1 covers the correlation use case on its own.

### 4.3 Relationship to `HostDefined`

They answer different questions and compose rather than compete:

| | `Engine.HostDefined` | AsyncContext |
| --- | --- | --- |
| Scope | per **engine** (principal realm's `[[HostDefined]]`) | per **flow**, within one engine |
| Set by | the host, in CLR | the script, or the host through §4.1 |
| Survives `RestoreGlobalSnapshot` | yes, deliberately | no — reset, see [§6.1](#6-1-restoreglobalsnapshot-and-generations) |
| Readable from | any CLR code holding the `Engine` | script through `Variable.get()`; CLR through §4.1 |

A `ConsoleSink` correlating log lines reads the request identity from `HostDefined` and the operation identity
from an AsyncContext variable the host installed. Neither replaces the other.

### 4.4 `HostPromiseRejectionTracker`

The spec states the requirement as three obligations on the host rather than as algorithm steps:

> - It must perform AsyncContextSnapshot() at the call of HostPromiseRejectionTracker,
> - It must perform AsyncContextSwap before the event notification, with the result of the AsyncContextSnapshot operation,
> - It must perform AsyncContextSwap after the event notification, with the result of the earlier AsyncContextSwap operation.

Jint's implementation raises a CLR event (`Engine.OnPromiseRejectionTracker`, reached from
`Jint/Runtime/Host.cs:473`), which is not a queued notification — it fires synchronously at the call, so the
ambient mapping is already the one the spec asks for and the obligation is satisfied by construction. Record
that reasoning in a comment; if the tracker is ever made asynchronous, the obligation becomes real.

### 4.5 What is *not* in the host surface

No `Options` knob. AsyncContext is a language feature, not an opt-in web API: nothing goes in
`Options.WebApi`, `WebApiFeatures` gets no bit, and `Jint/WebApi/WebApiRegistration.cs` is untouched. It is also
**not** TFM-gated — the whole-file `#if NET8_0_OR_GREATER` rule applies to web-API code, and the only file in
this design that carries one is the timers integration, which is inside the already-gated
`Jint/WebApi/Timers/` subtree.

---

## 5. Cost analysis

The rule this design is written to: **an engine that never uses AsyncContext must pay one predictable null test
per affected site and nothing else** — the same bar `Engine._webApi` and `Engine._atomicsWaiterDeadlines` are
held to (`Jint/Engine.Pump.cs:44-59`).

| Site | Cost with no AsyncContext in use | Notes |
| --- | --- | --- |
| `EventLoop.Enqueue` / `EventLoopJob` / `RunAvailableContinuations` | **zero** | untouched by design ([§3.2](#3-2-points-the-spec-deliberately-does-not-define)) |
| `Engine.AddToEventLoop(Action)` | **zero** | untouched |
| `PerformPromiseThen` (both overloads) | 1 field load + 1 field store per reaction | plus the storage question in [§7.2](#7-2-the-eight-bytes-on-promisereaction) |
| `RunReactionJob` | 2 null tests, **no protected region** on the fast arm | the two-arm split in [§2.3](#2-3-the-swap-a-null-mapping-fast-path-and-no-try-finally-on-it) |
| `GeneratorStart` / `GeneratorResume` / `GeneratorResumeAbrupt` | 1 load + 1 store on start; 2 null tests on resume | + 8 B on `GeneratorInstance`, which is not a per-call allocation |
| `AsyncGeneratorStart` / `AsyncGeneratorResume` | as above | |
| `setTimeout` / `setInterval` registration | 1 load + 1 store on `TimerEntry` | +8 B per timer, capped by `MaxActiveTimers` |
| `TimerEntry.Fire` | 2 null tests | `Jint/WebApi/Timers/TimerQueue.cs:300` |
| `queueMicrotask` | 1 load, captured into the existing closure | the closure already exists (`TimerFunctions.cs:180`) |
| `SourceTextModuleRecord.ExecuteModule` | 1 swap pair per module evaluation | not a hot path |
| `Variable.get()` | 1 null test → return `[[AsyncVariableDefaultValue]]` | only reachable if script has a `Variable` |
| Engine construction | **zero** | `Agent` gains a null reference field; nothing is allocated |

**The disarm story.** Unlike the timer queue, which needs an explicit `Clear()`, the mapping *self-disarms*:
every installer restores its predecessor in a `finally`, so once the outermost `run` unwinds the agent field is
`null` again, and the next reaction job takes the fast arm. There is no accumulating state and no "used it once,
pays forever" residue. The one place an explicit reset is still required is the restore fence
([§6.1](#6-1-restoreglobalsnapshot-and-generations)).

**What must be measured before merging.** The reaction-record change is on the promise hot path, so the
implementing PR carries the standing perf gate: a paired A/B (`Jint.Benchmark/measure-paired.ps1`, `gate` mode)
on the promise/async rows plus the full SunSpider and Dromaeo tables. A row is a regression only when its
bootstrap CI excludes zero. No numbers are quoted anywhere in this document: nothing here has been measured yet,
and a design-stage guess quoted once tends to be cited forever.

---

## 6. Risks

### 6.1 `RestoreGlobalSnapshot` and generations

Three interactions, two of which are already handled by machinery that exists.

- **Queued work.** A reaction or job created before a restore carries its mapping, but it is dropped anyway:
  `EventLoop.Clear()` discards what is queued and the generation stamp discards what arrives afterwards
  (`Jint/Runtime/EventLoop.cs:294,358`). No new fence is needed, and none may be added — the existing rule that
  `RestoreGlobalSnapshot` "bumps version counters, it never restores them" is unaffected because the mapping is
  not a counter.
- **The installed mapping.** `Engine.ResetTransientEvaluationState` (`Jint/Engine.GlobalSnapshot.cs:257`) must
  null the agent field, alongside `DiscardAtomicsWaiterDeadlines()` and `_webApi?.ResetTransientState()`. In a
  balanced program the field is already `null` at that point, but a restore can be initiated from inside a
  running job (the method's own comments note a job may call back into host code that restores), and an
  unbalanced mapping surviving into the restored engine is precisely the cross-cycle channel the fence exists to
  prevent.
- **Suspended `EvaluateAsync`.** A settlement loop sitting in its `await` is invisible to
  `_activeEvaluationContext` and to the execution-context depth; only `Engine._pendingAsyncOperations` sees it
  (`Jint/Engine.Async.cs:188`). Nothing about the mapping changes that, but any future guard reading "is a
  mapping installed" as "is the engine busy" would be wrong for the same reason.

### 6.2 ShadowRealm

The mapping is **per agent, not per realm** — confirmed from the Agent Record table, and the proposal has no
per-realm mapping anywhere. Jint's `ShadowRealm` creates a Realm Record inside the same `Engine`
(`Jint/Native/ShadowRealm/ShadowRealmConstructor.cs:38`), so it shares the mapping, which is spec-correct and
requires no work.

It is also not a leak. A shadow realm gets its own `%AsyncContext.Variable%` intrinsic, so its variables are
different object identities from the outer realm's; shadow-realm code cannot name an outer `Variable` and so
cannot read an outer value, even though the entry is sitting in the shared mapping while it runs. The
ShadowRealm callable boundary only lets primitives and (wrapped) functions across, which does not admit a
`Variable` object.

The residual risk is retention, and the champions flag it: a mapping entry keyed by a shadow realm's `Variable`
keeps that realm's objects alive for as long as the mapping is reachable. That is the cross-realm case
`MEMORY-MANAGEMENT.md` cites as the argument for weak keying. See [§6.4](#6-4-retention).

### 6.3 Proposal maturity

**AsyncContext is Stage 2.** Verified against `tc39/proposals@main`'s Stage 2 table (row: *Async Context*,
author Chengzhong Wu, champions Andreu Botella, Chengzhong Wu, Justin Ridgewell) and against the proposal
The proposal's own `Status: Stage 2`. It is not in the Stage 2.7 table.

`AGENTS.md` says proposal built-ins are registered unconditionally, with no per-feature option and no ES-version
gate, and that rule should stay. But it is worth being precise about what Jint has actually been shipping under
it. **Not one of the proposal features summarized in the ECMAScript reference appears in the Stage 2 table** — the whole
table was read, and Jint's set sits at 2.7 or above:

| Jint's shipped proposals | Stage |
| --- | --- |
| Decorators, Decorator Metadata, ShadowRealm, Immutable ArrayBuffers, Import Bytes | 2.7 |
| Await Dictionary (`Promise.allKeyed`), Iterator Chunking, Joint Iteration | 3 |
| Temporal, Explicit Resource Management | 3 / 4 |

AsyncContext would be the **first Stage 2 proposal Jint ships**, and it is an unusually poor candidate to be the
first:

- **It is not additive.** Every other proposal in that list adds built-ins that existing code paths never touch.
  This one patches promise reactions, generator start and resume, async generator start and resume, module
  evaluation and the finalization registry — the deepest and hottest machinery in the engine — and it does so on
  a path every embedder pays for.
- **Stage 2 explicitly does not promise the algorithm is settled.** The capture points are exactly what is still
  being debated — `tc39/proposal-async-context#124` ("Focus on What Hurts Most: AsyncContext for `await` as a
  First Step") argues for shipping the `await` integration first and deferring the rest — and the
  event-listener rules have a competing proposal of their own (`mmocny/proposal-async-event-listeners`). A
  capture point Jint ships and the committee later moves is a silent behaviour change for every embedder.
- **test262 has zero coverage.** Verified two ways: there is no `test/built-ins/AsyncContext` directory in
  test262 `main`, and no `async-context` entry in `features.txt` at Jint's pinned SHA
  (`3655e7464de3d52643ecddd4b5f9f4f3e7f62398`) or in the working checkout. The proposal repository carries a
  TypeScript polyfill (`src/`) and a single mocha file (`tests/async-context.test.ts`), and nothing else. Jint's
  usual tie-breaker — "where the prose and test262 disagree, test262 at the pinned SHA wins" — has nothing to
  say here, so every conformance claim would rest on our own reading of a draft.

**Recommendation.** Do not merge a JS-visible `AsyncContext` global while the proposal is at Stage 2.
Concretely:

1. **Now:** keep this document, and treat the [§3.4](#3-4-jint-shapes-that-change-the-answer) findings as
   independently valuable — the extra event-loop hops and the finalizer-thread FinalizationRegistry are
   pre-existing issues worth their own fixes regardless of whether AsyncContext ever lands.
2. **At Stage 2.7:** land phases 1-4 of [§7](#7-implementation-plan). 2.7 is exactly the gate that fixes this:
   it means the spec text is complete and, in current practice, that test262 tests are written — which is the
   thing Jint conforms *to*.
3. **If the maintainer wants it sooner:** ship it **unconditionally**, per the existing rule, and not behind a
   new per-feature switch. Inventing an ES-version or feature gate for one proposal would create a precedent
   that has to be honoured for every future one and would contradict `AGENTS.md` for a worse reason than the
   thing it is trying to avoid. Accept the maturity risk explicitly rather than hiding it behind a flag.

There is one thing worth doing in *any* of those branches: the mechanism can be built and tested without the
global. Phases 1-3 could land with `%AsyncContext%` created but **not installed on the global object**, reachable
only from `Jint.Tests` through `InternalsVisibleTo`. That gets the risky machinery soaked in CI, keeps every cost
in this document paid and measured, and leaves a one-line change to expose it when the proposal advances. It is
the option this design recommends if there is pressure to start early.

### 6.4 Retention

A mapping strongly holds its keys and values (`MEMORY-MANAGEMENT.md`: values are "strongly held (not weak
references)"). A captured mapping is reachable from a `PromiseReaction`, a `TimerEntry`, a `GeneratorInstance` or
a host `AsyncContextSnapshot`, so anything in it lives at least that long. Two Jint-specific amplifiers:

- **A warmed call site retains its last callee**, and a `ScriptFunction` retains the environment it closed over
  (`JintCallExpression._regCallee`). If that closure was created inside a `run`, nothing about AsyncContext makes
  this worse — but a host debugging a retention report will now find mappings in the graph and needs the
  documentation to say they are bounded per site, not accumulating.
- **A pooled engine keeps its `Agent`.** The mapping field self-disarms, so a pooled engine does not accumulate;
  but a `Snapshot` a host holds in CLR does keep its mapping alive for as long as the host holds it. The
  `AsyncContextSnapshot` doc comment must say so.

Weak keying (which the proposal permits but does not require) is **out of scope**: it would need a
`ConditionalWeakTable`-shaped mapping whose cost lands on every capture, and the hybrid strong-then-weak scheme
the champions sketch is not specified. Revisit only if a real embedder reports a leak.

### 6.5 Host-contract verification

The one invariant that is cheap to check and expensive to debug is *balance*: every job must leave the agent
mapping as it found it. That is a natural fit for the existing gate
(`Jint/Runtime/HostContractVerification.cs`), checked at the call site per `AGENTS.md`:

```csharp
if (HostContractVerification.Enabled)
{
    // in EventLoop.RunAvailableContinuations, around job.Run(engine)
    // assert the agent's mapping reference is unchanged across the job
}
```

Release cost when the switch is off: zero, because the flag is a `static readonly bool` the JIT folds. This is
the only new verifier the design proposes, and it is what makes the two-arm fast path safe to trust.

---

## 7. Implementation plan

PR-sized, each independently reviewable, each rebased on latest `main`, each targeting
`sebastienros/jint` `main`.

### 7.1 Phases

| Phase | Content | Depends on |
| --- | --- | --- |
| **0** | *Prerequisites, independently valuable.* Fix the extra event-loop hops in [§3.4(b)](#3-4-jint-shapes-that-change-the-answer) to be spec-shaped; move FinalizationRegistry cleanup off the finalizer thread onto the event loop ([§3.3](#3-3-finalizationregistry-is-blocked-in-jint)). Neither mentions AsyncContext. | — |
| **1** | `Agent.AsyncContextMapping`, `AsyncContextMapping` + `AsyncContextEntry`, `Engine.AsyncContextSnapshot/Swap`, `Jint/Native/AsyncContext/` (`AsyncContextInstance`, `VariableConstructor`, `VariablePrototype`, `SnapshotConstructor`, `SnapshotPrototype`, `JsAsyncContextVariable`, `JsAsyncContextSnapshot`), `Intrinsics.AsyncContext.cs`. **Promise reaction capture and restore** (rows 1-3). Global registration decided per [§6.3](#6-3-proposal-maturity). | 0 (not strictly) |
| **2** | Generators and async generators (rows 4-7), module evaluation's empty mapping (row 8). | 1 |
| **3** | Web-API integration: `setTimeout`/`setInterval` capture at registration and restore in `TimerEntry.Fire`, `queueMicrotask` capture. Whole-file `#if NET8_0_OR_GREATER` is already in force in that subtree. A comment in the events code recording that `addEventListener` deliberately does not capture. | 1, and #3096 for the events comment |
| **4** | Host surface: `Engine.Advanced.CaptureAsyncContext` / `RunWithAsyncContext`, the `AsyncContextSnapshot` struct, and `Jint.Tests.PublicInterface/HostAsyncContextTests.cs` — the `Host*Tests.cs` family, per the generically-named-file convention. | 1 |
| **5** | `Engine.Advanced.CreateAsyncContextSnapshotObject`, README entry under "ECMAScript proposals (no version yet)", `HostContractVerification` balance check. | 4 |
| **6** | *Deferred:* FinalizationRegistry rows 9-10, once phase 0's second half has landed. | 0 |

Phase 1 carries the perf gate ([§5](#5-cost-analysis)). Phases 2-5 touch no default-engine hot path and need no
benchmark unless they modify a shared file.

### 7.2 The eight bytes on `PromiseReaction`

`PromiseReaction` (`Jint/Native/Promise/PromiseTypes.cs:16`) is an `internal sealed record` with four fields.
Adding `AsyncContextMapping? Mapping` costs 8 bytes on every reaction — two per `.then()`, two per `await`,
allocated on Jint's hottest asynchronous path, paid by every embedder whether or not AsyncContext is used.

The design's default is to **add the field and measure**, because it is by far the clearer code. If the gate
refuses it, the fallback is a subclass:

```csharp
internal record PromiseReaction(...);                                   // unsealed
internal sealed record PromiseReactionWithAsyncContext(..., AsyncContextMapping Mapping) : PromiseReaction(...);
```

allocated only when the ambient mapping is non-null, read with `reaction is PromiseReactionWithAsyncContext c`.
That is zero bytes and one type test when unused, on a path that already performs two type tests
(`reaction.Continuation is { }`, `reaction.Handler is ICallable`). The cost is unsealing the record, which
forfeits nothing measurable here — no virtual member of `PromiseReaction` is called on the hot path — but does
make the type extensible inside the assembly, so it needs a comment saying why.

Decide with the paired benchmark, not by argument.

---

## 8. Test strategy

There is **no test262 coverage to inherit** ([§6.3](#6-3-proposal-maturity)), so every case is hand-written in
`Jint.Tests/Runtime/AsyncContextTests.cs` (plus `Jint.Tests/Runtime/WebApi/AsyncContextTimerTests.cs` under a
whole-file `#if NET8_0_OR_GREATER`, since timers are net8-only), with the host-facing pins in
`Jint.Tests.PublicInterface/HostAsyncContextTests.cs`. The proposal's own
`tests/async-context.test.ts` and its `src/` polyfill are useful as a source of cases and as a reference
implementation to diff behaviour against; they are not a conformance suite.

Per the repository's standing rule, every one of these must be **shown failing against unfixed code**, with the
pre-fix error quoted in the PR.

| # | Case | Bites when |
| --- | --- | --- |
| A1 | `v.run(1, f)`; inside `f`, `v.get() === 1`; after, `v.get() === default` | the swap or the restore is missing |
| A2 | `run` restores after the callback **throws** | the restore is a post-call statement rather than a `finally` |
| A3 | `run` restores after a `TimeoutException` / cancellation unwinds through it | the restore is a `catch (JavaScriptException)` |
| A4 | nested `run` on the same variable; inner shadows, outer survives | `With` mutates instead of copying |
| A5 | `Snapshot.wrap(fn)` called from a different context sees the wrap-time value, and the result carries `"wrapped "` + `fn.name` and `fn.length` per `CopyNameAndLength(wrapped, fn, "wrapped")` | the name/length copy is missing — note the only `CopyNameAndLength` in the tree today is `WrappedFunction`-typed and private (`Jint/Native/ShadowRealm/ShadowRealm.cs:235`), so one has to be generalised |
| P1 | `v.run(1, () => p.then(() => log(v.get())))` logs `1` | row 1 capture missing |
| P2 | value at `.then()` registration wins over the value at settle time | the capture is taken at job dequeue instead of at `PerformPromiseThen` |
| P3 | `await` across two suspensions keeps the value | the reaction-carried mapping does not compose across resumes |
| P4 | thenable resolution (`NewPromiseResolveThenableJob`) keeps the value | row 3 missing |
| P5 | reaction job leaves the agent mapping exactly as it found it (assert via the internal accessor) | the fast/slow arm split leaks |
| G1 | generator body sees its creation-time value across `yield` | row 4/5 missing |
| G2 | a mutation made by the *resumer* between two `next()` calls is not visible inside the generator | the resume swap is missing |
| G3 | **`yield` needs no swap of its own** — a `run` performed by the resumer around `gen.next()` does not leak in | [§3.4(a)](#3-4-jint-shapes-that-change-the-answer)'s equivalence claim; this is the test that proves it rather than asserting it |
| G4 | the same for async generators and `for await` — including the extra-hop site at `JintForInForOfStatement.cs:1227` | [§3.4(b)](#3-4-jint-shapes-that-change-the-answer) |
| M1 | a module body sees the **default** value even when imported from inside a `run` | row 8's empty mapping missing |
| T1 | `setTimeout` callback sees the registration-time value | the timer capture in phase 3 is missing |
| T2 | `setInterval` sees the *registration-time* value on **every** firing, not the value at the firing turn | the entry re-captures on `Reschedule` |
| T3 | `queueMicrotask` sees the call-time value | |
| T4 | a timer registered inside `run`, then `RestoreGlobalSnapshot`, then a pump — nothing fires and no mapping survives | [§6.1](#6-1-restoreglobalsnapshot-and-generations) |
| E1 | **`addEventListener` does not capture**: a listener registered inside a `run` and dispatched outside it sees the *dispatch*-time value | [§3.2](#3-2-points-the-spec-deliberately-does-not-define) — this test exists to stop a future agent "fixing" the omission |
| S1 | a shadow realm's `AsyncContext.Variable` is a different key; outer values are invisible inside | [§6.2](#6-2-shadowrealm) |
| H1 | `Engine.Advanced.CaptureAsyncContext` + `RunWithAsyncContext` round-trips a value into a host-invoked callback | [§4.1](#4-1-the-minimal-pair) |
| H2 | `RunWithAsyncContext` restores when the action throws | |
| H3 | `default(AsyncContextSnapshot)` runs the callback under the empty mapping | |
| H4 | `Options` shared by two engines: mappings are independent | the mapping is stored anywhere but the per-engine `Agent` |
| V1 | with `JINT_HOST_CONTRACT_VERIFICATION=1`, a deliberately unbalanced internal job trips the verifier | [§6.5](#6-5-host-contract-verification) |

Timer cases use `Options.WebApi.Timers.TimeProvider` with a fake provider, as the existing timer tests do, so
none of them are wall-clock flaky.

---

## 9. Open questions

1. **Phase 0 sequencing.** The extra-hop fixes ([§3.4(b)](#3-4-jint-shapes-that-change-the-answer)) are
   observable today only as microtask-ordering detail. Are they worth a standalone PR before there is an
   AsyncContext to motivate them? This design says yes — they are correctness debt either way — but it is the
   maintainer's call.
2. **FinalizationRegistry on the event loop.** Moving cleanup off the CLR finalizer thread is a behaviour change
   with its own risk profile (`CleanupFinalizationRegistry` is currently an empty stub, so today's behaviour is
   already non-conformant in more ways than one). Worth confirming that nobody depends on the current shape.
3. **Whether phase 1 lands with the global installed.** [§6.3](#6-3-proposal-maturity) recommends building the
   mechanism and withholding the global until Stage 2.7; that is a policy decision, not an engineering one.
4. **`PromiseReaction` storage.** Field or subclass ([§7.2](#7-2-the-eight-bytes-on-promisereaction)) — decided
   by the paired benchmark, which has not been run.

---

## Appendix: sources read

**Normative.** `tc39/proposal-async-context@master` `spec.html`, read in full — specifically the clauses
`sec-asynccontext-mapping-record-specification-type`, `sec-agents`, `sec-host-cleanup-finalization-registry`,
`sec-createbuiltinfunction`, `sec-generator-function-definitions-runtime-semantics-evaluation`,
`sec-source-text-module-record-execute-module`, `sec-newpromisereactionjob`,
`sec-newpromiseresolvethenablejob`, `sec-performpromisethen`, `sec-host-promise-rejection-tracker`,
`sec-generatorstart`, `sec-generatorresume`, `sec-generatorresumeabrupt`, `sec-asyncgeneratorstart`,
`sec-asyncgeneratorresume`, `sec-asynccontextsnapshot`, `sec-asynccontextswap`,
`sec-createasynccontextsnapshotobject`, `sec-asynccontext-snapshot`, `sec-asynccontext-snapshot.wrap`,
`sec-asynccontext-snapshot.prototype.run`, `sec-asynccontext-variable`,
`sec-asynccontext-variable.prototype.run`, `sec-asynccontext-variable.prototype.name`,
`sec-asynccontext-variable.prototype.get`, `sec-finalization-registry-cleanup-callback`.

**Informative.** The proposal's `README.md` (stage), `WEB-INTEGRATION.md` (schedulers, event listeners),
`MEMORY-MANAGEMENT.md` (retention, expected mapping size), `src/` (TypeScript polyfill), `tests/` (one mocha
file). `tc39/proposals@main` `README.md` Stage 2 and Stage 2.7 tables.

**Jint.** `Agent.cs`, `Engine.cs`, `Engine.Advanced.cs`, `Engine.Async.cs`, `Engine.GlobalSnapshot.cs`,
`Engine.Pump.cs`, `Engine.WebApi.cs`, `Options.WebApi.cs`, `Runtime/EventLoop.cs`, `Runtime/Host.cs`,
`Native/Promise/*`, `Native/Generator/GeneratorInstance.cs`,
`Native/AsyncFunction/AsyncFunctionInstance.cs`, `Native/FinalizationRegistry/FinalizationRegistryInstance.cs`,
`Native/ShadowRealm/*`, `Native/Array/ArrayConstructor.cs`, `Runtime/Interpreter/JintStatementList.cs`,
`Runtime/Interpreter/Expressions/JintAwaitExpression.cs`,
`Runtime/Interpreter/Statements/JintForInForOfStatement.cs`, `Runtime/Interpreter/JintFunctionDefinition.cs`,
`WebApi/Timers/*`, `AGENTS.md`.

---

### Design Browser Native Capabilities (`docs/design/browser-native-capabilities.md`)

# Browser media, canvas and dialog capabilities

The native tree stores markup without constructing playback, rendering or interaction state. Browser owns
these capabilities on demand. `BrowserMediaState` and `BrowserDialogState` are weakly keyed by the actual
native element. They hold no engine or parser callback. Adoption preserves an element's state; cloning
creates a new identity and therefore starts with initial state.

The pinned AngleSharp integration registered no media decoder or canvas renderer. Its controller-null
setters generally did nothing, `play()` returned undefined, canvas context selection returned null,
and encoding returned an empty string. These were backing stubs, rather than implemented playback or
rendering. The native cutover makes the boundary explicit:

* Canvas context selection returns null and the legacy support query returns false. `toDataURL` and the
  nonstandard `setContext` raise `NotSupportedError`. No external rendering-context object is retained.
  Raising an error instead of returning the previous empty string is a deliberate behavior correction.
* Media starts at `HAVE_NOTHING`, `NETWORK_EMPTY`, paused, duration NaN, zero intrinsic video dimensions,
  and no buffered, seekable or played ranges. Script-written volume, mute, playback rates and requested
  start time survive reads. Mute defaults follow the current unnamespaced `muted` attribute until script
  sets the mute state. Volume/rate changes queue real Jint events. Reverse playback is unsupported.
  `canPlayType` returns the empty string and `play()` returns a promise rejected with `NotSupportedError`.
  No request is made by those queries, setters, context operations or playback refusal.
  An explicit `load()` selects the current null-namespace `src`, or the first source child, and calls
  the existing document resource owner. The selected URL is captured once on the page loop. A lazy
  source observer cancels a direct source on its own `src` changes. Source-child selection queues a
  bounded recheck; unrelated descendants and appended tracks do not cancel the selected request.
  Repeated loads cancel the previous operation and invalidate its queued resource events. Completion
  returns to the loop and checks operation identity, owner document, source and
  base URL before publishing a media error. Receiving bytes still yields `MEDIA_ERR_SRC_NOT_SUPPORTED`
  because no decoder exists; a failed request yields `MEDIA_ERR_NETWORK`. Disposal cancels transport and
  releases the observer. A document with no resource owner explicitly refuses a non-empty request.
  Absence of a source stays `NETWORK_EMPTY`; a present empty, malformed or disallowed source resets the
  previous load and becomes `MEDIA_ERR_SRC_NOT_SUPPORTED`. Known source setup failures use a dedicated
  resource-owner exception; host and constraint failures propagate. Completion checks precede state
  commit, and a failed check leaves coherent empty state after releasing the operation.
* Track lists are stable empty objects; there is no decoder output or invented text track. `addTextTrack`,
  the nonstandard controller and start-date capabilities explicitly refuse. Empty immutable range objects
  are reused within each realm, which differs from HTML's fresh range snapshots but cannot expose stale data.
* Ordinary dialog `show()` and `close()` use the native `open` attribute and a lazy return value. Opening
  fires cancellable `beforetoggle`; closing fires non-cancellable `beforetoggle`; both queue a coalesced
  `toggle` event carrying the actual old/new states. Closing queues a non-bubbling `close` event. These
  events use the Jint dispatcher. The internal ToggleEvent prototype exposes `oldState`, `newState` and
  null `source`; this does not install or claim a global ToggleEvent constructor.
* Dialog focus uses the existing Browser focus model: a light-tree focusable descendant, preferring
  autofocus, or the dialog itself. The existing focus model may decline the dialog itself and has no
  rendering or scroll-container focus delegate. Previous focus is restored only while focus remains
  inside the closing dialog. There is no popover/close-watcher integration, top layer, document blocking
  or inert modal subtree. `showModal()` raises `NotSupportedError` without changing `open`, deliberately
  correcting the old silent attribute-only success. Form submission with `method=dialog` remains outside
  this capability group.

Algorithm references: [HTML media elements](https://html.spec.whatwg.org/multipage/media.html#media-elements),
[canvas](https://html.spec.whatwg.org/multipage/canvas.html#the-canvas-element), and
[dialogs](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element).

## Finite HTML attributes and collections

These helpers read current native attributes and ordinary tree links with one bounded read-work
object. They add no parser element properties and allocate no per-element state for attribute reads.
The legacy context-menu assignment and map collection identity use lazy weak-key Browser tables.

* `contentEditable` uses exact ASCII-insensitive enumerated keywords. Whitespace is not trimmed;
  the getter returns a canonical state, `inherit` removes the attribute on setting, and invalid setters
  raise `SyntaxError`. This deliberately corrects the old whitespace normalization. The editor host
  lookup shares the keyword parser but retains its separate form-control routing exclusions.
  `isContentEditable` follows editing-host/eligible-element ancestry in the ordinary tree. Only HTML
  elements and the actual SVG `svg` and MathML `math` roots are eligible. A document's actual design-mode
  flag makes its direct HTML child an editing host, including in an XML document. Detached nodes and
  shadow descendants do not inherit document editing mode through an owner-document reference.
* `spellcheck` recognizes true/empty and false explicitly; missing or invalid values return the permitted
  headless default false, without ancestor lookup in the IDL getter. No spelling provider is installed,
  so `forceSpellCheck()` raises `NotSupportedError`. `accessKeyLabel` is empty because no shortcut is
  assigned. `translate` inherits yes/empty/no through the ordinary tree and defaults to true; foreign
  ancestors are traversed without interpreting their attributes.
* Automatic `draggable` recognizes HTML images and anchors with a present `href`. Object elements
  currently have no actual image representation in the resource model, so a MIME hint cannot make them
  draggable. Explicit true/false override the automatic result.
* Legacy `contextMenu` retains an explicitly assigned native identity across adoption, without changing
  the raw attribute or copying assignment to clones. Clearing assignment restores lookup of the first
  matching ID in the current owner document; it must be an HTML menu. Empty references return null.
* Input `autocomplete` computes the IDL-exposed autofill value from the complete field categories,
  including hidden anchors, section/address/contact scope and terminal `webauthn`. Missing or invalid
  token sequences return empty. Field and credential spellings are retained as the algorithm requires;
  scope keywords are canonicalized. The setter remains raw attribute reflection. This corrects the
  previous partial token handling without installing an autofill or credential provider.
* `map.images` is a stable existing HTMLCollection view containing only associated HTML `img` nodes in
  the current ordinary tree. A hash-name reference is the nonempty literal suffix after the first `#`,
  without trimming or URL decoding. The first HTML map whose ID or name matches wins. Each read resolves
  this map's two names once before scanning images, so duplicates do not cause a map-tree scan per image.
  The view remains live across changes and adoption and does not cross a shadow boundary.

Sources: [contenteditable and editing](https://html.spec.whatwg.org/multipage/interaction.html#attr-contenteditable),
[translate](https://html.spec.whatwg.org/multipage/dom.html#the-translate-attribute),
[draggable](https://html.spec.whatwg.org/multipage/dnd.html#the-draggable-attribute),
[autofill](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill), and
[hash-name references](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-a-hash-name-reference).

## Remaining legacy HTML members

The pinned AngleSharp 1.8.2 menu/menuitem types and keygen key type are raw attribute reads, with the
binding's existing string conversion. Their native binding retains that behavior rather than inventing
enumerated defaults. The keygen `type` string stays `keygen`. A command reference uses the current owner
document's first matching ID, which must identify an HTML element; an empty reference returns null.
These queries charge actual attribute, character and ordinary-tree work and finish with host checks.

The pinned keygen labels implementation allocates a NodeList but never populates it. Browser preserves
this unavailable legacy association as a distinct stable empty NodeList for each keygen, allocated on
first demand and projected through the existing NodeList wrapper. Adoption preserves the list identity;
cloning creates a separate list. This does not change the modern labelable-element algorithm or imply
that key generation or legacy label association is implemented.

The pinned marquee loop member is an auto-property starting at zero, independent of the content
attribute. Browser deliberately corrects it to [HTML's finite loop algorithm](https://html.spec.whatwg.org/multipage/obsolete.html#dom-marquee-loop):
the getter parses the current attribute's signed integer prefix and returns a positive count or `-1`.
Only ASCII whitespace is skipped. Out-of-Int32 positive counts return `-1` as an explicit finite
interoperability policy; HTML's prose does not define that boundary. The setter ignores zero and values
below `-1`. A valid positive value or `-1` changes the content attribute only when the parsed current
value differs, so an equivalent prefix keeps its raw spelling and a missing/invalid `-1` keeps its
absence/spelling. The existing RequiredInt32 conversion precedes this setter algorithm. Reads and the
setter's pre-mutation parse check actual digit work, including long zero and overflowing prefixes.
This supplies finite reflection behavior without a rendering animation or eager loop state.

The dormant canvas context and text cue interface descriptors remain present, but no native producer
creates their receivers. A private-constructor abstract Browser marker replaces their legacy CLR receiver
type; it cannot be instantiated or subclassed. Generated members check the interface receiver before
argument coercion, followed by a defensive `NotSupportedError` continuation that no actual receiver can
reach. Prototype objects and objects forged with those prototypes fail the ordinary receiver check.
Constructors remain illegal. No context, cue, dummy node or legacy-to-native cast is introduced.


## Native attribute token reads

The raw null-namespace attribute remains authoritative. Token-list views and raw `value` reads do not
parse tokens. The first token demand builds an ordered unique index of offset, length and incremental
hash slices, keyed by the immutable raw string's reference. Only that source and the completed slices
remain on the list until indexed item exposure; comparers, temporary sets, work callbacks and realms are not retained. Repeated
`length` and indexed iteration reuse this index after a bounded current-attribute lookup. Character
scanning, duplicate comparison and token substring copying check actual work; a whole-source token can
reuse the immutable string without copying it. The first indexed item exposure lazily materializes and
caches that item's immutable string after the final source check, so later reads reuse it. View, raw
value, length and contains accesses do not populate the exposed-string cache. Interrupted materialization
publishes nothing, and a new source index discards the old strings.

After the final host check, reads validate the captured native Attr's owner and value reference before
publishing or returning. Absence uses a native per-element attribute-structure identity allocated only
when an absence proof is demanded. Append, removal, replacement, parsed attachment and copying clear
that identity without allocating its successor; value changes and adoption preserve it. Ordinary
parsing creates no token-list state or proof object. A checkpoint that mutates the source causes a
retry with the same invocation work and cancellation token. Interrupted builds publish no partial index.
Mutation consumers validate the same source proof immediately before their single attribute write.
A direct CLR enumeration captures one immutable invocation source; JavaScript indexed iteration makes
a new live read for each item. Deterministic tests cover work scaling, cold and warm reads, cancellation,
direct Attr replacement and removal, absent versus empty attributes, and final-checkpoint mutation.

---

### Design Browser Native Cutover Handoff (`docs/design/browser-native-cutover-handoff.md`)

# Browser native DOM cutover checkpoint

## Status

This is an unfinished, unmerged checkpoint, not a working Browser cutover. Work
stopped at the user's request to finalize the remaining chats. Do not integrate
the production migration until its remaining callers and native dependencies
are completed and verified. No compatibility fallback or stub was added to make
the build pass.

- Branch: `codex/browser-native-dom-cutover`.
- Starting source: `3c0f0ee7541cd977b458a3d46e5f47b56006ebfd`.
- Independently coherent generator commit: `632db0093` (Make DOM emitter type
  selection explicit in binding contract).
- The following checkpoint commit preserves the production edits, generated
  output, contract changes, and this note together.
- No PR was created. No active build or generator process needs this checkout.

## Verified separable change

The generator commit adds explicit type-map candidacy and configurable
collection type names, with defaults preserving the existing contract.
Its Release build succeeded. Generating the original contract produced all
12 files byte-for-byte identical to the original generated files.
That commit can be considered independently of the unfinished production work.

## Preserved production work

The existing `DomNodeObject`, `DomRealm`, wrapper cache, and creation-realm
association were moved toward native `Jint.HtmlParser` nodes and attributes.
There is no parallel native wrapper identity. Attribute wrappers retain the
JavaScript Node contract even though native Attr is not a Node. Normal wrapping
does not scan a subtree; association and adoption boundaries still do.

The checkpoint also contains a native namespace/local-name classifier,
Browser-owned browsing-context and document-state associations, native Node
helpers and equality, live child-node-list views, native XML parsing and
serialization, and an inert HTML parser-session driver. Event reconciliation
and activation overrides remain present, but their old dependencies have not
all been ported.

The contract was regenerated through the generator, preserving 163 interfaces
and producing 12 files with zero generator diagnostics. Broad receiver/type
replacement is an intermediate migration step: many member helpers and APIs
still need deliberate native implementations. Generated files were not edited
by hand. AngleSharp package references remain because production consumers
still depend on them.

Native parser runtime source was not changed here. The only native-project
edit is the signed Browser `InternalsVisibleTo` grant in
`Jint.HtmlParser/Parsing/AssemblyInfo.cs`.

## Build evidence and gaps

The last fresh build was:

```sh
dotnet build -c Release -f net8.0 Jint.Browser/Jint.Browser.csproj -p:RestoreSources=https://api.nuget.org/v3/index.json
```

It failed with zero warnings and four declaration errors:

- `DomProcessingInstructionAttributes.cs`: missing native `DomRange`.
- `DomRealm.cs`: unresolved `IHtmlCollection<>` and `IStringMap`.
- `DomRealm.cs`: sealed native Element cannot be a generic constraint.

These errors are the first declaration gate and mask further errors. They are
not the complete remaining work list. Native HtmlParser, engine, and DevTools
dependencies compiled in this run. Browser tests were not run, and the Browser
net10 target was not built. There is no successful Browser compilation or
behavioral verification for this checkpoint.

Known remaining work includes:

- Port Runtime, ParserDriver, FrameWindows, constructors, activation helpers,
  reflected/token/string-map helpers, and all other AngleSharp consumers.
- Connect Runtime URL writers to the new document-state association.
- Complete and audit native HTML/SVG interface classification and immutable
  WebIDL brand checks, including TryBind and argument error behavior.
- Correct iterator references left by broad contract replacement; the pending
  native class is `DomNodeIterator`, not `Jint.HtmlParser.NodeIterator`.
- Integrate native Range, NodeIterator, TreeWalker, and CharacterData operations
  from their owning work. The checkpoint references pending
  `NativeCharacterData.Normalize(Node)` and the proposed DomRange boundary API.
- Integrate HTML foreign-content/session and script insertion-marker work from
  their owners. A parser MissingFeature remains an integration failure.
- Integrate the native CSS executor and the Browser per-query live-state seam
  for selectors, including selectors nested in is/not/has.
- Implement native XPath navigation with Browser namespace adaptation.
- Audit mutation bookkeeping, budgets, document-position ordering, live views,
  Attr semantics, events, and all 163 interface bindings against the baseline.
- Remove stale AngleSharp documentation and package references only after all
  consumers have been migrated and the required test/build legs pass.

The native traversal/CharacterData, HTML parser-session, insertion-marker, and
CSS executor work belonged to separate active chats at the time of checkpoint.
Their completion and exact integration commits must be established before
resuming; this note does not assert those dependencies are ready.

---

### Design Browser Native Cutover Progress (`docs/design/browser-native-cutover-progress.md`)

# Browser native cutover: resumed implementation

The full production objective resumed after the preserved checkpoint
`233d0aa6a4a0040f2ac221d7d2850ea30a595ba5`. The historical handoff remains in
`browser-native-cutover-handoff.md`; its stop instruction no longer describes
the active task. The integration source `97a77c148` was merged cleanly into
`codex/browser-native-dom-cutover`. Production remains incomplete and unmerged.

## Connected consumers

- Native collection, dataset, label, token-list and reflected-attribute views
  use the existing wrapper families and cache. Live element traversal follows
  native links without a stack allocation. Token lists are associated with the
  actual element and attribute name, preserving SameObject identity.
- Node insertion/replacement captures source creation-realm associations before
  native ownership changes. Ordinary wrapping remains O(1). Native element
  classification now assigns listing/xmp to HTMLPreElement, basefont/rb/rtc to
  HTMLElement, and keygen to HTMLUnknownElement.
- The event wrapper reads native stored slot assignment, including closed roots
  internally. A noncomposed event stops at the shadow root of its first invocation
  target; slotted light-tree events cross the slot's shadow root to its host.
- CharacterData operations delegate to NativeCharacterData after ordered WebIDL
  conversions. Range and traversal bindings use native DomNodeIdentity, including
  Attr. The duplicate Browser TreeWalker and NodeIterator implementations were
  removed. Native traversal filters remain on the same callback/realm lane.
- Constructors and DOMImplementation manufacture native documents and nodes.
  PageRuntime now holds a native document and publishes URL/referrer metadata on
  that document when it is installed or its address changes.
- XPath uses guarded native cursors and guarded native expression preparation
  under the BCL evaluator. The Browser adapter preserves namespace suppression
  independently of native namespaces. The pinned AngleSharp.XPath 2.0.6 assembly
  was inspected: NamespaceURI is empty with ignoreNamespaces, Prefix is retained,
  element Name is its local name, and MoveToFirstNamespace always returns false.
  XPath work checkpoints check engine constraints, and results retain native
  identities rather than cloned DOM nodes.

## Evidence and remaining work

All generated edits came from the Release binding generator. Generation still
reports 163 interfaces, 12 files, and zero diagnostics. `git diff --check` passed.

The most recent completed fresh Release net8.0 Browser build failed with 1,058
unique diagnostics, primarily generated HTML/DOM members and remaining
AngleSharp consumers. This number is a diagnostic census, not a readiness
measure. The final slot wiring was included in that fresh build. It reported
zero warnings and 1,058 errors. No Browser tests have run, and neither a successful Browser build nor behavioral verification is
claimed for this progress commit.

Concrete dependency needs reported to the coordinator:

- Native input checkedness, indeterminate state, value state, option selectedness,
  select resets and constraint validity, shared by bindings, activation, forms
  and selectors. Native type metadata, form association, disabled state and
  textarea state already exist; there is no blanket-false replacement for the
  missing states.
- Validated mutable CSS declarations, sheets, rules and media state. A separate
  native owner is implementing the registry and CSSOM; Browser adapters remain
  here until ownership is explicitly reassigned.
- Native selector live-query state, separately owned. Radio names must remain
  case-sensitive, language must derive from the document/tree rather than host
  Culture, and interaction ancestry must follow the flat tree.
- H8's reviewed parser session script requests and inserted-input protocol,
  separately owned. Browser owns preparation/execution, fetched queues,
  stylesheet readiness, currentScript and scheduling; native script metadata
  must not be duplicated. ParserDriver and document.write still need migration.
- Native work-check hooks for long Range operations to let Browser check its
  task deadline, in addition to cancellation tokens.
- Indexed native attribute access for NamedNodeMap. AttributeCount plus an
  enumerable would make every indexed access linear; caching strong Attr entries
  in a live view would retain removed attributes.

Other production groups still awaiting migration include CustomElements,
Observers, activation/form/input consumers, selection, accessibility/extraction,
DevTools, layout and frames. None has been replaced by a stub or a copied DOM.
The old packages remain referenced until their actual consumers are eliminated.

The current XPath adapter preserves the previous Browser policy of snapshotting
iterator results. Native rejection of empty Text and XMLNS Attr context nodes
still needs explicit Browser compatibility/spec review and tests; do not silently
map such contexts to another node or fall back to AngleSharp.


## Final wrap-up

At the user's renewed wrap-up request, implementation stopped after production
progress commit `eb9379071bfeee38c9f4fd1b550b5a07634327bf`. The following
notes-only commit records the final fresh build above. No broken production
changes were integrated into common, and no PR was created.

The largest diagnostic groups in that final build were:

| File group | Unique diagnostics |
| --- | ---: |
| Generated HTML shapes | 621 |
| Generated DOM shapes | 185 |
| DomHostHooks | 41 |
| ParserDriver | 21 |
| Generated collection accessors | 14 |
| InputDispatcher | 10 |
| Page.Input | 9 |
| FrameWindows | 9 |

All owned build, generator and inspection commands finished. No active process
needs this checkout. Preserve the branch, chat and worktree for recovery.
Before resuming, establish which separately owned native prerequisites were
reviewed and integrated after `97a77c148`, then merge them into this isolated
branch. Continue the actual production port and tests; this checkpoint is not a
ready-to-integrate cutover.

## Resumed integration: native DOM mutations, collections, selectors and Range reads

The wrap-up above is historical; work resumed after merging common `0b9b921de`,
then the reviewed attribute-index/CSS-source and Range-read APIs through
`2b6cc11f2`.

Production changes in this checkpoint:

- Native ParentNode append/prepend/replaceChildren and ChildNode before/after/
  replaceWith use native mutation bookkeeping. The Node-or-string union preserves
  native Attr identity through ordered fragment construction: a later Attr refusal
  leaves preceding argument nodes moved, rather than preflight-rejecting the union.
- Native Element attribute-node methods, adjacent insertions, reflected id/class/
  slot, child/sibling reads and public assignedSlot. Closed shadow slots are hidden
  only by the public getter; event paths still use the actual native assignment.
- Live SameObject immediate-child HTMLCollection and NamedNodeMap views, using
  native `Element.GetAttributeAt(uint)` and the existing canonical Attr wrapper.
- DOM selector methods now use the actual native compiler/matcher and data-only
  SelectorEnvironment. Focus and pointer seeds come from the actual interaction
  store (`BrowserEventRealm.Of(engine)`), independently of wrapper creation realm.
  Document target identity starts null and is selected at navigation boundaries,
  never on query; ID mutations cannot silently retarget `:target`. Initial load,
  fragment navigation and same-document traversal are wired to the selection step.
  The native traversal charges non-element visits and ascents too. Target selection
  follows HTML's scroll-to-fragment identity rule; ancestor revealing, fragment
  focus and scrolling remain work for the runtime migration.
- All six native readonly Range operations receive the Browser deadline check
  adapter and cancellation token after WebIDL conversions. The stringifier no
  longer supplies a default token. Synchronous content mutations make no new
  cancellation guarantee.

Generator: Release regeneration succeeded with 163 interfaces, 12 output files,
zero generator diagnostics. Fresh Release net8 Browser build failed with **974
unique diagnostics, zero warnings**, in 19.58 seconds. No Browser runtime tests
can run yet. This is an isolated, incomplete production cutover, not an integration
claim. The remaining Range clone-reaction diagnostic is the still-unported custom
registry consumer, not a reason to omit reactions.

Remaining native seam: selector compilation has cancellation polling but no host
work callback; Browser checks host constraints before/after compilation. Matching
and navigation target traversal already poll the host deadline during work.

### Exclusive Browser leaf group available for parent dispatch

Relinquish all implementation files under `Jint.Browser/Accessibility/` and
`Jint.Browser/Extraction/`, plus focused tests under
`Jint.Tests.Browser/Accessibility/` and `Jint.Tests.Browser/Extraction/` (including
those directories' fixture/golden helpers). Those files have no local edits here.
The recipient owns native production conversion within those paths and reports
missing native state instead of inserting false predicates or substitutes.

Do not edit shared DomRealm/wrapper/cache, generated contract or files, Events,
Layout, CustomElements/Observers, Runtime/Parsing or Page navigation, or DevTools
from that leaf group. Parent reviews and merges its commit back into this branch.
The main Browser owner retains those shared seams and the parser scheduler.

## Continued shared-consumer checkpoint

After the previous commit, public assignedSlot was corrected to the bounded native
`FindSlot(..., openOnly: true, hostCheckpoint, cancellation)` query. Event paths
continue to use stored assignment. Navigation target resolution now scans native
attribute indexes and performs polled ordinal comparisons, including attribute
names, rather than hiding an unbounded attribute scan/string comparison after a
bounded node traversal. URL parsing and percent decoding still have only boundary
checks; their existing shared helpers have no work hook. No second decoder was
introduced. Selector compilation retains the separate missing host-work seam.

Additional production ports:

- HTMLScriptElement's force-async getter/setter uses the native script identity
  slot; charset/type/integrity/defer use native reflected attributes and text uses
  native child mutations. No competing script metadata was added to Browser.
- HTML/XML markup getters and Page.ContentAsync use native serializers with host
  work checks and cancellation. XMLSerializer also passes those checks. Remaining
  old DevTools serialization callers must be migrated with their native trackers.
- Selection owns a native DomRange and its native weak change subscription.
  Boundary edits and native endpoint repairs schedule the existing coalesced task;
  notification bookkeeping never invokes a listener from inside native mutation.
  Replacement disconnects the token, and browsing-context disposal clears its sink.
- ARIA explicit references and their IDL projection use native Element identities.
  Accessibility's engine-free reader is `AriaElementReferences.Explicit(Element,
  string)`. `BrowserEventRealm.FocusedElementOf(Document?)` reads the actual focus
  interaction store through a weak document association without creating or
  touching an Engine; there is no second focus-value field for the leaf consumer.

The parent dispatched Accessibility/Extraction from `92732c130` in separate
worktree `68c5`; those implementation and matching test directories remain
untouched here. Shared CssCascade remains unported because actual validated native
CSS declarations/cascade still require implementation; do not return invented
computed values to satisfy that consumer.

Latest fresh Release net8 Browser build: failed, **954 unique diagnostics, zero
warnings**, 1.00 seconds. No Browser tests ran. Generator regeneration succeeded
for the same 163 interfaces and 12 files with zero diagnostics.

Parser prerequisites newly confirmed by actual API inspection: no HTML/XML
context-sensitive fragment entry point is present for markup setters, and H8 host
requests expose neither script start-tag location nor input attribution (request
steps have their default zero offset). These need real producer APIs; a whole-
document parse/reparse or a dummy script line is not a replacement. The scheduler
port remains owned here and incomplete.

## Final finite wrap-up checkpoint

Stopped expansion at the parent's explicit finite-wrap-up instruction. The narrow
textarea edit projects native HtmlTextAreaState value/default value, selection
offsets/direction, select and setSelectionRange, plus native reflected attributes.
The helper queues select only for changed selection-method results, not value
assignment. Reflection and selection scheduling were checked against the living
HTML textarea and text-field-selection algorithms. No validation implementation or
placeholder was added. Generated output retains all 163 interfaces and 12 files;
the Release generator reports zero diagnostics.

Final fresh Release net8 Browser build failed with **925 errors, zero warnings**,
in 16.88 seconds. No diagnostic names DomTextAreaMembers; this is not runtime
verification. Browser tests could not run because the package does not compile.
The isolated cutover must not be merged into the buildable common branch. No PR
was opened. The Accessibility/Extraction leaf remains separately owned and is not
included in this checkpoint; the parent can integrate its reviewed checkpoint
into this branch later.

Remaining work before the production cutover can be claimed:

- Port the parser scheduler/resource-loading/frame consumers to the native H8
  request protocol, preserving the real baton, budgets, writes and script ordering.
  Integrate the producer's real script source attribution once reviewed; do not
  substitute expanded-input offsets or dummy document lines. Context-sensitive
  HTML/XML fragment parsing remains a required missing producer entry point.
- Port CSS cascade and computed declarations, layout, events/focus/activation,
  forms/control validation and collections, custom elements/observers, remaining
  runtime and DevTools consumers, and the remaining generated HTML/SVG/DOM rows.
  AngleSharp packages remain while these actual consumers remain.
- Finish native read deadline hooks for selector compilation, URL parse/percent
  decode, XML parsing and textarea reads/clamps. Direct textarea IDL mutations
  schedule selection changes here; native subtree-driven clamps still need a
  producer notification seam. Input/select state and validation must be real.
- Integrate and verify the reviewed Accessibility/Extraction leaf and shared CSS
  seams, complete wrapper/brand/XPath/lifetime audits, then build and run focused
  Browser tests and the required verification legs against freshly compiled code.

The worktree is preserved for unfinished work. list_artifacts returned an empty
artifact list at wrap-up (this checkout is the chat's existing workspace, with no
attached managed worktree or pull request).

## Reviewed leaf integrated into preserved WIP

At the parent's explicit reviewed disposition, cherry-picked exactly
`cdf59d402`, `b1725f7ee`, and `90aa31b723c63b7b79fee5fdf1d96a9de92b0c66`
into this isolated branch as `559fb89d8`, `3dc1e3cc7`, and `fb853f441`.
Integration was conflict-free and includes the reviewed namespace corrections.
No common-branch merge or additional implementation was performed. This section
supersedes the previous checkpoint's statement that the leaf was not included.

One fresh Release net8 Browser build after integration failed with **932 errors,
zero warnings**, in 21.96 seconds. Ten diagnostics remain in Accessibility:
input/select/option consumers require native authoritative state; ContentEditing
accepts old AngleSharp element identities; and CssCascade/visibility consumers
accept old document/element identities. These dependencies remain unresolved,
without substitute values or disabled behavior. Accessibility and Extraction
golden tests and new focused tests remain unverified because Browser is not
buildable. Earlier parser, remaining generated binding, runtime, event, CSS and
DevTools blockers still apply.

The build process completed with exit code 1. No task build/generator process
remains running. Work stops at this preserved WIP integration checkpoint; both
unfinished chats are retained by the parent.

## Full objective resumed: shared native bindings

Merged reviewed common producer checkpoint `56898c3d8`. Fresh Release Browser
baseline remains 932 errors (702 generated bindings, 53 other DOM, 49 runtime,
47 Events). Released Events and matching tests plus Page.Input.cs to the parent-
assigned leaf, except shared BrowserEventRealm coordination. CSS implementation
files remain available to the dedicated owner.

Projected 107 explicit reflected attributes in the contract and regenerated all
163 interfaces with zero generator diagnostics. The next fresh build reported
718 errors, zero warnings. Native shared dataset, labels and form-owner hooks,
live document collections including HTMLAllCollection, native frame-context
readers and PageActivationHost signatures followed. The latter matches the leaf's
announced Element signatures; until those reviewed leaf files integrate, the
compiler stops with six override diagnostics, which masks the deeper census and
is not a claim of six remaining errors.

Review corrections: non-URL reflection resolves no base URL; URL reflection
uses a bounded native document walk, and base.href separately resolves missing,
empty and relative values against the fallback URL, ignoring all base elements.
Native form-owner reads retain the stored parser association. ControlsOf and
custom-owner readers accept host checkpoints and cancellation; option form reads
use native select ancestry with its barriers. Added native reflection regressions
for fallback-base resolution and ordinary attribute access on a large DOM versus
a bounded URL walk; these remain unrun while Browser cannot compile.

The XML fragment prerequisite was misstated earlier: the existing producer facade
is MarkupParser.ParseXmlFragment. Only context-sensitive HTML fragment parsing
remains absent at this checkpoint. Runtime/parser execution, remaining bindings,
actual CSS cascade and control producers still need integration and verification.

### Shared runtime continuation before Events integration

Native hyperlink component bodies now use the shared WHATWG parser/setters,
including the opaque-path and credentials/port early returns. Location has a
Browser-owned per-page identity backed by existing navigation operations. Document
metadata hooks and native frame/context and cookie readers have been migrated.
Details attribute and reflected boolean writes notify the incoming Events
coalesced toggle helper on an actual presence transition.

Secondary-document dynamic markup insertion now uses the native incremental
HtmlParserSession rather than concatenating/reparsing/copying the document on
each write. Its native hook call sites and the principal parser scheduler remain
under active migration, so this is a coordinated WIP checkpoint, not build or
runtime clearance. No test was disabled. Awaiting reviewed Events integration
to remove the current override diagnostic masking before the next census.

### Native runtime and construction integration continues

The production parser now drives the native incremental session directly. It
retains script preparation flags, synchronous dynamic-script preparation before
custom-element reactions, raw stylesheet resource installation, native frame
contexts and the shared bounded subresource transport. Parser ingestion still
keeps raw attribute and text state; enhanced controls and CSS are read on demand.

Autonomous form-associated custom elements enable the existing native category
when their definition attaches. Upgrade resets stored ownership after a successful
constructor and before the custom state. Failed upgrades clear the category,
owner, definition and reactions, including when a fatal engine constraint escapes.
Customized built-ins retain their built-in category. Cloning/importing relies on
normal upgrade rather than copying native category or owner history. This closes
the native owner-history seam; it does not implement the existing unsupported
formAssociatedCallback, formDisabledCallback, formResetCallback or
formStateRestoreCallback lifecycle. An owner revision alone cannot reconstruct
those callback payloads and their order.

The fresh Release net10.0 Browser inventory at `4313ab7a4` completed with 259
errors and zero warnings. Native parser and runtime dependencies compiled;
remaining errors include generated consumers and CSS/DevTools integration plus
analyzer repairs. The build remains unsuccessful and Browser regressions remain
unrun. Source integration and corrections continue; this is not a final gate.

### Coherent HTML and CSS binding checkpoint

Reviewed native CSS model bindings, stylesheet revision invalidation, HTML attribute
semantics, form and selection callbacks, and editing consumers have been integrated.
The next fresh Release net10.0 production inventory at
`fed2ec409fcec956068424723358f3d922747281` completed with 80 errors and zero warnings
(26.77 seconds). All diagnostics were generated consumers: HTML 50, DOM 20,
SVG 7, media 2, and one collection accessor. No handwritten Browser source produced
a diagnostic. The previous inventory at `aea66c249` had 131 errors. Neither snapshot
is a successful build; Browser regression fixtures remain unrun.

Document metadata now has a concrete production packet under source review: weak
per-document origin and modification timestamp state, immutable tuple or opaque
origin identity, creator facts captured before fetch yields, and the same pending
origin published before new-document scripts and then assigned before parser scripts.
Frame exposure uses that record, including indexed and named window access. Frozen
sandbox origin and script facts inherit into child creation. Blank URL inheritance
uses parsed paths and preserves the captured creator base URL. Response modification
timestamps travel to the parser; manufactured documents use the getter's current
local time when no resource timestamp exists. Domain relaxation remains explicitly
unsupported after WebIDL conversion. These changes and their regression fixtures
still require source acceptance and execution at the coordinated Browser gate.

---

### Design Devtools Protocol (`docs/design/devtools-protocol.md`)

# DevTools protocol for Jint — design

**Status: finalized design; implementation under way.** The authoritative statement of this design is the body
of [sebastienros/jint#3575](https://github.com/sebastienros/jint/issues/3575); where this
document and that issue disagree, the issue wins and this file is brought back into line. What this file adds is
the longer form: the engine mechanisms each decision rests on, named so that a reader can find them.
[§9](#9-what-shipped-and-where-it-differs) is the index of what was built against this design and the one line
in which each item differs from it. For what the package does rather than why, read
[the Jint.DevTools package guide](../packages/jint-devtools/index.md) instead of this file.

Everything normative here was read from the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)
(the pinned `js_protocol.json` / `browser_protocol.json` under `tools/devtools-protocol/`), from V8's inspector
design (`v8_inspector::V8InspectorClient::runMessageLoopOnPause`), and from what the clients actually send,
recorded by `tools/cdp-histogram/` into `tools/devtools-protocol/handshakes/`.

> **On citations.** This file cites files and members, not line numbers; a wrong line number still reads like a
> fact. `grep` finds a member.

---

## 1. Why a protocol, and why this one

Jint has had a debugger since 3.x (`Jint/Runtime/Debugger/`): breakpoints with conditions, step in/over/out,
return-point stepping, a call stack with per-frame `this` and a scope chain whose `DebugScopeType` was written to
mirror the protocol's `Debugger.Scope` types, expression evaluation in the paused context, and a `BeforeEvaluate`
event per script. Two community projects informed the protocol design:
[Jither/Jint.DevToolsProtocol](https://github.com/Jither/Jint.DevToolsProtocol)
(CDP) and [Jither/Jint.DebugAdapter](https://github.com/Jither/Jint.DebugAdapter) (DAP). The original campaign
identified exception pauses, breakpoint-location enumeration and per-step allocation as integration concerns.
The engine work in §5 provides the first two capabilities; allocation claims require measurement against the
actual integration. The [X6 integration note](../integration/upstream-adoption.md#integration-note-for-jither)
maps today's public seams to examples and tests, without assuming those adapters work unchanged or that a
note has been delivered.

The protocol is CDP because every client that matters speaks it — Puppeteer, Playwright on Chromium,
PuppeteerSharp, Playwright for .NET, chrome-remote-interface, chromedp, and the Chrome DevTools frontend itself.
WebDriver BiDi is the W3C draft and Firefox's default; it is a later façade over the same session core (Lightpanda
ships both from one core), not a first target. A Debug Adapter Protocol adapter for VS Code is likewise a thin
layer over the same core, deferred. Nothing here invents a protocol.

There are two layers because the two audiences differ:

- **`Jint.DevTools`** is engine-level. It serves *every* embedder — a workflow engine, a CMS, a game — that wants
  to attach Chrome DevTools to the scripts it runs: `Runtime`, `Debugger`, `Profiler`, `Console`, `Log`,
  `Schema`, `Browser`, and `Target` for engine targets. A Jint engine appears to the frontend as a Node-flavoured
  target (`js_app.html?v8only=true`), so the frontend never asks it for a page.
- **`Jint.Browser`** adds the page-level domains (`Page`, `DOM`, `Network`, `Fetch`, `Input`, `Emulation`,
  `Storage`, `Accessibility`, page `Target`s) on top of the same session core. It is the subject of
  [`headless-browser.md`](headless-browser.md).

## 2. The thread rule

Every `JsValue` and every `Engine` is thread-affine: `Engine.EnterHostCall` and
`Engine.VerifyValueConstructedOnOwningThread` fail fast when another thread touches them, and *Jint never starts a
thread to run script* is load-bearing across the web-API family (see `docs/design/web-workers.md` §1). The
protocol package is a *host*, so it may own threads, but the rule it lives by is:

> A `JsValue` never leaves the engine thread. Transport threads move strings. Every domain method runs on the
> engine thread. Every event is serialized on the engine thread before it is handed to the writer.

The mechanism is one mailbox per target, `EngineDispatcher`, drained in exactly two places:

1. **Running.** `Post(item)` enqueues and wakes the engine's loop through the public, thread-safe
   `engine.Tasks.Post(Action)` (engine PR E1, a one-line public door over the internal
   `Engine.AddToEventLoop(Action, generation)`). The drain therefore runs as an ordinary event-loop job on
   whichever thread pumps the engine, wakes `Tasks.WaitForScheduledWork`, and interleaves with microtasks. A
   command never calls `WaitForScheduledWork` or `UnwrapIfPromise` from inside a job (the pump's re-entrancy guard
   forbids it); `Runtime.evaluate` with `awaitPromise` attaches reactions to the promise and answers when they
   fire, which is V8's shape too.
2. **Paused.** The debugger's pause is synchronous: `DebugHandler.Pause` invokes the `Break`/`Step` delegates
   inline and the delegate's *return value* is the next `StepMode`. There is no resume token. So the protocol
   traffic that a paused page generates — `Runtime.getProperties` for the Scope pane, `evaluateOnCallFrame` for
   the console, `Debugger.resume` — is serviced by a message loop running **inside the paused handler on the
   engine thread**, exactly `runMessageLoopOnPause`: send `Debugger.paused`, then wait on the mailbox signal,
   drain pause-safe items, until a resume or step command sets the mode and ends the loop; send
   `Debugger.resumed`; return the mode. Commands that would re-enter a public engine entry while paused
   (`Runtime.runScript`, `Profiler.start`) are answered `-32000 "Not allowed while paused"`.

Hosts integrate through `EngineTargetOptions.ThreadMode`:

- `HostOwned` (default): the host's thread runs script and pumps; commands are serviced when it calls
  `engine.Tasks.ProcessTasks()` or `target.Pump()`. A command waiting longer than `CommandTimeout` fails with
  `-32000 "Engine is not being pumped"`, which is the diagnostic a host that forgot to pump needs.
- `LibraryOwned`: the target starts one thread running drain → `ProcessTasks` → `WaitForScheduledWork`, the host
  submits work with `target.Post(Action<Engine>)`, and the engine's single-drainer rule fail-fasts any host thread
  that touches the engine directly.
- `WaitForDebuggerOnStart` (`--inspect-brk`): the first posted work is held until a session sends
  `Runtime.runIfWaitingForDebugger`.

Client disconnect mid-pause detaches the session, which enqueues a control item; the pause loop resumes with
`StepMode.None`, that session's breakpoints are removed, its exception mode and skip-all flag reset, its object
groups released — V8's implicit `Debugger.disable`.

## 3. Targets and sessions

`/json/version` answers `webSocketDebuggerUrl: ws://host:port/devtools/browser/<id>`; `/json/list` lists targets
with `type: "node"`, a direct `ws://…/devtools/page/<targetId>` endpoint (no `sessionId` on that socket), and
`devtoolsFrontendUrl` in Node's form. Puppeteer requires flattened sessions: `Target.setAutoAttach(flatten: true)`
emits `attachedToTarget` for existing targets (with `waitingForDebugger`), `Target.attachToTarget(flatten: true)`
mints a `sessionId` that then rides every message; `flatten: false` is refused (no client sends it). One
`Debugger`-enabled session per target at a time; `Runtime` is shared. This is a documented divergence from V8's
per-session breakpoints.

**A page target is a target that outlives its engine.** `DevToolsTarget` carries the identity a client keeps
addressing and `TargetRuntime` carries one engine and everything that dies with it — the mailbox, the
remote-object table, the script registry, the console journal, the execution context — so committing a
document replaces the lot through `DevToolsTarget.Replace` and every domain hears about it. The
execution-context counter is the target's, so a second document's default context is `2` and never `1` again
and a stale identifier is refused rather than resolved. `ITargetHost` is what mints a target when a client
sends `Target.createTarget`; with none registered every target command behaves as it did.
`Jint.Browser`'s `AddBrowser` registers one, and publishes a **`tab`** target in front of each page because
Puppeteer's browser-level `setAutoAttach` filter excludes pages and reaches one through its tab.

The transport is `TcpListener` + an HTTP/1.1 upgrade + `WebSocket.CreateFromStream` — no `HttpListener`, no
ASP.NET dependency — behind `IDevToolsConnection`, with an `InProcessConnection` (string in, string out) for tests
and embedding.

## 4. The protocol layer is generated and checked in

`tools/devtools-protocol/` vendors `js_protocol.json` and `browser_protocol.json` at a pinned commit
(`pin.json`; a bump is a code change, as it is for test262 and WPT) and a `manifest.json` naming the domains
whose types are generated and the methods and events that are implemented. A .NET generator emits
`Jint.DevTools/Protocol/Generated/`: DTO records, per-domain abstract dispatch bases with one virtual per
command, event factories, the `System.Text.Json` source-generation context, and the manifest tables
`Schema.getDomains` answers from. Anything not in the manifest answers `-32601 "'X.y' wasn't found"`, Chrome's
text — never a silent success.

Checked-in output rather than a Roslyn generator, for four reasons: the JSON serializer context must be generated
*over* the DTOs and generators cannot chain; `Jint.SourceGenerators` is netstandard2.0 and must carry no JSON
dependency; generated code in the tree is reviewable, greppable and debuggable, which matters for a surface people
read to learn what is implemented; and `tools/whatwg-encoding/` set the precedent. A currency test re-runs the
emitter in memory and fails on drift. CDP enums are emitted as string constants (`JsonStringEnumMemberName` is
.NET 9+; net8.0 must compile) so the whole layer is AOT-safe.

`SpecCitationTests` only scans `tc39.es` URLs; CDP citations use the form
`https://chromedevtools.github.io/devtools-protocol/tot/<Domain>/#method-<name>` and are verified offline against
the vendored JSON by `ProtocolCitationTests`.

## 5. Domains and the engine seams they ride

| Domain | What it maps onto | Engine change |
| --- | --- | --- |
| `Runtime` | `engine.Evaluate` (running) or `DebugHandler.Evaluate` (paused); `getProperties` over `GetOwnPropertyKeys` + descriptors, accessors reported and never invoked; `awaitPromise` by reactions; `consoleAPICalled`; `exceptionThrown`/`exceptionRevoked` from `Tasks.PromiseRejectionTracker`; `getHeapUsage` from `Diagnostics.GetMemoryReport` | **E5** ([#3597](https://github.com/sebastienros/jint/pull/3597)) structured `ConsoleRecord` sink overload + public `ValueInspector` (previews that never run script, promoted from `ConsoleFormatter`) |
| `Debugger` | `ScriptRegistry` on `DebugHandler.BeforeEvaluate` keyed by `Program` identity (a cached `Prepared<Script>` is announced once); breakpoints as `DevToolsBreakPoint : BreakPoint` (the class is unsealed for this); `pause` as a flag checked in `Skip`; scopes from `DebugScopeType` 1:1 | **E1** ([#3587](https://github.com/sebastienros/jint/pull/3587)) `TryGetSourceText(Program)` for `getScriptSource`; **E2** ([#3614](https://github.com/sebastienros/jint/pull/3614)) `GetStepLocations(Program)` for `getPossibleBreakpoints` and column snapping; **E3** ([#3623](https://github.com/sebastienros/jint/pull/3623)) pause on exceptions with caught/uncaught; **E4** ([#3622](https://github.com/sebastienros/jint/pull/3622)) evaluate on any call frame; **E6** ([#3632](https://github.com/sebastienros/jint/issues/3632)) `CallFrame.Program`, a profile frame's `Program` and `CoverageSource.Program`, so a position is matched to a script by identity rather than by source name ; **E8** ([#3631](https://github.com/sebastienros/jint/issues/3631)) `ExceptionPauseMode.Caught` and `StepMode.Unchanged`, so the four protocol states are four engine modes and declining a pause cannot cancel a step |
| `Profiler` | `Profiler.Profile` synthesized behind an `IProfileSource` seam from either of the engine's profilers (`Jint/Profiling/`) — the sampler by default, the evented one when the host is already sampling — one weighted sample per interval between two activations, so the time deltas add up to the recording rather than approximating it; precise/best-effort coverage from `CoverageReport` (`Jint/Runtime/Coverage/`), with the uncovered set derived from the script registry's abstract syntax tree so an unused function is reported with `count: 0` | **E7** ([#3630](https://github.com/sebastienros/jint/issues/3630)) public read-only accessors on `SampledProfile`'s sample, stack, frame and function tables, so the sampler of [#3608](https://github.com/sebastienros/jint/pull/3608) is consumable without writing its document and parsing it back |
| `Console`, `Log` | the structured record | E5 |
| `Target`, `Browser`, `Schema` | the session core and the manifest | none |

**All eight engine seams are merged**, and every one is additive and public — so that a third party (AngleSharp.Js,
a DAP adapter, a host's own tooling) can build the same thing without `InternalsVisibleTo`. `Jint.DevTools`
itself consumes only public Jint API; that is deliberate, and it is what proves the seams are reachable.

Every seam the build wanted it now has; the three it was missing at design time are
[#3630](https://github.com/sebastienros/jint/issues/3630),
[#3631](https://github.com/sebastienros/jint/issues/3631) and
[#3632](https://github.com/sebastienros/jint/issues/3632), and each is named in the table above under the
domain it was blocking.

## 6. Costs, stated up front

- `Options.Debugger.Enabled`, `Options.Profiling.Enabled` and `Options.Coverage.Enabled` are construction-time;
  `UseDevTools` sets them, and a target added on an engine without them degrades per domain with an explicit
  `-32000`.
- Debug mode disarms the interpreter's tight-loop lane (`Jint/Constraints/AGENTS.md`), and the `Skip`
  subscription adds a delegate call per execution point; the subscription is attached only while a session has
  `Debugger` enabled, and benchmarks never run with it.
- Source text is retained for `getScriptSource` through the same switch as `Function.prototype.toString`
  (`RetainFunctionSourceText`), one weak-table entry per `Program`, not a second copy.
- Remote-object handles are strong until `releaseObject`, `releaseObjectGroup` or session detach.

## 7. Deliberately absent

Async call stacks, source maps, blackboxing, `HeapProfiler`, per-session breakpoints, and any always-on mode.
Each is a follow-up issue if a client turns out to need it.

## 8. Verification

All of it is in place, and each layer claims something the one below it cannot:

- **In-process protocol tests** over `InProcessConnection`, no sockets, asserting the envelope as text — `id`,
  `error.code` and the wording a client feature-detects on.
- **Socket tests**, which are not a duplicate of those: the upgrade handshake, the frame handling, the single
  writer, the close ordering, and whether the read loop keeps reading while a command is outstanding.
- **Recorded handshake fixtures** of PuppeteerSharp, Puppeteer, Playwright, Playwright for .NET and the DevTools
  front end, in `tools/devtools-protocol/handshakes/`, replayed as tests: a manifest method must be answered,
  and every other must be *exactly* `-32601`.
- **A PuppeteerSharp suite over a real WebSocket**, the only test that can claim client compatibility: connect,
  handles, bindings, console, breakpoints, exception pauses, a profile, a coverage round trip, and the Jint
  REPL's `--inspect` spawned as a separate process and reached through the port its banner named.
- **The Chrome DevTools front end, driven.** `tools/devtools-frontend-smoke/` launches Chrome for Testing,
  loads the hosted front end against a real Jint process and asserts against the front end's own DOM;
  `Jint.DevTools/docs/manual-checklist.md` is the same walk by hand. Neither runs in CI — one downloads a
  browser, the other is a person — which is why the layers above it exist.
- **A Native AOT probe in `Jint.AotExample`**, which references `Jint.DevTools` *without* rooting it and drives
  the published native binary over a real socket: attach, evaluate, break, resume. The same CI leg fails if any
  `IL2xxx`/`IL3xxx` diagnostic is attributed to a file in the package.
- **The host-contract verification leg** with `JINT_HOST_CONTRACT_VERIFICATION=1`, which makes the thread rule
  exact rather than asserted: a `JsValue` touched from a transport thread fails loudly under it.

## 9. What shipped, and where it differs

The sections above are the design, kept as the design. This table is the index of what was built against it:
one row per item, the pull request that built it, and the one line in which what shipped is not what was
planned. A blank last column means the section above describes what exists.

| § | What shipped | PR | Where it differs |
| --- | --- | --- | --- |
| 4 | The package itself: the pinned vendored protocol JSON, the manifest, the emitter and `Protocol/Generated/` | [#3586](https://github.com/sebastienros/jint/pull/3586) | — |
| 4 | The handshakes four automation clients and the front end actually send, recorded rather than assumed | [#3599](https://github.com/sebastienros/jint/pull/3599) | — |
| 2, 3 | `EngineDispatcher`, the two drains, `ThreadMode`, the transport and flattened sessions | [#3602](https://github.com/sebastienros/jint/pull/3602) | `flatten: false` is refused outright with `-32000` rather than supported, and breakpoints are the **target's** rather than the session's — V8 keeps them per session |
| 3 | A target that outlives its engine: the `DevToolsTarget` / `TargetRuntime` split, and `ITargetHost` for a client's `Target.createTarget` | [#3678](https://github.com/sebastienros/jint/pull/3678) | `ITargetHost` is `internal`. `Jint.Browser` is its only implementer today, and it is promoted when there is a second — the seam a third party needs meanwhile is [#3684](https://github.com/sebastienros/jint/issues/3684) |
| 5 | `Runtime`: handles, `getProperties`, `callFunctionOn`, `awaitPromise`, bindings | [#3605](https://github.com/sebastienros/jint/pull/3605), engine seam [#3597](https://github.com/sebastienros/jint/pull/3597) | — |
| 5 | `Console` and `Log`: what a script logged and what it threw, values still attached | [#3606](https://github.com/sebastienros/jint/pull/3606) | — |
| 5 | `Debugger`: breakpoints by URL or script, the three steps, `continueToLocation`, frames, scopes, `setVariableValue` | [#3620](https://github.com/sebastienros/jint/pull/3620), engine seams [#3587](https://github.com/sebastienros/jint/pull/3587) and [#3614](https://github.com/sebastienros/jint/pull/3614) | — |
| 5 | Pausing where a script throws, and evaluating in any frame of the stack | [#3625](https://github.com/sebastienros/jint/pull/3625), engine seams [#3623](https://github.com/sebastienros/jint/pull/3623), [#3622](https://github.com/sebastienros/jint/pull/3622) and [#3662](https://github.com/sebastienros/jint/pull/3662) | — |
| 5 | `Profiler`: a sampled CPU profile, and precise and best-effort coverage | [#3629](https://github.com/sebastienros/jint/pull/3629), engine seams [#3608](https://github.com/sebastienros/jint/pull/3608) and [#3654](https://github.com/sebastienros/jint/pull/3654) | — |
| 5 | Describing a value: a function's declaration site, a console message's anchor, an error's details, a path published as a URL | [#3640](https://github.com/sebastienros/jint/pull/3640) | A function value does not publish the `Program` it was parsed in, so `[[FunctionLocation]]` still resolves its script by name ([#3666](https://github.com/sebastienros/jint/issues/3666)) |
| 5 | A position resolves to its script by identity rather than by source name | [#3652](https://github.com/sebastienros/jint/pull/3652), engine seam [#3651](https://github.com/sebastienros/jint/pull/3651) | — |
| 8 | The REPL's `--inspect`/`--inspect-brk`, the driven front end, and the native binary as a client | [#3633](https://github.com/sebastienros/jint/pull/3633) | Neither `tools/devtools-frontend-smoke/` nor `docs/manual-checklist.md` runs in CI — one downloads a browser and the other is a person — which is why the four layers below them exist |

Two things a reader of §5 should know are decisions rather than omissions. The eight engine seams the table
in that section names all merged, and every one is public and additive, so a third party can build the same
server without an `InternalsVisibleTo` grant — `Jint.DevTools` consumes only Jint's published API, and that is
what proves it. And the page domains (`Page`, `DOM`, `Network`, `Fetch`, `Input`, `Emulation`, `Storage`,
`Accessibility`) are in the same manifest but are implemented in `Jint.Browser`; on an engine target every one
of them is `-32601`.

---

### Design Headless Browser (`docs/design/headless-browser.md`)

# A headless browser on Jint — design

> Historical campaign design, written before the native parser cutover. Dependency descriptions and
> retired integration details below record that campaign; current architecture and ownership are in
> [the Browser guide](../packages/jint-browser/index.md) and
> [the native parser guide](../packages/jint-htmlparser/index.md).

**Status: finalized design; implementation under way.** The authoritative statement of this design is the body
of [sebastienros/jint#3575](https://github.com/sebastienros/jint/issues/3575); where this
document and that issue disagree, the issue wins and this file is brought back into line. What this file adds is
the longer form: the mechanisms each decision rests on, named so that a reader can find them. The protocol half
is [`devtools-protocol.md`](devtools-protocol.md), and
[§12](#12-what-shipped-and-where-it-differs) is the index of what was built against this design and the one
line in which each item differs from it. For what the package does rather than why, read
[the Jint.Browser package guide](../packages/jint-browser/index.md) instead of this file.

Everything normative here was read from the [DOM](https://dom.spec.whatwg.org/), [HTML](https://html.spec.whatwg.org/multipage/),
[Fetch](https://fetch.spec.whatwg.org/), [XMLHttpRequest](https://xhr.spec.whatwg.org/) and
[WebIDL](https://webidl.spec.whatwg.org/) living standards, and from the
[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/).

> **On citations.** This file cites files and members, not line numbers.

---

## 1. The premise, and the one principle

Jint already carries the web platform's non-DOM half — thirty opt-in subtrees under `Jint/WebApi/` that cover
WinterTC's Minimum Common API except `WebAssembly` (declined by design), plus `fetch`, `WebSocket`,
`EventSource`, `Storage`, `caches`, `Worker` and the Streams family. The only missing layer between that and a
browser is the document: HTML parsing, the DOM, the CSSOM, and the runtime that ties navigation, script
scheduling, timers, network and storage to a `Window`.

**AngleSharp** already has the parser, the DOM and (with `AngleSharp.Css`) the CSSOM, and **AngleSharp.Js**
already binds Jint to that DOM, executes `<script>`s, loads modules and import maps, and runs Web Workers. The
project founder's guidance for this campaign is the principle every decision below is checked against:

> Jint should add value to AngleSharp without competing too much.

AngleSharp supplies parsing, DOM storage and the CSSOM. Jint owns the browser semantics its embedding
requires, including behavior AngleSharp intentionally leaves outside its scope. Local standards-defined
algorithms are allowed and must compose with the existing tree, preserve identity and mutation behavior,
and carry regression tests and a recorded divergence. Upstream acceptance or a future package release is
not a prerequisite for fixing a Jint issue. The implementation includes a binding layer built on Jint's own
shape and layout machinery instead of a reflection trampoline, a page runtime that wires Jint's timers, fetch, storage and workers into a `Window` under Jint's
execution constraints, and the automation protocol. The generated bindings and the tree-aware event dispatcher
are intended for adoption by AngleSharp.Js; the [X6 review drafts](../integration/upstream-adoption.md)
record the current extraction blockers and delivery status; every AngleSharp
or AngleSharp.Js divergence the conformance lane finds is recorded (in the PR that found it and in
`Jint.Browser/AGENTS.md`'s divergence table) and presented to the maintainer, who decides what is raised
upstream — an agent never opens an issue on a neighbouring project on its own. In every document and README
sentence, `Jint.Browser` is "AngleSharp + Jint", never a rival DOM stack.

## 2. What it is, and what it is not

The shape is Lightpanda's, not Chromium's: **a browser that does not render.** DOM, JavaScript, network, storage,
workers, and the Chrome DevTools Protocol so that Puppeteer, Playwright, PuppeteerSharp, Playwright for .NET,
chrome-remote-interface and the Chrome DevTools frontend can drive it — in-process, with no native binaries and no
browser download, on any platform .NET runs on, with Jint's statement, time and memory constraints bounding what
a page may do. Kitesurf's framing of the trade is the honest one and it is exactly an interpreter's profile: a
fraction of Chromium's CPU and memory per page, at some multiple of its wall-clock time.

| v1 delivers | Out of v1 (absent, so feature detection is honest) |
| --- | --- |
| Full HTML5 parse with inline, external, `defer`, `async` and module scripts, import maps, `document.write`; generated DOM and CSSOM bindings; tree event dispatch; forms, history, cookies, storage, in-memory IndexedDB, workers, `fetch`/`XMLHttpRequest`/`WebSocket`/`EventSource`; `MutationObserver`; stub `IntersectionObserver`/`ResizeObserver`; deterministic coordinate input; accessibility tree and markdown snapshots; CDP for Puppeteer/Playwright `connect`; a WPT lane; constraints per page | Layout-dependent APIs (`offsetWidth` is synthetic, `cssom-view`), rendering, screenshots, PDF, WebGL, canvas 2D, media, `caches` (the engine has it; a page has no origin-partitioned provider to grant it on), WebAssembly, CSP enforcement, TLS-fingerprint stealth, iframe scripting (v1.1), `WindowProxy`, SharedWorker/ServiceWorker, drag and drop, real isolated worlds (v1.1) |

`Page.captureScreenshot` answers a CDP error with a sentence, the way Lightpanda does.

## 3. The runtime model

- **The global stays `GlobalObject`.** `Host.CreateGlobalObject(Realm)` is virtual and `GlobalEnvironment`
  tolerates a host-defined global, but substituting one forfeits the global-identifier inline cache
  (`JintIdentifierExpression` keys on the realm's `GlobalObject`) and would mean re-installing every intrinsic
  `GlobalObject.Properties` emits. Instead the global's `[[Prototype]]` becomes
  `Window.prototype → EventTarget.prototype → Object.prototype` (`GlobalEnvironment.HasBindingOnGlobalPrototype`
  already resolves prototype members as globals), and the per-document singletons — `document`, `location`,
  `history`, `navigator`, `screen`, `window`, `frames`, `top`, `parent`, `customElements` — are own lazy
  properties installed through the public `Engine.AddLazyGlobal`, which keeps them on the identifier cache.
  `window instanceof EventTarget` holds through the chain; `EventTarget.prototype.addEventListener.call(window, …)`
  works because `EventTargetPrototype.Brand` accepts the realm's global and maps it to the engine's
  `GlobalEventTarget` (engine PR B1).
- **One `Engine` per top-level navigation.** A navigation is a new realm in a browser; here it is a new engine
  built from the same `BrowserOptions`, so "per document" and "per engine" coincide and no `WindowProxy` is needed
  in v1. The previous engine receives `beforeunload`, `pagehide` and `unload`, its cancellation token is
  cancelled, its pending fetches abandoned, and it is disposed on the page loop.
- **One `PageLoop` thread per page**, owning the engine and the DOM: it drains a mailbox of host and protocol
  work, runs one engine task and its microtask checkpoint, runs the animation-frame lane, and sleeps by
  `Tasks.TimeUntilNextScheduledWork` — the `WptHarness.PumpWorker` shape. Every public `Page` API and every CDP
  command posts to the mailbox and awaits a completion; nothing else touches the engine or the DOM. Workers come
  from a `ThreadPerWorkerProvider` (the package is a host, so it may start threads; the engine still never does).
- **Iframes parse but do not run script in v1**: frames are real in the frame tree, their documents are
  fetched and parsed, and each has a `contentWindow` of its own on the page's realm — what it has not got is a
  realm, so nothing in a frame runs and its constructors are the page's. v1.1 adds one realm per frame on the same engine through an
  `Engine.WebApi.InstallInRealm(Realm)` seam (today `WebApiRegistration.InstallGlobals` targets the main realm
  only); a second engine could never satisfy `parent.document`.

## 4. The binding layer

A curated generator, `tools/dom-bindings/`, reads `AngleSharp.dll` and `AngleSharp.Css.dll` at the pinned
version through `System.Reflection.MetadataLoadContext` and treats AngleSharp's `[DomName]`, `[DomAccessor]`,
`[DomConstructor]`, `[DomNoInterfaceObject]` and `[DomPutForwards]` attributes as the WebIDL they effectively
are. It emits checked-in `Jint.Browser/Dom/Generated/*.g.cs`: one `JsObjectShape` per interface (methods,
accessors, constants, a per-realm `constructor` slot, `@@toStringTag`), interface objects, the prototype chain
down to Jint's `EventTarget.prototype`, a `DomTypeMap` from AngleSharp runtime type to shape, and collections
over `ArrayLikeObject` / `NamedPropertyObject`. A curated `overrides.json` skips or replaces what the attributes
cannot express: event-handler content attributes, `Task`-returning members, navigation-shaped members,
`innerHTML`/`outerHTML` setters (routed through the parser driver so inserted scripts run), `[PutForwards]`.
`JINT_DOM_BINDINGS=update` regenerates; a staleness test fails on drift — the `JINT_SPEC_ANCHORS` /
`JINT_WPT_CENSUS` discipline. Checked-in rather than a live source generator for the same reasons as the
protocol layer: reviewable diffs, an analyzer-free build, and swapping later is mechanical.

Why generated on Jint shapes rather than AngleSharp.Js's reflection bindings: a shape-mode prototype per
interface is what the inline caches and the prototype-method cache want, member bodies are static lambdas that
call the AngleSharp interface member directly without reflection in that call. This is the intended host-object
cost improvement; the isolated comparison in [#3898](https://github.com/sebastienros/jint/issues/3898) must
measure it. Generated calls alone do not establish trimming or AOT compatibility for an adopting host.
This is the proposed upstream contribution. The current runtime still uses internal realm and event APIs;
[the adoption proposal](../integration/upstream-adoption.md#proposal-for-anglesharp-js) identifies the public
seams and extraction experiment needed to use it independently of the browser runtime.

**What was built also owns HTML §4.13**, which AngleSharp has nothing of: `Jint.Browser/CustomElements/` is
the `CustomElementRegistry`, the element state (a side table keyed on the AngleSharp element), the
construction stack that makes `super()` answer the element being created, and the reaction lane. Three of the
overrides above exist for it — `createElement`, `createElementNS` and `cloneNode` answer the *constructor's*
element for a defined name — and `DomInterfaceObject.Construct`, which refuses every other `new`, is HTML's
`HTMLElement` constructor. The one approximation is where a reaction runs: nothing here can see a generated
member return, so the element queue is drained as a reaction *arrives* rather than when the outermost
`[CEReactions]` operation returns, and a parser-created element is upgraded at the driver's script boundaries
rather than constructed by the parser. `Jint.Browser/AGENTS.md` states both and what they cost.

Wrapper identity: `DomNodeObject : JsEventTarget` holds an `INode`, the prototype picked from `DomTypeMap`, the
brand check in a generated member is the interface cast (`Illegal invocation` otherwise). One wrapper per node
through a per-page `ConditionalWeakTable<INode, DomNodeObject>`: a node in the tree keeps its wrapper and its
expandos alive (React and Vue rely on that), a node dropped by both the tree and script collects with its
wrapper — the browsers' wrapper-preservation rule for free. Short-lived views (`DOMTokenList`, `NamedNodeMap`,
`Range`, `CSSStyleDeclaration`) wrap with a strong reference.

## 5. Events: one bus, Jint's

**AngleSharp's event bus is neither observed nor driven by script.** Every script-visible event is a Jint `Event`
dispatched through the tree-aware dispatcher engine PR B1 adds to `JsEventTarget` (DOM §2.9 dispatch, get the
parent, the event path, retargeting, activation behaviour, over virtuals a DOM wrapper overrides), at the
algorithm points the package owns: navigation (`readystatechange`, `DOMContentLoaded`, `load`, `pageshow`,
`beforeunload`, `unload`), input (the `InputDispatcher`), forms (`submit`, `formdata`, `reset`, `invalid`),
history (`popstate`, `hashchange`), observers, scripts (`load`/`error` on `<script>`). AngleSharp's own
`Dispatch` calls run into AngleSharp's listener lists, which hold nothing script-registered, so they are
invisible, and whatever AngleSharp-internal listeners exist keep working untouched. `DomNodeObject.GetParent`
implements "get the parent": parent node or assigned slot; `ShadowRoot → host`; `Document → window` except for
`load`; `Window → null`.

## 6. Navigation and the parser baton

A document fetch goes through Jint's own fetch pipeline (`FetchTransport`, engine-free by design) with the
context's cookie jar, `Referer`/`Origin`, the `UrlFilter` re-checked per redirect hop and `MaxResponseBytes`
bounding the document (engine PR B3 adds the base URL, referrer, cookie jar and observer seams). Then the
`ParserDriver` runs AngleSharp's parser on a parser thread and hands a **baton** back and forth with the page
loop: `IScriptingService.EvaluateScriptAsync` and the resource loader park the parser and give the DOM to the
loop, which runs the script (or the fetch and any due tasks) and hands it back; completions use
a blocking handshake, so the parser never resumes inline on the loop thread — it cannot resume at all until the
loop releases it. Invariant: exactly one side holds the baton, and only the holder touches the DOM. That is
also what gives browser-correct timing — timers fire while the parser waits on a parser-blocking
`<script src>`, and run nowhere while it is tokenizing, which is what a browser does because there the parser
*is* the task the event loop is running.

**What shipped, measured rather than assumed** (campaign item R3). The question the design left open —
whether AngleSharp's parse suspends and resumes on a pool thread once a `<script src>` is present — is
answered in its source and by the driver's own check: `HtmlDomBuilder.ParseAsync` awaits the task
`HandleScript` produced with `ConfigureAwait(false)`, so **yes, it does**, and the baton rather than the
fallback is what the package implements. The refinement the implementation adds is that a subresource fetch
finishes *before* AngleSharp's `IResourceLoader` returns — the loader hands the baton over and the loop
fetches while pumping — so the parse never suspends at all and stays on the thread it started on;
`ParserBaton.ParserHopped` reports it as a page error if that ever stops being true. Registering an
`IResourceLoader` is itself what makes the parse asynchronous, so it and the baton arrive together.

Classic, `defer` and `async` scripts stay AngleSharp's to prepare and order — which is what buys
parser-blocking, document order, the deferred queue and the `document.write` insertion point without
re-implementing any of them — while module scripts and import maps are hidden from it (`SupportsType` answers
`false`) and run from the driver after the parse over Jint's `IAsyncModuleLoader` against the page's fetch,
which is where HTML puts them anyway. `document.readyState` is the page's own shadow, moved
`loading` → `interactive` → `complete` with the `readystatechange` events that go with them, because
AngleSharp's setter is protected; `DOMContentLoaded` fires after the deferred and module scripts and `load`
after every subresource. `document.write` during parsing writes into the live text source while the parser is
parked, and after it is refused with a page error rather than implying `document.open()`. A `<script>` a
script inserted runs (blocking the loop for its fetch rather than pumping, since pumping from inside a
running script would run the page's jobs in the middle of one); one `innerHTML` inserted does not, which is
HTML's rule and AngleSharp's behaviour already. `<link rel=stylesheet>` is fetched and cascaded, and so is a
frame's document — AngleSharp opens it into the nested browsing context it already made for the element, and
`load` fires at the frame before the window's, HTML's "delay the load event"; an `<img>` is recorded in
`Page.Requests` as not fetched, there being nothing to render it with. The scheduling divergences this shape costs are listed in `Jint.Browser/Runtime/AGENTS.md`.

## 7. Constraints per page

The constraints gotcha in the root `AGENTS.md` applies twice over: a page is a host-driven sequence of entries,
and its event loop is pumped. So a page's budget is built only from what survives the per-entry reset.
`BrowserOptions.MaxTaskDuration` brackets each **turn** with `OperationDeadlineConstraint.Begin`/`End`, and a
turn is one mailbox request, one task (timer callback, observer/rendering task, animation-frame batch or
protocol command) and its complete microtask checkpoint, or one inline `<script>`. Browser engines separate
task and microtask lanes so recursive reactions stay in their originating task's budget even when another
task was already queued; ordinary engine hosts retain their existing FIFO. A request that runs out of budget fails its own
task with `TimeoutException`; a drain's and a script's are recorded as a `PageErrorKind.BudgetExceeded` entry
and the page survives. `BrowserOptions.MemoryLimit` arms a per-page `MemoryLimitConstraint` over the same turn,
and a worker's pump takes the same bracket over the constraint factories its parent replayed. `Page.Close` and
`Target.closeTarget` cancel the page token registered with `ObserveCancellation`; `LimitExecutionTime` still
governs each `Runtime.evaluate` / `Page.Evaluate` entry as well. `MaxDomNodes` is one number checked against two
quantities — the parse refuses a document holding more, and the wrapper factory refuses the projection that
would take one engine past the same number of node wrappers. `MaxActiveTimers`, `MaxResponseBytes` and `FetchTimeout` are the
page-sized names for their engine settings, applied to page and worker engines alike before any host callback;
one `UrlFilter` covers document, subresource, XHR, WebSocket, EventSource and worker loads.
`BrowserOptions.ForUntrustedContent` applies `Options.ForUntrustedCode` to page engines — from inside the
package's own construction callback, so it wins over a host's `ConfigureEngine` — and reaches their workers
through `CopySecurityPosture` and the replayed factories, with `BlockPrivateNetwork` on by default for every
context that did not assign it.

## 8. Input without layout

Every element gets a deterministic synthetic box — the "flat renderer" — so that `elementFromPoint`,
`getBoundingClientRect`, `DOM.getBoxModel`, `DOM.getContentQuads`, `DOM.getNodeForLocation` and
`Input.dispatchMouseEvent(x, y)` resolve without a layout engine.

**What shipped, in one sentence.** `Jint.Browser/Layout/FlatLayout` gives every *rendered* element an ordinal
in tree order and the row `[i·R, (i+1)·R)` with `R = 16`; its box starts at that row, is
`R × (1 + rendered descendants)` tall and the viewport wide. Boxes therefore nest exactly as the tree does and
never straddle, and the deepest box containing a point is always the owner of the row the point falls in — so
a hit test is a division, the centre of a leaf hits the leaf, the centre of a container hits a descendant as a
browser does, and the click bubbles back up. Rendered is HTML's set minus what a rendering would have needed:
`<head>` and its subtree, `<script>`/`<style>`/`<template>`/`<noscript>` wherever they sit, and whatever the
accessibility layer's `ElementVisibility` calls not rendered (`hidden`, `display: none`,
`visibility: hidden|collapse`); `aria-hidden` does not remove a box. An element with no box answers zeros in
script and `-32000` over the protocol, because a client reads zeros as a real box at the origin.

**Single-line horizontal flex rows refine that model.** Computed flex bases, growth and shrinkage partition
the containing width; direct children share vertical space, with stretch/start/center/end cross-axis
alignment. Their synthetic intrinsic sizes still come from rows, not fonts. DOM and protocol rectangles,
offsets, hit testing and resize observations share those boxes. Wrapping, gaps, margins, min/max sizing, main-axis justification,
ordering and positioned layout remain outside this model. The original ordinal fast path remains for
documents without horizontal flex rows.

**Scrolling is virtual and is the only state the model keeps.** A page holds a `scrollY` clamped to its
document; `window.scrollTo`/`scrollBy`/`scroll`, `element.scrollIntoView`, `DOM.scrollIntoViewIfNeeded` and a
wheel event set it, `window.scrollY`/`pageYOffset` and `document.scrollingElement.scrollTop` read it, and
every client rectangle subtracts it. Alignment uses the whole bounding box, not just its first row;
`nearest` does not move an oversized box spanning both viewport edges. `scrollX` remains zero: horizontal
overflow has no scroll range. That is what lets a client whose click path insists on "scroll it into view, then check the box is
inside the viewport" — Playwright's does — succeed on a document taller than its window.

`dispatchMouseEvent` is the pointer/mouse event sequence with focus and click activation (`<a>` navigates,
submit buttons submit, checkbox/radio toggle with legacy pre-activation rollback, `<label>` forwards,
`<summary>` toggles, `<option>` selects), and `mouseWheel` is a scroll of `deltaY`.

**The keyboard shipped, and the protocol's four types are three questions.** `dispatchKeyEvent` dispatches at
whatever the page has focused — the body when nothing is. `keyDown` fires `keydown` and, for a key that
produces text, `keypress`, then runs the whole default action; `rawKeyDown` fires `keydown` and runs the
default action *without* the insertion, because it is what a client sends for a key whose character is coming
separately or not at all — which is every editing key; `char` is that character alone, a `keypress` and then
the insertion; `keyUp` fires `keyup` always, because it is part of no default action. Modifier state is the
client's: every one of them puts the bit field in each event. `insertText` is text at the caret with no key
events, which is an IME commit and Puppeteer's `sendCharacter`; `imeSetComposition` is accepted and changes
nothing, because a candidate string never reaches the value and the commit that does arrives as `insertText`.

Editing is a string and two offsets, which needs no rendering: insertion at the selection, `Backspace` and
`Delete`, `Home`/`End`/arrows with `Shift` extending from the anchor (so `selectionDirection` is real),
`ArrowUp`/`ArrowDown` as line moves computed from the newlines in a `<textarea>`'s value, select-all,
`maxlength`, `Enter` as a newline in a `<textarea>` and as HTML's implicit submission in an `<input>`,
`Tab`/`Shift`+`Tab` along the sequential focus order, and `beforeinput` (cancelable) / `input` / `change`
around all of it. `contenteditable` is deliberately light — text spliced in one text node, the caret kept in
the document's own `Selection` — so `Enter` there does nothing rather than something structural and wrong.

**Touch is a gesture rather than an event, so the contacts outlive the command.** `dispatchTouchEvent`
hit-tests each contact against the same flat box model a mouse event uses and fires one event per *changed*
contact, which is what the protocol defines its `touchPoints` as. What a page then reads is Touch Events
§5.2's three lists — `touches` is every contact on the surface after this event's own change, so a `touchend`
does not list the finger it is announcing; `targetTouches` is the subset that started on the event's target;
`changedTouches` is the one contact the event is about — and a contact's `target` is fixed where it went
down, so a finger dragged off its button still ends on the button. A single-finger tap that nothing cancelled
then leaves §8's four compatibility mouse events at the point the finger came off, through the same helpers
`dispatchMouseEvent` uses, so a tap activates what a click activates and there is one answer to "what does a
click do" rather than two. `preventDefault()` on the `touchstart` or the first `touchmove` withdraws them, as
does a second contact or a `touchcancel`. **No pointer event is fired for a touch**: `pointerType: "touch"`
with a `pointerId` per contact and its own boundary events is a second pointer model over the same contacts.

Whether a page can *detect* touch stays a separate decision and a separate command.
`Emulation.setTouchEmulationEnabled` — and `Page.SetTouchEmulationAsync`, the same seam from the host's side
— adds Touch Events §5.4's four handler IDL attributes to the window, the document and `Element.prototype`,
answers `navigator.maxTouchPoints`, and makes `(pointer: coarse)` and `(hover: none)` match. A touch is
delivered either way: a client that sends one is asking for one, and a page that added a `touchstart`
listener hears it whether or not anybody said the device has a digitizer. Exposing `ontouchstart` unasked
would tell every responsive framework in the world this is a touch device.

WPT's `testdriver.js` is mapped onto the same dispatcher through `testdriver-vendor.js`, the file upstream
ships empty for a vendor to replace: `click`, `send_keys` and `action_sequence` resolve a WebDriver origin to
a point in the page and post it to a host function that runs the same `InputDispatcher` the `Input` domain
runs. There is one implementation, so a wpt document and a Puppeteer client cannot disagree about what a
click does.

## 8a. The page-level protocol

A page is **one target with one engine per navigation**, and that is the whole shape. `PageTarget` is a
`Jint.DevTools.DevToolsTarget`: it holds the identifier a client keeps addressing, the frame that identifier
also names, the bindings `Runtime.addBinding` installed, the scripts
`Page.addScriptToEvaluateOnNewDocument` runs and the emulation a client set; the engine under it is replaced
on every commit, and with it every handle, every script identifier and the execution context. A client that
comes back with something from the document before is told so in Chrome's own words —
`Cannot find context with specified id`, `Could not find object with given id` — rather than answered about
a value of the document that replaced it.

`Jint.Browser` reaches all of that through one seam, `IPageObserver`, and a page has exactly one observer.
`DocumentCreated` fires after the window installer and before the parse, on the loop thread, which is what
makes it the place to replace the engine, re-install the bindings and run the new-document scripts: every one
of them has to be in place before the document's first inline script. `Phase`, `SameDocumentNavigated`,
`TitleChanged`, `DialogOpening`/`DialogClosed`, `NetworkIdle` and `Closed` are the rest.

The events of a cross-document navigation, in the order the recordings show Chrome sending them:
`frameStartedNavigating`, `frameStartedLoading`, `lifecycleEvent(init)`, `frameNavigated`, then the engine
swap (`Runtime.executionContextsCleared`, `executionContextCreated`), `lifecycleEvent(commit)`,
`domContentEventFired` + `lifecycleEvent(DOMContentLoaded)`, `loadEventFired` + `lifecycleEvent(load)`,
`frameStoppedLoading`, and — after half a second of quiet — `lifecycleEvent(networkAlmostIdle)` and
`lifecycleEvent(networkIdle)`. One divergence, and it comes from where the commit is announced: Chrome
interleaves `frameNavigated` between the two context events, and here the frame and the engine swap are one
`DocumentCreated` call — the moment the next document's engine exists and nothing of it has been parsed — so
`frameNavigated` precedes the swap. A same-document move — `pushState`, a fragment, a traversal — is
`navigatedWithinDocument`.

`DevToolsServerExtensions.AddBrowser(server, browser)` is the only public member the protocol layer adds.
Every page becomes a `page` target carrying its browser context, and the server's `Target` domain mints both
through a `BrowserTargetHost`: `createBrowserContext` opens a `BrowserContext`, `createTarget` opens a `Page`
in it, `closeTarget` closes the page, `disposeBrowserContext` closes the context. Each page is published
with a **`tab` target** in front of it, which is not decoration: modern Chrome puts one between the browser
and each page, Puppeteer's browser-level `setAutoAttach` filter is *everything but a page*, and it reaches a
page by sending `setAutoAttach` again on the tab's session. A server that published pages and no tabs is one
Puppeteer connects to, discovers a page on, and then waits forever for a session — which is how it was
found.

Three divergences are deliberate and stated where they are made. An **isolated world is an alias** for the
document's own realm: there is one realm per document, so a world buys a client its own
`executionContextId` and none of the isolation the name promises. A **dialog does not block the page** —
`alert` runs on the page loop, inside the script that called it, and that loop is the thread a client's
answer would be delivered on — so `Page.handleJavaScriptDialog` sets the standing decision the next dialog
reads, and `javascriptDialogOpening` and `javascriptDialogClosed` arrive together. And
**`captureScreenshot` and `printToPDF` answer `-32000`** with a sentence that says this browser renders no
pixels and names `Jint.getMarkdown`, `Jint.getText` and `document.documentElement.outerHTML` instead.

What is accepted and not yet effective is accepted because refusing it fails an ordinary connection, and
each says which campaign item makes it real: `Network.setCacheDisabled` (there is no cache to bypass),
`Audits.enable` (nothing to report) and `Inspector.enable` (no renderer to crash; chromedp sends it on
every attachment).

**The network domains are real, and what is still absent is absent with a reason rather than pending.**
`Network` reports every request the page makes, and `Fetch` pauses one at the **request** stage or the
**response** stage, all over the page's own request log, which is the engine's `FetchObserver`; the
document's request carries the `loaderId` as its `requestId`, which is what makes a client's `goto` answer a
response object. A page's `WebSocket` takes the four events the protocol gives a socket — its creation, both
handshakes and its close — over the engine's own `WebSocketObserver`, and is deliberately *not* in the
request log, because a socket stays open for as long as the page wants it and an entry would stop
`networkIdle` firing. `Fetch.getResponseBody` answers the whole body of a response-stage pause, base64: the
bytes are read off the socket through the engine's own seam and then **replayed ahead of the unread
remainder**, so the page receives every original byte exactly once, and a client that never asks costs the
page nothing. Both the body and the base64 reply are charged to `BrowserOptions.MaxCapturedResponseBytes` —
the same allowance `Network`'s captured bodies spend — and a body that does not fit is a `-32000` error
rather than a resolved pause. What is not there: `Fetch.takeResponseBodyAsStream` and with it the `IO`
domain, because a stream handle is a second lifetime to bound for a shape no recorded client sends and the
domain's only mainstream producers are `Page.printToPDF` and `Tracing`, neither of which this browser has;
the three `webSocketFrame*` events and
`eventSourceMessageReceived`, because the socket observer is never told about a frame and a stream is
observed as bytes rather than as the events they decode into; and `Network`'s **timing** document, because
no phase of a request is measured and a document of zeros reads as a page that loaded instantly. A paused
request holds the transport thread it is being sent on and never the page loop — the one exception is a
`<script src>` a running script inserted, which blocks the loop by design, and which is exactly why the
commands that release a pause, `getResponseBody` included, are answered off the loop.

**`Emulation` is effective, and the question each command answers is *when*.** The viewport, the emulated
media type and its Level 5 preference features, touch, focus, geolocation, the user agent and the hardware
concurrency move the document that is loaded: `matchMedia` re-evaluates against a single
`PageMediaEnvironment` and every `MediaQueryList` whose own answer moved fires `change`, and `navigator`
answers differently on the next read. The time zone and the locale are `Options` an engine is *constructed*
from and a page constructs one per navigation, so those two — and script execution, which the parse refuses
— take effect on the next document, which is where every client sets them. The rest are accepted no-ops
whose summaries say what there is none of: no renderer for auto dark mode, a background colour, a scrollbar
or a CPU throttle; no idle detector; no touch event interface for a mouse event to be translated into. The
preference values belong to the page. Since AngleSharp.Css 1.1.0, `PageRenderDevice` exposes that same
environment through `IRenderDevicePreferences`, so supported stylesheet `@media` features follow live
emulation too. The local `matchMedia` evaluator remains for semantics the native evaluator still lacks,
and the page owns change-event scheduling. The user agent is one setting for two
commands — `Emulation`'s and `Network`'s — kept on the page, because `navigator.userAgent` has to answer the
same string every request carries.

`Accessibility` publishes the tree of §9's accessibility layer in Chrome's `AXNode` shape, with the `DOM`
domain's `backendNodeId` on every node — which is what makes `page.accessibility.snapshot()`, an `aria/`
selector and `getByRole` answer about a node the same client can then measure and click. `Security`,
`Overlay` and `CSS` answer what a DevTools front end sends while attaching and nothing more: `CSS` has the
computed style and the inline style, both AngleSharp.Css's, and every editing command is `-32601`.

## 9. The scoreboard

WPT is the conformance suite the way test262 is the language's, with the discipline `Jint.Tests/Wpt/AGENTS.md`
already enforces: the exclusion table is the artefact (an entry matches at least one failing test and no passing
one), `NeedsTriage` empty is the signal, the census is a ceiling that only lowers. `Jint.Tests.Browser` adds the
browser lane: the in-process `WptServer` serves `.html`, the real upstream `testharness.js`, `.headers` sidecars
and `.sub.html` substitution; a `testharnessreport.js` overlay posts results through a page binding; the existing
`.any.js` files run again inside a real `Window` realm through synthesized `.any.html` wrappers.

**What was built** is `Jint.Tests.Browser/Wpt/`, and four things about it differ from the paragraph above, each
for a reason its own [`AGENTS.md`](https://github.com/sebastienros/jint/blob/main/Jint.Tests.Browser/Wpt/AGENTS.md)
argues. The corpus is **not** vendored
twice: `Jint.Tests.Browser` references `Jint.Tests` and runs the same tree at the same pin, so there is one
corpus and one pin. The overlay posts **strings** through a host function the driver installs on every page
engine, not values through a binding, because a page's engine belongs to a thread the driver is not on. Only the
**window** wrapper is synthesized — upstream's dedicated-worker wrapper builds a *classic* worker whose body
opens with `importScripts`, which Jint runs no lane for. And an uncaught exception is **upstream's harness's**
business rather than the driver's, because the engine fires a real `error` event at the global scope and
`testharness.js` listens for it, `setup({allow_uncaught_exception: true})` included. A second overlay fills
upstream's other vendor slot, `testdriver-vendor.js`, so a document that drives input through `test_driver`
reaches the same `InputDispatcher` a protocol client does — one implementation, so the two cannot disagree.
The suites arrive a PR at a
time; the first are `dom/events` and `html/webappapis/scripting`'s events and processing-model halves, and the
rest of the list above follows through the same lane.

**The nightly `wpt run` over CDP is built**, and the two numbers it leaves the project with are not two
measurements of the same thing — which is the sentence this paragraph used to leave implied. **The census**
is *ours*: our driver, in our process, over the vendored subset, with an exclusion table that names every
failure one at a time, and it **is a gate** — `Not passing` only ever goes down. **The scoreboard** is
*upstream's*: `wpt run` over `wptserve`, across the whole of ten suites — `dom/`, `html/dom/`,
`html/semantics/scripting-1/`, `html/webappapis/`, `html/browsers/history/`, `xhr/`, `url/`, `fetch/api/`,
`FileAPI/` and `custom-elements/` — including every wrapper the manifest generates for a global this engine
has no lane for, and it **gates nothing** — a failure there is a
number on a page. The census is the one an engine change has to keep; the scoreboard is the one that can be
compared with what another engine publishes, because wpt measured it. A suite in the scoreboard that the
census does not have is a suite nobody has vendored yet, never a disagreement.

`wpt run chrome` cannot produce it: that product requires a `--webdriver-binary`, launches that
`chromedriver`, and speaks WebDriver classic — its CDP is tunnelled through `chromedriver`'s
`goog/cdp/execute` extension command, and no `debuggerAddress` capability exists anywhere in the wpt tree.
Lightpanda ships a WebDriver front end beside its CDP for exactly this reason. What is here instead is a
**wptrunner product plugin**,
[`tools/wpt-scoreboard/`](https://github.com/sebastienros/jint/tree/main/tools/wpt-scoreboard), registered
through upstream's `wptrunner.products` entry-point group so that no fork of wpt is needed: its executor
navigates a page over CDP and reads the results upstream's own `testharnessreport.js` posts, through a
`Runtime.addBinding` binding, and every judgement about whether a subtest passed stays upstream's.
`.github/workflows/wpt-scoreboard.yml` runs it nightly and commits the page to a `wpt-scoreboard` branch;
it fails only when the runner could not reach the browser or produced no report. Next to it, an obstacle course
of offline fixtures (React, Vue, Preact and Svelte TodoMVC, SSR hydration, jQuery 3 with `async: false`, htmx,
Alpine, a `pushState` router, custom elements, modules with an import map, forms with redirects, a cookie login,
`localStorage` persistence, `IntersectionObserver` and `MutationObserver` widgets, dialogs) each asserting a DOM
end state and an empty error sink, and PuppeteerSharp / Playwright for .NET smoke suites over the in-process
WebSocket.

## 10. Packages and the engine seams

| Where | What |
| --- | --- |
| `Jint/` (engine, public, additive) | B1 tree event dispatch; B2 `XMLHttpRequest` (`WebApiFeatures.XmlHttpRequest`, sync supported as a blocking wait on the engine-free transport); B3 fetch for documents (`BaseUrl`, referrer policy, `Origin`, `CookieJar`, `FetchObserver` with interception), and C3's one addition to it — `FetchInitiator.XmlHttpRequest`, plus the body chunks an `XMLHttpRequest` never handed its observer; B4 `PerformanceObserver`, `FileReader`, blob URLs |
| `Jint.Browser/` (net8.0+, `InternalsVisibleTo` from Jint for now, promoted to public seams as they prove their shape) | the runtime of §3–§8 and the page-level CDP domains, plus the custom `Jint` domain (`getMarkdown`, `getText`, `getAccessibilitySnapshot`). **No engine seam is owed for the emulation**: a page's time zone and culture are `Options.TimeZone` and `Options.Culture`, already public and already fixed at construction, so the whole of `Emulation` is the package's own |
| `Jint.Browser.Tool/` | the `jint-browser` dotnet tool. **What shipped**: `serve [--port 9222] [--host]`, `fetch <url\|file> --dump html\|text\|markdown\|ax` with `--wait-until commit\|domcontentloaded\|load\|networkidle`, `--main-content`, `--max-length`, `--header`, `--cookie`, `eval <url> <expression>` and `version`; `--untrusted`, `--user-agent`, `--max-task-duration`, `--memory-limit` and the private-network switches on every command; five exit codes so a caller can tell its own mistake from the site's from the page's. It takes **no** `InternalsVisibleTo` grant, which is what makes it the standing proof that the published surface is enough — the three seams it needed (`Page.MarkdownAsync`/`TextAsync`/`AccessibilitySnapshotAsync`, `Page.WaitForNetworkIdleAsync`, `BrowserOptions.BlockPrivateNetwork`) were promoted rather than reached around |
| `Jint.Browser.Mcp/` | the Model Context Protocol server, and `jint-browser mcp` serving it. **What shipped**: `navigate`, `back`, `forward`, `reload`, `snapshot` (`ax` with `ref=` handles, `markdown`, `text`), `click`, `fill`, `type`, `press`, `select`, `hover`, `scroll`, `evaluate`, `wait_for`, `network_requests`, `cookies`, `set_cookie`, `close`, plus `jint://page/markdown` and `jint://page/requests` as resources; hardened by default; every tool answers a `CallToolResult`, and none throws — the SDK redacts an ordinary exception's message. **`--http` did not ship**, and its own `AGENTS.md` argues why: the protocol's 2026-07-28 revision removed the session header from streamable HTTP, so the SDK's transport is stateless, per-session state needs the `[Experimental]` `RunSessionHandler` and a negotiated downgrade, and `ModelContextProtocol.AspNetCore` would put a `Microsoft.AspNetCore.App` framework reference in a `dotnet tool`. The `ref=` handles are the accessibility tree's own identifiers rather than `backendNodeId`, which belongs to a protocol target there is none of here |
| `Jint.Tests.Browser/` | the WPT browser lane, the obstacle course, the client smoke suites |

`Jint.Browser` ships `IsAotCompatible=false` in v1 and says so: AngleSharp is not trim-annotated. An AOT
inventory is a v1.1 item once its warnings are counted.

## 11. Verification

Every PR keeps the repository's gates. Engine PRs run the affected WPT `.any.js` suites and, when they touch a hot
path, the full SunSpider/Dromaeo tables. The browser lane's exclusion table and census, the obstacle course on all
four CI legs, and the two .NET client smoke suites gate `Jint.Browser`. The benchmark that closes the campaign
runs the same PuppeteerSharp script against `Jint.Browser`, headless Chromium and, where installed, Lightpanda,
and records wall time, CPU and peak memory per page load in the honest framing of §2.

**What was built** is `Jint.Tests.Browser/Fixtures/` and the two client suites beside it, and three things
about them differ from the paragraph above. The course runs on all four legs, as promised — but the
**Playwright** suite does not: its driver is a Node process the package carries, so the suite reads
`JINT_BROWSER_CLIENTS` and a `browser-clients` CI leg sets it, while PuppeteerSharp's stays on every leg
because it costs nothing. A fixture that does not pass is a **`needs triage` row** in
[`Fixtures/README.md`](https://github.com/sebastienros/jint/blob/main/Jint.Tests.Browser/Fixtures/README.md)
with the failing assertion and a
one-line diagnosis, and `FixtureInventoryTests` fails unless that set is exactly the set of cases marked
`[Explicit]` — the discipline the web-platform-tests exclusion table is under, for the same reason. Both
rows that were ever written have since been retired by the pull request that paid them: `htmx` was owed
DOM XPath and `custom-elements` was owed the registry. And the
course is a **gate on the engine as much as on the package**: the eighteen fixtures and the two clients found
seven defects between them, six of which were fixed in the pull request that added them — an
`insertBefore(node, null)` that was a `TypeError`, a selector refusal a page could not catch, a
`Target.getTargetInfo` with no identifier, a page target that named no browser context, a missing
`Node.getRootNode`, and `Storage.getCookies` answering on one session model of the two. The seventh was
recorded rather than fixed and then fixed on its own: `getComputedStyle` resolved no initial values, so
`visibility` was the empty string and Playwright's actionability check read every element of every page as
hidden — `Jint.Browser/Dom/Views/ResolvedStyle` is the ten-property exception that closed it.

## 12. What shipped, and where it differs

The sections above are the design, kept as the design. This table is the index of what was built against it:
one row per item, the pull request that built it, and the one line in which what shipped is not what was
planned. A blank last column means the section above describes what exists.

| § | What shipped | PR | Where it differs |
| --- | --- | --- | --- |
| 3 | One `PageLoop` thread per page, a new engine per navigation, and the global's `Window.prototype` chain | [#3648](https://github.com/sebastienros/jint/pull/3648) | — |
| 3 | Frame documents, realm-owned globals and classic script execution ([#3771](https://github.com/sebastienros/jint/issues/3771)) | [#3667](https://github.com/sebastienros/jint/pull/3667), realm brands [#4068](https://github.com/sebastienros/jint/pull/4068), child scripts [#4069](https://github.com/sebastienros/jint/pull/4069) | Same-origin, unsandboxed sourced frames run classic scripts and markup handlers on the page loop in their own document realms, with distinct globals, intrinsics, DOM/UI constructors and window listeners. Empty iframes, child modules, WindowProxy navigation, cross-origin/sandbox execution and child-specific browser services remain separate work. The native parser resolves [#3971](https://github.com/sebastienros/jint/issues/3971) for associated documents: its adoption observer captures creation realms before ownership changes, including detached native text/comment nodes first wrapped after adoption. A source document must be associated before its nodes leave it |
| 4 | The generator over the two pinned AngleSharp assemblies, and the checked-in `Dom/Generated/` | [#3634](https://github.com/sebastienros/jint/pull/3634) | A DOM prototype carries no `@@unscopables`, because AngleSharp's metadata does not say which members are unscopable; and a nullable `DOMString` parameter converts `null` to the string `"null"` ([#3712](https://github.com/sebastienros/jint/issues/3712)) |
| 4 | HTML §4.13: the registry, the construction stack, the element state and the reaction lane | [#3709](https://github.com/sebastienros/jint/pull/3709) | The element queue is drained as a reaction *arrives* rather than when the outermost `[CEReactions]` operation returns, and a parser-created element is upgraded at the driver's next script boundary rather than constructed by the tokenizer. There is no `ElementInternals`, so `static formAssociated` is recorded and consulted by nothing |
| 5 | The UI event interfaces, HTML's handler content attributes and every activation behaviour a click has | [#3671](https://github.com/sebastienros/jint/pull/3671), engine seams [#3696](https://github.com/sebastienros/jint/pull/3696) | — |
| 6 | The `ParserDriver` and the baton: classic, `defer` and `async` scripts, modules through the import map, `document.write`, `<link rel=stylesheet>` | [#3676](https://github.com/sebastienros/jint/pull/3676) | The refinement §6 already records: a subresource fetch finishes *before* AngleSharp's `IResourceLoader` returns, so the parse never suspends and stays on the thread it started on |
| 6 | A navigation is a fetch and a new engine; forms, history, cookies, storage and workers | [#3667](https://github.com/sebastienros/jint/pull/3667), child scripts [#4069](https://github.com/sebastienros/jint/pull/4069) | Images fetch under `MaxImageRequests` and report availability, header dimensions and load/error events without pixel decoding ([#4011](https://github.com/sebastienros/jint/pull/4011)); zero disables image requests. Supported child frames run classic scripts under the page budgets; their Web APIs use the page network configuration, child-specific browser services remain incomplete, and location writes throw ([#3771](https://github.com/sebastienros/jint/issues/3771)) |
| 5, 6 | The observers and the DOM views: `MutationObserver`, `IntersectionObserver`, `ResizeObserver`, `Range`, `TreeWalker`, `NodeIterator`, `DOMParser`, `XMLSerializer`, `getSelection` | [#3669](https://github.com/sebastienros/jint/pull/3669) | The two stubs the design named now carry real numbers from §8's model — but with no layout nothing can stop intersecting, so an observed target is reported once, fully intersecting, and a lazy list loads every page at once |
| 7 | `PageBudget`: `MaxTaskDuration` and `MemoryLimit` over a turn, `ForUntrustedContent`, `MaxDomNodes` | [#3679](https://github.com/sebastienros/jint/pull/3679) | — |
| 8 | `Layout/FlatLayout`, the hit test, the virtual scroll, `dispatchMouseEvent` and the activation behaviours | [#3697](https://github.com/sebastienros/jint/pull/3697) | Boxes are rows, so an excluded element takes its subtree with it — right for `display: none` and wrong for `visibility: hidden`, whose `visibility: visible` descendant CSS lets escape |
| 8 | `dispatchKeyEvent`, `insertText`, the editor, and `testdriver.js` over the same dispatcher | [#3703](https://github.com/sebastienros/jint/pull/3703) | `imeSetComposition` is accepted and changes nothing; `contenteditable` splices one text node, so <kbd>Enter</kbd> there does nothing rather than something structural |
| 8a | `PageTarget`, `AddBrowser`, `BrowserTargetHost` and the lifecycle events of a navigation | [#3680](https://github.com/sebastienros/jint/pull/3680), the target split [#3678](https://github.com/sebastienros/jint/pull/3678) | A **`tab` target** is published in front of every page, found by driving Puppeteer rather than by reading the protocol; and `frameNavigated` precedes the engine swap where Chrome interleaves it between the two context events |
| 8a | A domain of Jint's own — `Jint.getMarkdown`, `getText`, `getAccessibilitySnapshot` — and the screenshot refusal that names it | [#3681](https://github.com/sebastienros/jint/pull/3681), the extractors [#3657](https://github.com/sebastienros/jint/pull/3657) | — |
| 8a | `Network` reporting every request, and `Fetch` pausing one at the request stage | [#3700](https://github.com/sebastienros/jint/pull/3700) | The notifications and the interception run on the **transport thread**, not the page loop: moving them would deadlock the one fetch a page cannot pump through. The three absent lanes are [#3701](https://github.com/sebastienros/jint/issues/3701) |
| 8a | `Emulation` effective rather than accepted, and `Accessibility` in Chrome's `AXNode` shape | [#3704](https://github.com/sebastienros/jint/pull/3704), cascade integration [#3731](https://github.com/sebastienros/jint/pull/3731) and [#3861](https://github.com/sebastienros/jint/pull/3861) | Stylesheet `@media` rules now share the viewport, media type, and supported emulated preferences with `matchMedia`. The local query evaluator remains for the native semantic gaps recorded in [the divergence register](https://github.com/sebastienros/jint/blob/main/Jint.Browser/Dom/divergences.md) |
| 9 | The web-platform-tests browser lane, and the eleven defects it first recorded | [#3685](https://github.com/sebastienros/jint/pull/3685), fixes [#3699](https://github.com/sebastienros/jint/pull/3699) | The four differences §9 already records — one corpus and one pin, strings through a host function rather than values through a binding, only the window wrapper synthesized, and an uncaught exception left to upstream's harness |
| 9 | The obstacle course: eighteen offline fixtures through the `Page` API, three of them again over the protocol | [#3710](https://github.com/sebastienros/jint/pull/3710) | The Playwright suite is gated on `JINT_BROWSER_CLIENTS` because its driver is a Node process; PuppeteerSharp's runs on every leg. Two fixtures are `needs triage` rather than passing |
| 10 | The `jint-browser` command line: `serve`, `fetch`, `eval`, `version` | [#3715](https://github.com/sebastienros/jint/pull/3715) | It takes no `InternalsVisibleTo` grant, so the three seams it needed were promoted onto the package rather than reached around |
| 10 | `Jint.Browser.Mcp`, the Model Context Protocol server, and `jint-browser mcp` serving it on stdio | [#3717](https://github.com/sebastienros/jint/pull/3717) | **`--http` did not ship** — the protocol's 2026-07-28 revision removed the session header from streamable HTTP, and the two ways to hold per-session state either need an `[Experimental]` handler or put an ASP.NET Core framework reference in a `dotnet tool`; and a `ref=` is the accessibility tree's own identifier rather than a `backendNodeId`, which belongs to a protocol target an MCP session has none of |

Two decisions in the v1/not-v1 table of §2 turned out differently and are worth naming here rather than
leaving a reader to compare tables. §8's model gives both observers numbers to report.
`IntersectionObserver` still reports each target once, fully intersecting. `ResizeObserver` also reports
subsequent synthetic size changes, including hidden-to-visible transitions, from page-turn checkpoints;
its entries preserve the measured size. Callback-induced changes are deferred to another task rather than
processed through a depth-limited rendering loop. And `getComputedStyle` answers a *resolved* value for the ten
properties an automation client reads to decide whether an element can be interacted with
([#3716](https://github.com/sebastienros/jint/pull/3716)) — the smallest exception to the standing decision
against an initial-value table of our own, made because without it no supported client can drive a page.

---

--- METRICS ---
- Files Extracted: 53
- Estimated Token Budget: ~135326 tokens
- Canonical Reference: https://codewiki.google/github.com/sebastienros/jint
