jint Documentation

Javascript Interpreter for .NET

4,742 stars C# C# 53 files · ~135,407 tokens #dotnet#interpreter#javascript
RAW Doc

Technical Documentation: sebastienros/jint

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)



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 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,
EventStoreDB,
Orchard Core,
Elsa Workflows,
Docfx, and
JavaScript Engine Switcher.

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 Chrome DevTools Protocol server for debugging and profiling an engine
Jint.Browser Headless HTML, DOM, navigation, networking, storage, and content extraction
Jint.Browser.Tool jint-browser command-line tool
Jint.Browser.Mcp Model Context Protocol server for browser automation

See Additional packages
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.

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
for the complete policy and
Migrating to Jint 5
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.
Read the detailed scope and exclusions for
ECMAScript,
Web APIs, and
Jint.Browser.

  • 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
and 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
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
and the threat model
before deploying a script service.

Documentation and support

Jint is licensed under the BSD 2-Clause License.


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,
Okojo and YantraJS) and
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, 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 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):

text
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 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–
#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,
#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) and recently wrapped objects reuse their
wrappers (#2734) — backed by cached array-like
wrapper factories with lazy length (#2730),
boxing-free primitive element conversion (#2731,
extended to the Array.prototype iteration lane in
#2735), a compiled-invoker fast lane for
single-candidate interop calls (#2733) and
JSON.parse value interning plus span-based number parsing
(#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–#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), compiled CLR property and
field access instead of reflecting on every hit (#2744,
which also more than halved interop-property-access allocation, 798 → 329 KB), trimmed the method
fast lane's per-call overhead (#2745), and
memoized the converted value of a stable reference-typed property so a host array read in a loop
skips re-conversion (#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,
#2776), toWellFormed no longer rebuilding
already-well-formed strings (#2778), dense fast
paths for the callback-free array methods (#2779,
#2780) and the string-concat plus RegExp fixes
aimed at this suite's regexp row (#2781, with
follow-up concat correctness in #2811 and
#2812) — a fast-call lane for built-in calls
with declared per-argument leaf guards
(#2783,
#2828,
#2844), span- and UTF-8-based JSON parsing
(#2832), and an interop round that compiles CLR
member access for keyed writes, indexers and statics
(#2839), shares resolved members process-wide
across engines (#2798) and stops re-reflecting
on every host delegate call (#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): 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); the cheapest Number type tests and six
String.prototype search methods followed under declared per-argument guards
(#2980); and Map/Set lookups became frameless
once each instance carried a type bit the guard could check
(#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); 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). 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) 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).

text
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:

text
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

text
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:

text
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 server over a headless browser, from
Jint and Jint.HtmlParser. 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 ``, 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.

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 --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.


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

jint-browser

A headless browser on the command line, from Jint and
Jint.HtmlParser. 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 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:

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 ``
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 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.

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 implies disk mode when no
mode is supplied. Persistent disk storage requires both that directory and an explicit
--http-cache-partition 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 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.

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 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 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; for an agent,
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.

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

Automatic HTTP caching is opt-in per context; see configuration, HTTP policy and persistence.


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

Jint.DevTools

A Chrome DevTools Protocol server for
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 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.

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
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- 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 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.
The acceptance tracker
records remaining work; dependency removal does not establish complete behavioral parity.

Renderless CSS boundary

The renderless CSS contract
defines Browser's text-value scope and the deliberately limited declaration grammar.

Browser integration contract

The Browser integration contract
defines reviewed internal hooks, snapshots and parser/Browser ownership.


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

To generate test suite, run:

text
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. 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 covers standalone API usage with compiled
examples. The package README
covers standalone embedding. The contributor guide
covers parser contracts, regeneration, corpora and package verification, and the
acceptance tracker 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. 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 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 Builds character-to-parser-array dispatch once Reuse immutable lookup structures; do not build them per character/token
CharHelper 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 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 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.
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 `` 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 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 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 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/.

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">
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 to
#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.

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 out of
Fetch §5.2's 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 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
`` 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
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.
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 's `media` and `type`, and the tokenizer's own corner cases — and `img-picture-ancestor.html` is the four about which an `` 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 carries a row for each. invalid-src.html and
null-image-source.html both wait for the error an 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 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
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 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 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, 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'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),
XMLDocument has its interface (#3893), and
createDocument registers its own table. Running its seven documents one at a time
(#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, 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 resolves the remaining 20 HTML parser assertions
tracked by #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 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> 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), so all 244 of
them pass
and neither file carries a single exclusion — #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). 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 and into two interfaces HTML does not have, and
the header cell's own scope shadowed the reflected HTMLTableCellElement.scope, so answered the raw attribute value while answered the enumeration.

forms-weekmonth took eleven, all on , 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 '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: 's twelve, `<object>`'s twelve, '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>, `` 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 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), 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; 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); 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). 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, 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. The original
#3907 smoke/raw-diagnostic harness is extended under
#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 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.
The nightly URL is mutable, 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) is resolved. The collector and correctness smoke
landed in #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.

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

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 pins CLDR 48.2 (release-48-2):

Input What it gives Pinned by
cldr-dates-full-48.2.0.tgz 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 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 at release-48-2 ``, 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, 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) 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 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), 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, 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): 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 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, 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 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 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, 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); it
expects the calendar packages unpacked under <cldr-json-root>/cal//package and cldr-common-48.2.zip beside
them. It writes 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, 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//<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,
    reference/golden-styles.tsv,
    reference/range-golden.tsv and
    reference/golden-calendars.tsv 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).


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 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 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:

  • 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, at commit

text
ea39a11d80de9a08ce2af03f52125ed2e462cf84

which is npm [email protected], 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:

text
//     source:   tools/devtools-protocol/browser_protocol.json
//     protocol: version 1.3, ChromeDevTools/devtools-protocol@ea39a11… ([email protected])
//     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.


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- (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, at commit

text
a985b62a9b45c17da3e17a9f0a0b4e30c34c4a8a

encodings.json is the machine-readable form of the standard's
names-and-labels table; indexes.json holds the
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 — 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 product called
jint_browser, and the script that turns the run's wptreport.json into
the published scoreboard
— 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/) 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 —
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 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 over 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.

text
/* 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 `` — 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,
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.
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, whose harness
was tracked in #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 AMD EPYC 9V74 80-Core Processor 1.0% of one core
2 35677667091, attempt 1 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 and the
benchmark requirements 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
  • Independent repeat: 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

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 and repeat artifact 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 describe all metrics, fixtures, lane boundaries and collection commands. The pinned workflow 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 refused a 44.498% idle observation after 151 rows; partial observations were not accepted as a calibration.
  • Comparison 35711999339 completed its calibration but refused a 50.998% idle observation after 63 comparison rows. The partial comparison was excluded.
  • Comparison 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:

text
[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:

text
#data
OneTwo
#errors
3: Missing document type declaration
#document
| <html>
|   <head>
|   
|     
|       "One"
|     
|       "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 repository, at commit

text
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

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.

Additional packages

**Jint.HtmlParser** Inert markup parsing, native trees, CSS syntax, XPath and serialization without JavaScript. **Jint.DevTools** A Chrome DevTools Protocol server for an engine hosted by your application. **Jint.Browser** An in-process, non-rendering browser built with Jint and Jint.HtmlParser. **Jint.Browser.Tool** The `jint-browser` command for extraction, evaluation, CDP, and MCP. **Jint.Browser.Mcp** An MCP server that lets agents navigate and interact with non-rendered pages.

Used by

Projects using Jint include
RavenDB,
EventStoreDB,
Orchard Core,
Elsa Workflows,
Docfx, and
JavaScript Engine Switcher.


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:

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 and
Execute, Evaluate, and Invoke.


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.

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

Upgrading an existing application? Read Migrating to Jint 5 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 purpose, frameworks, and dependencies
ECMAScript support Implemented language editions and proposals
Web API features Opt-in browser and runtime APIs in Jint
Browser compatibility What Jint.Browser provides and omits
DevTools domains Engine and page CDP domains
Native AOT Trimming and native compilation
Security Trust boundaries and hardened hosting
Branches and releases 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

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.


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

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.


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, Navigation, and 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 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

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


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. For applications without an existing engine loop, see
LibraryOwned hosting.


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

Jint.DevTools

Jint.DevTools exposes a Jint engine through the
Chrome DevTools Protocol (CDP).
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

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


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 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 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- (default 0),
while an existing prerelease suffix is preserved. Browser packages pin their calculated parser dependency.
External package and API acceptance 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>Hello</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
Create, traverse, mutate, observe or select a range in a tree Document, Node, Element, DomRange Working with trees
Inspect CSS syntax or query a tree with XPath MarkupParser.ParseCss*, NativeXPath CSS syntax and XPath
Emit HTML or XML, with optional shadow roots MarkupSerializer Serialization
Bound work, collect diagnostics and coordinate readers ParseLimits, cancellation tokens Limits and threading

The package is marked AOT-compatible; the unsigned fresh-package consumer also runs as Native AOT in
CI. See Native AOT and Trimming 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, with the one-line CLAUDE.md beside it.
Its contributor guide holds regeneration, corpus and package-check
recipes outside the instruction budget. The NuGet README 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.

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 or
sponsor @sebastienros, but really prefer @lahma because he deserves
it more.

Add, update, or remove a listing

Open a sponsor listing issue
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 ``. 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

`` 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.

never appears inside. 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 ``, 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.

`` 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).

`` 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 `` 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):

text
/* 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 `` for (the rest are overrides) 1,919
…of those, documented 1,199
…of those, undocumented 720
Summaries containing a `` (public surface) 35
Summaries containing a `` (whole assembly) 452
Documentation comments nesting a `` 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 -in- 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 `` 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/, 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.
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 is taken.

This document maps the TC39 AsyncContext proposal 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, 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):

Member Spec anchor
new AsyncContext.Variable({ name, defaultValue }) #sec-asynccontext-variable
AsyncContext.Variable.prototype.run(value, func, ...args) #sec-asynccontext-variable.prototype.run
AsyncContext.Variable.prototype.get() #sec-asynccontext-variable.prototype.get
get AsyncContext.Variable.prototype.name #sec-asynccontext-variable.prototype.name
new AsyncContext.Snapshot() #sec-asynccontext-snapshot
AsyncContext.Snapshot.prototype.run(func, ...args) #sec-asynccontext-snapshot.prototype.run
AsyncContext.Snapshot.wrap(fn) #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 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):

[[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.

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() and
AsyncContextSwap(snapshotMapping), 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),
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:

  1. Let asyncContextMapping be a new empty Async Context Mapping.
  2. For each Async Context Mapping Record p of previousContextMapping, do
    1. If SameValueZero(p.[[AsyncContextKey]], asyncVariable) is false, then … Append q to asyncContextMapping.
  3. Let p be the Async Context Mapping Record { [[AsyncContextKey]]: asyncVariable, [[AsyncContextValue]]: value }.
  4. 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 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.

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.

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, 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 step "Let mapping be AsyncContextSnapshot()" capture, onto both reaction records PromiseOperations.PerformPromiseThen — both overloads, Jint/Native/Promise/PromiseOperations.cs:136 and :195
2 NewPromiseReactionJob step "Let previousContextMapping be AsyncContextSwap(reaction.[[PromiseAsyncContextMapping]])" restore PromiseOperations.RunReactionJob, Jint/Native/Promise/PromiseOperations.cs:33
3 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 capture onto the generator GeneratorInstance.GeneratorStart, Jint/Native/Generator/GeneratorInstance.cs:128
5 GeneratorResume / GeneratorResumeAbrupt restore GeneratorInstance.GeneratorResume :142, GeneratorResumeAbrupt :170
6 AsyncGeneratorStart / AsyncGeneratorResume capture / restore Jint/Native/AsyncGenerator/AsyncGeneratorInstance.cs
7 YieldExpression evaluation restore the generator's own mapping after the yield returns Nothing needed in Jint — see §3.4
8 ExecuteModule — "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 ) — "Set finalizationRegistry.[[FinalizationRegistryAsyncContextMapping]] to AsyncContextSnapshot()" capture at construction FinalizationRegistryInstance ctor, Jint/Native/FinalizationRegistry/FinalizationRegistryInstance.cs:16 — blocked, see §3.3
10 HostEnqueueFinalizationRegistryCleanupJob restore the construction-time mapping around the cleanup same file, Observer finalizer at :64 — blocked
11 HostPromiseRejectionTracker requirements list snapshot at the call, swap around the notification Host.HostPromiseRejectionTracker Jint/Runtime/Host.cs:473 → Engine.OnPromiseRejectionTracker. Host territory; see §4.4
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]]).
  2. 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, 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). 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 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, 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. 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)
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
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)
Engine.AddToEventLoop(Action) zero untouched
PerformPromiseThen (both overloads) 1 field load + 1 field store per reaction plus the storage question in §7.2
RunReactionJob 2 null tests, no protected region on the fast arm the two-arm split in §2.3
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).

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.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 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. 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) to be spec-shaped; move FinalizationRegistry cleanup off the finalizer thread onto the event loop (§3.3). 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. 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). 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), 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)'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)
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
E1 addEventListener does not capture: a listener registered inside a run and dispatched outside it sees the dispatch-time value §3.2 — 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
H1 Engine.Advanced.CaptureAsyncContext + RunWithAsyncContext round-trips a value into a host-invoked callback §4.1
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

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)) 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 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) — 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,
canvas, and
dialogs.

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,
translate,
draggable,
autofill, and
hash-name references.

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:
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; 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 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 instead of this file.

Everything normative here was read from the Chrome 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
(CDP) and 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
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 Targets) on top of the same session core. It is the subject of
    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/; /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) 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) TryGetSourceText(Program) for getScriptSource; E2 (#3614) GetStepLocations(Program) for getPossibleBreakpoints and column snapping; E3 (#3623) pause on exceptions with caught/uncaught; E4 (#3622) evaluate on any call frame; E6 (#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) 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) public read-only accessors on SampledProfile's sample, stack, frame and function tables, so the sampler of #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,
#3631 and
#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 —
4 The handshakes four automation clients and the front end actually send, recorded rather than assumed #3599 —
2, 3 EngineDispatcher, the two drains, ThreadMode, the transport and flattened sessions #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 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
5 Runtime: handles, getProperties, callFunctionOn, awaitPromise, bindings #3605, engine seam #3597 —
5 Console and Log: what a script logged and what it threw, values still attached #3606 —
5 Debugger: breakpoints by URL or script, the three steps, continueToLocation, frames, scopes, setVariableValue #3620, engine seams #3587 and #3614 —
5 Pausing where a script throws, and evaluating in any frame of the stack #3625, engine seams #3623, #3622 and #3662 —
5 Profiler: a sampled CPU profile, and precise and best-effort coverage #3629, engine seams #3608 and #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 A function value does not publish the Program it was parsed in, so [[FunctionLocation]] still resolves its script by name (#3666)
5 A position resolves to its script by identity rather than by source name #3652, engine seam #3651 —
8 The REPL's --inspect/--inspect-brk, the driven front end, and the native binary as a client #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 and
the native parser guide.

Status: finalized design; implementation under way. The authoritative statement of this design is the body
of sebastienros/jint#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, and
§12 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 instead of this file.

Everything normative here was read from the DOM, HTML,
Fetch, XMLHttpRequest and
WebIDL living standards, and from the
Chrome 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
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 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 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: 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