diff --git a/.claude/skills/kuri-server/SKILL.md b/.claude/skills/kuri-server/SKILL.md index 6c161d2..6cc0e3a 100644 --- a/.claude/skills/kuri-server/SKILL.md +++ b/.claude/skills/kuri-server/SKILL.md @@ -61,6 +61,37 @@ If you already know a tab id, set it directly with: curl -s -H "X-Kuri-Session: $SESSION" "$BASE/tab/current?tab_id=ABC123" ``` +## Choosing the right Kuri browser path + +Use the main `kuri` server for production browser automation. It drives Chrome/CDP and exposes HTTP sessions, page info, snapshots, actions, HAR, cookies, and screenshots. + +Use the separate `kuri-browser/` workspace only for the experimental Zig-native browser runtime. It is not wired into the root build and cannot replace headless Chrome yet. + +```bash +cd kuri-browser +zig build run -- render https://news.ycombinator.com --selector ".titleline a" --dump text +zig build run -- render https://todomvc.com/examples/react/dist/ --js --wait-eval "document.querySelectorAll('.todo-list li').length >= 1" +zig build run -- bench --offline +zig build run -- parity --offline +zig build run -- serve-cdp --port 9333 +``` + +`serve-cdp` exposes Chrome-style HTTP discovery plus a minimal WebSocket JSON-RPC router. It can answer basic Browser/Target/Page/Runtime/DOM methods, and `Runtime.evaluate` returns V8-shaped CDP remote objects backed by QuickJS. This is useful for protocol smoke tests, but it is not broad Playwright/Puppeteer compatibility and cannot replace Chrome yet. + +For screenshots, `kuri-browser` currently delegates to the main Kuri/CDP renderer: + +```bash +# terminal 1, repo root +zig build +./zig-out/bin/kuri + +# terminal 2 +cd kuri-browser +zig build run -- screenshot https://example.com --out example.jpg --compress --kuri-base http://127.0.0.1:8080 +``` + +`--compress` captures a PNG baseline and JPEG candidate, writes the smaller file, and reports byte savings. Current local measurement on `https://example.com`: `20,523` bytes PNG to `18,183` bytes JPEG quality 50, saving `2,340` bytes or `11%`. + ## Key endpoints ### Navigation & page control @@ -161,3 +192,4 @@ curl -s 'https://target.com/api/v4/data' \ 5. **HAR for API discovery** — start HAR before navigating, then use `/har/replay?filter=api` to find the site's API endpoints. 6. **Cookies transfer** — use `/cookies` to get browser session cookies, then make direct `curl` calls. 7. **Refs persist per snapshot only** — take a new snapshot after any navigation or meaningful DOM change. +8. **Native browser experiment is separate** — `kuri-browser` is useful for parity work and benchmarks. Its `serve-cdp` router is minimal, screenshots still use the Kuri/CDP fallback, and native layout/paint is not implemented. diff --git a/.gitignore b/.gitignore index b7c4d5a..13ff498 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ benchmarks/results/* !benchmarks/results/20260327-111742-https_vercel.com/** !benchmarks/results/20260327-111817-https_www.google.com_travel_flights_q=Flights%20to%20TPE%20from%/ !benchmarks/results/20260327-111817-https_www.google.com_travel_flights_q=Flights%20to%20TPE%20from%/** +kuri-browser/PARITY.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eef93e8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,77 @@ +# AGENTS.md + +Instructions for AI coding agents working in this repository. + +## Benchmark Honesty + +Never present a benchmark, parity score, or competitor comparison as stronger than the evidence supports. + +- Always include the exact command, branch, commit, date, machine/OS when known, Zig/Chrome versions when relevant, run mode, and whether the run was offline or live. +- Always state whether each number is locally measured, CI-measured, copied from upstream documentation, or inferred from code inspection. +- Always report skipped checks, failed probes, timeouts, warmups, iteration counts, and sample size. Do not average away failures. +- Never compare Kuri numbers against another project's README numbers as apples-to-apples unless the same hardware, URLs, browser engine, cache policy, process model, and measurement tool were used. +- Treat README benchmark tables from other projects as upstream claims until reproduced locally. +- Do not claim `kuri-browser` can replace headless Chrome, Playwright, Puppeteer, Obscura, Lightpanda, or agent-browser unless the bench proves the same protocol surface and workload. +- If a benchmark depends on Kuri's Chrome/CDP fallback, label it as fallback-backed, not native `kuri-browser` rendering. + +## Cache Disclosure + +Benchmarks must explicitly disclose cache state. + +- Prefer cold runs with fresh processes, fresh profiles, and cache-busted top-level URLs. +- If a live URL is used, add a cache-busting query parameter unless the endpoint semantics would be changed by doing so. +- If Chrome/CDP is involved, disclose whether the Chrome profile, HTTP cache, service workers, cookies, or IndexedDB may be warm. +- If a benchmark intentionally uses warm cache, label it `warm-cache` and explain why. +- If cache state is unknown, say `cache=unknown` and do not use the number for competitive claims. +- For `kuri-browser bench`, native fetch probes should use fresh runtime/fetch sessions. The screenshot probe delegates to Kuri/Chrome, so it must keep disclosing that Chrome cache can still affect fallback-rendered screenshots. + +## Current Browser Baselines + +Last refreshed from GitHub on 2026-04-26. Recheck upstream before changing comparative claims. + +| Project | What it is | Current signal | How Kuri compares today | +|---|---|---|---| +| Obscura | Rust headless browser engine for agents/scraping | README claims V8, CDP, Puppeteer/Playwright compatibility, stealth mode, and Chrome-replacement metrics. The repo is split into `obscura-dom`, `obscura-net`, `obscura-browser`, `obscura-js`, `obscura-cdp`, and `obscura-cli`; `obscura-js` uses `deno_core` and creates a V8 startup snapshot. | `kuri-browser` is behind on engine completeness, stealth, and broad CDP. It has QuickJS-backed eval and a minimal CDP shim only. Do not claim parity. | +| Lightpanda | Zig headless browser designed for AI/automation | README says it is beta, uses V8, has DOM APIs, Ajax, cookies, proxy, network interception, CDP/websocket server, Puppeteer support, MCP, and a transparent 933-page crawler benchmark. Its `build.zig.zon` depends on `lightpanda-io/zig-v8-fork`, libcurl, html5ever-related pieces, and other browser infrastructure. | `kuri-browser` is architecturally closer because it is a Zig-native experiment, but it is much smaller: QuickJS, no native layout/paint, minimal CDP, no broad Web API matrix. | +| Vercel agent-browser | Rust browser automation CLI/daemon for AI agents | It is not a new browser engine. It drives Chrome for Testing or detected Chrome via CDP, has broad CLI actions, snapshots, screenshots, PDF, HAR, sessions/profiles, React/Web Vitals tooling, and benchmarks Node daemon vs Rust daemon in Vercel Sandbox. | Main `kuri` is the closer comparison because both automate Chrome/CDP. `kuri-browser` is not comparable as a Chrome replacement yet. | + +Source URLs to recheck: + +- https://github.com/h4ckf0r0day/obscura +- https://github.com/lightpanda-io/browser +- https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md +- https://github.com/vercel-labs/agent-browser +- https://github.com/vercel-labs/agent-browser/tree/main/benchmarks + +## Competitive Comparison Rules + +- Obscura comparison must separate upstream claims from local reproduction. Its README currently does not provide enough hardware/cache/run detail for its headline table to be treated as verified here. +- Lightpanda comparison may cite its benchmark protocol because it publishes hardware, Chrome version, commit, page count, measurement tools, and commands. Still disclose that the upstream benchmark is their environment unless rerun locally. +- Agent-browser comparison must not be framed as native-browser parity. It benchmarks daemon overhead while still using Chrome, so compare it to Kuri's Chrome/CDP HTTP-agent path, not to `kuri-browser` native rendering. +- For any new comparison table, include a `Not Comparable Yet` row when workloads differ. +- If a score increases because a fallback path was added, report both native-only and fallback-backed interpretation. + +## Local Verification Baseline + +Before pushing browser-runtime benchmark changes, run: + +```sh +cd kuri-browser +zig fmt src/bench.zig src/parity.zig src/cdp_server.zig src/js_runtime.zig +zig build test +zig build +./zig-out/bin/kuri-browser bench --offline +./zig-out/bin/kuri-browser parity --offline +``` + +When running live validation, start Kuri from a known state and record whether it was a fresh process: + +```sh +cd .. +zig build +./zig-out/bin/kuri + +cd kuri-browser +./zig-out/bin/kuri-browser bench --kuri-base http://127.0.0.1:8080 +./zig-out/bin/kuri-browser parity --kuri-base http://127.0.0.1:8080 +``` diff --git a/kuri-browser/README.md b/kuri-browser/README.md new file mode 100644 index 0000000..8df56da --- /dev/null +++ b/kuri-browser/README.md @@ -0,0 +1,175 @@ +# kuri-browser + +Experimental standalone browser-runtime workspace for Kuri. + +This folder is intentionally not wired into the root `build.zig`. It exists as a separate Zig build so we can prototype a standalone fetch + DOM + JS runtime without disturbing Kuri's current Chrome/CDP path. + +## Current Layout + +- `src/model.zig`: shared `Page`, `Link`, and fallback-mode types +- `src/core.zig`: runtime shape plus page-loading orchestration +- `src/dom.zig`: parsed DOM tree plus basic selector queries +- `src/fetch.zig`: network acquisition, validation, redirects, and `curl` fallback +- `src/js_engine.zig`: QuickJS-backed page execution plus browser API shims +- `src/render.zig`: parsed-page extraction into the shared page model +- `src/native_paint.zig`: native SVG text/DOM paint output +- `src/screenshot.zig`: screenshot fallback through Kuri's existing CDP server +- `src/bench.zig`: replacement-readiness benchmark +- `src/parity.zig`: weighted parity score against Kuri's current browser surface +- `src/cdp_server.zig`: minimal Chrome-style HTTP discovery plus WebSocket JSON-RPC routing +- `src/shell.zig`: CLI-facing usage, status, roadmap, and text rendering +- `src/runtime.zig`: thin facade used by `src/main.zig` + +This is intentionally closer to the repo boundaries in `nanoapi` and `turboAPI`: stable shared types in the middle, thin shell edges, and transport/rendering logic kept separate. + +## Build + +```sh +cd kuri-browser +zig build +zig build run -- --help +zig build run -- status +zig build run -- render https://example.com +``` + +## Current Scope + +- keep Kuri's existing managed-Chrome/CDP server untouched +- prototype a Zig-native browser runtime in isolation +- use real HTTP fetch, redirects, cookies, parsed DOM, selector queries, and QuickJS-backed page evaluation +- keep a stable `Page` model so future DOM/JS layers have a fixed handoff point +- provide a small CDP discovery and WebSocket JSON-RPC shim while the native runtime evolves +- keep full native CSS layout, raster screenshots, PDF, broad CDP domain coverage, and full Playwright/Puppeteer compatibility out of scope until the runtime is stable + +This is not wired into the root `zig build`, and it is not a production replacement for Kuri's managed Chrome path yet. + +## Current Commands + +```sh +zig build run -- status +zig build run -- roadmap +zig build run -- parity --offline +zig build run -- bench --offline +zig build run -- render https://news.ycombinator.com +zig build run -- render https://example.com --dump html +zig build run -- render https://news.ycombinator.com --dump links +zig build run -- render https://news.ycombinator.com --selector ".titleline a" --dump text +zig build run -- render https://todomvc.com/examples/react/dist/ --js --wait-eval "document.querySelectorAll('.todo-list li').length >= 1" +zig build run -- render https://example.com --har example.har +zig build run -- paint https://example.com --out example.svg +zig build run -- serve-cdp --port 9333 +``` + +### CDP Shim + +`serve-cdp` is an experimental compatibility shim, not a full browser protocol implementation. + +```sh +zig build run -- serve-cdp --port 9333 +curl http://127.0.0.1:9333/json/version +curl http://127.0.0.1:9333/json/list +``` + +The advertised `webSocketDebuggerUrl` upgrades to WebSocket and routes a small JSON-RPC surface: `Browser.getVersion`, basic `Target` lifecycle, `Runtime.evaluate`, `Page.navigate`, `Page.getFrameTree`, `DOM.getDocument`, and no-op enable/input methods. Runtime values are V8-shaped CDP remote objects backed by the existing QuickJS page runtime; no V8 dependency is added. + +This is enough for local protocol smoke tests and parity tracking. It is not enough to replace Chrome for Playwright/Puppeteer yet because sessions, isolated worlds, robust target/frame events, locator actionability, screenshots, tracing, downloads, and native layout/paint are still incomplete. + +### Native SVG Paint + +`paint` writes a native SVG approximation directly from the fetched page model: + +```sh +zig build run -- paint https://example.com --out example.svg +zig build run -- paint https://quotes.toscrape.com/js/ --js --out quotes.svg +``` + +This does not call Kuri/CDP or Chrome. With `--js`, it executes the page in the QuickJS DOM shim, serializes `document.documentElement.outerHTML`, reparses that mutated DOM, and paints the serialized page. It is useful for fast, token-light visual context from page title, text, links, form controls, images, and code blocks. It is not CSS layout, raster screenshot, PDF, canvas, video, or pixel-equivalent rendering. + +Check pixel parity against real Chrome before treating this as a renderer replacement: + +```sh +zig build +python3 tools/paint_parity.py https://example.com --keep-artifacts +python3 tools/paint_parity.py https://example.com --direct-svg --keep-artifacts +python3 tools/paint_parity.py https://quotes.toscrape.com/js/ --paint-js --keep-artifacts +``` + +Current local Chrome comparison on `https://example.com` at `1280x720`: + +- Chrome actual screenshot: `16,577` bytes +- Native SVG paint artifact: `758` bytes +- Native SVG rasterized through a no-margin HTML wrapper: `16,583` bytes +- Exact matching pixels through wrapper: `99.35%` +- Mean absolute RGB delta through wrapper: `0.48/255` +- Direct standalone SVG screenshot exact matching pixels: `87.27%` + +So this is much closer for the simple `example.com` target, but it is still not 1:1. Exact pixel parity requires matching Chrome's layout, font shaping, antialiasing, viewport behavior, and raster pipeline, not just drawing similar SVG text. + +Current local Hacker News comparison on `https://news.ycombinator.com` at `1280x720`: + +- Chrome actual screenshot: `159,387` bytes +- Native SVG paint artifact: `10,127` bytes +- Native SVG rasterized through wrapper: `146,370` bytes +- Exact matching pixels through wrapper: `88.06%` +- Mean absolute RGB delta through wrapper: `10.58/255` + +Current local JS-rendered page comparison on `https://quotes.toscrape.com/js/` with `--paint-js` at `1280x720`: + +- Chrome actual screenshot: `71,989` bytes +- Native SVG paint artifact: `8,457` bytes +- Native SVG rasterized through wrapper: `68,496` bytes +- Exact matching pixels through wrapper: `90.32%` +- Mean absolute RGB delta through wrapper: `7.47/255` + +### Screenshot Fallback + +`kuri-browser` can capture screenshots through the existing Kuri/CDP renderer while full native layout and raster paint are still missing. + +Start the normal Kuri server in another terminal: + +```sh +cd .. +zig build +./zig-out/bin/kuri +``` + +Then run: + +```sh +cd kuri-browser +zig build run -- screenshot https://example.com --out example.png --kuri-base http://127.0.0.1:8080 +zig build run -- screenshot https://example.com --out example.jpg --compress --kuri-base http://127.0.0.1:8080 +zig build run -- screenshot https://www.singaporeair.com/en_UK/sg/home#/book/bookflight --out sia.png --kuri-base http://127.0.0.1:8080 --desktop-user-agent --wait-ms 15000 +``` + +`--compress` is token-oriented. It captures a PNG baseline, captures a JPEG candidate, keeps whichever file is smaller, fixes the output extension to match the selected format, and prints: + +- `original-bytes`: PNG baseline size +- `bytes`: selected output size +- `saved-bytes`: byte delta versus PNG +- `saved-percent`: rounded percentage saved versus PNG + +Current local measurement on `https://example.com`: PNG `20,523` bytes to JPEG quality 50 `18,183` bytes, saving `2,340` bytes or `11%`. + +For heavier JS sites, `--wait-ms`, `--wait-selector`, `--wait-timeout-ms`, `--user-agent`, and `--desktop-user-agent` make the CDP fallback wait for late-rendered app shells before capture. This is still Chrome/CDP fallback behavior, not native Kuri layout. + +### Readiness Checks + +Use these commands to keep the experiment honest: + +```sh +zig build test +zig build run -- parity --offline +zig build run -- bench --offline +zig build run -- bench --kuri-base http://127.0.0.1:8080 +``` + +The current live bench is useful for tracking progress, but the answer is still "not ready to replace headless Chrome" until broader CDP browser domains, full native layout/raster paint, pixel-parity checks, and Playwright/Puppeteer lifecycle support exist. + +## Target Direction + +1. HTTP navigation, redirects, cookies, and resource loading +2. DOM tree construction and selector queries +3. Embedded JS runtime for page execution +4. Agent-facing snapshot/evaluate APIs +5. Broader CDP and Playwright/Puppeteer compatibility once the core runtime is stable diff --git a/kuri-browser/build.zig b/kuri-browser/build.zig new file mode 100644 index 0000000..65c4e81 --- /dev/null +++ b/kuri-browser/build.zig @@ -0,0 +1,106 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const quickjs_dep = b.dependency("quickjs", .{ + .target = target, + .optimize = optimize, + }); + + const jsengine_mod = b.createModule(.{ + .root_source_file = b.path("../src/js_engine.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + jsengine_mod.addImport("quickjs", quickjs_dep.module("quickjs")); + + const root_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + root_mod.addImport("jsengine", jsengine_mod); + root_mod.addImport("quickjs", quickjs_dep.module("quickjs")); + + const exe = b.addExecutable(.{ + .name = "kuri-browser", + .root_module = root_mod, + }); + exe.root_module.linkLibrary(quickjs_dep.artifact("quickjs-ng")); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run kuri-browser"); + run_step.dependOn(&run_cmd.step); + + const main_test_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + main_test_mod.addImport("jsengine", jsengine_mod); + main_test_mod.addImport("quickjs", quickjs_dep.module("quickjs")); + const main_tests = b.addTest(.{ + .root_module = main_test_mod, + }); + main_tests.root_module.linkLibrary(quickjs_dep.artifact("quickjs-ng")); + const run_main_tests = b.addRunArtifact(main_tests); + + const runtime_test_mod = b.createModule(.{ + .root_source_file = b.path("src/runtime.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + runtime_test_mod.addImport("jsengine", jsengine_mod); + runtime_test_mod.addImport("quickjs", quickjs_dep.module("quickjs")); + const runtime_tests = b.addTest(.{ + .root_module = runtime_test_mod, + }); + runtime_tests.root_module.linkLibrary(quickjs_dep.artifact("quickjs-ng")); + const run_runtime_tests = b.addRunArtifact(runtime_tests); + + const jsengine_test_mod = b.createModule(.{ + .root_source_file = b.path("../src/js_engine.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + jsengine_test_mod.addImport("quickjs", quickjs_dep.module("quickjs")); + const jsengine_tests = b.addTest(.{ + .root_module = jsengine_test_mod, + }); + jsengine_tests.root_module.linkLibrary(quickjs_dep.artifact("quickjs-ng")); + const run_jsengine_tests = b.addRunArtifact(jsengine_tests); + + const engine_test_mod = b.createModule(.{ + .root_source_file = b.path("src/engine.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + const engine_tests = b.addTest(.{ + .root_module = engine_test_mod, + // Restrict to tests defined in engine.zig itself; dom.zig has its own + // test entry points exercised via main_tests/jsengine_tests, where its + // allocator pairing is known to be valid. + .filters = &.{"engine.test"}, + }); + const run_engine_tests = b.addRunArtifact(engine_tests); + + const test_step = b.step("test", "Run kuri-browser tests"); + test_step.dependOn(&run_main_tests.step); + test_step.dependOn(&run_runtime_tests.step); + test_step.dependOn(&run_jsengine_tests.step); + test_step.dependOn(&run_engine_tests.step); +} diff --git a/kuri-browser/build.zig.zon b/kuri-browser/build.zig.zon new file mode 100644 index 0000000..c0079e4 --- /dev/null +++ b/kuri-browser/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .kuri_browser, + .version = "0.0.0", + .dependencies = .{ + .quickjs = .{ + .path = "../zig-pkg/quickjs_ng-0.0.0-0cZnA8XHAwCc95T1GAebWrw-SGEwp1Y0fUAmilP8xGuS", + }, + }, + .fingerprint = 0x6465a3ac30ba5041, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "README.md", + "src", + }, +} diff --git a/kuri-browser/parity-percent.md b/kuri-browser/parity-percent.md new file mode 100644 index 0000000..effad88 --- /dev/null +++ b/kuri-browser/parity-percent.md @@ -0,0 +1,203 @@ +# Kuri Browser Parity Percent + +Target in this file is **Kuri's real Chrome/CDP server**. + +This score is meant to answer a narrower question: + +- How much of Kuri's current browser-facing surface does `kuri-browser` cover natively today? +- Which parts are actually runnable against a live Kuri server instead of being estimated by inspection? + +## Current Score + +- Estimated feature parity: **78%** +- Automated validation coverage: **74%** of the target surface +- Offline replacement-readiness bench: **70%**, not ready +- Last live replacement-readiness bench: **74%**, not ready, measured against local Kuri on 2026-04-26 +- Last live parity validation: **47%** of the full target surface, with all cache-busted live probes passing on 2026-04-26 +- Last CDP surface live smoke: **22 of 22 calls passed** on 2026-04-27, including `Schema.getDomains`, `Browser.getWindowForTarget`, `Runtime.callFunctionOn` (real eval, returns 42 from `(a,b)=>a*b` with args `[6,7]`), `Network.setCookies`/`Network.getAllCookies` round-trip, `Emulation.setDeviceMetricsOverride` reflected in `Page.getLayoutMetrics`, and `DOM.querySelector` + `DOM.getOuterHTML` returning the literal `

Example Domain

` from a live `Page.navigate https://example.com`. +- Last engine + paint live smoke: `Page.captureSnapshot` on `https://example.com` (viewport 800×600) returns 2857 bytes of real CSS-aware SVG via the `engine.zig` layout + paint pipeline; the H1 emits at `font-size:24px` (author `1.5em` × 16px) and `font-weight:700` (UA bold), the body paragraph at 16px / weight 400, and the anchor at `fill:#334488` with `text-decoration:underline` (UA `a:link` underline + author `color:#348`). Word-wraps inside the 480px-wide content area. The same engine drives the CLI: `kuri-browser paint https://example.com` produces a 2,966-byte engine SVG with body painted as `#EEEEEE` at `x=256` (post `71578b0`). The unified renderer at commit `8c9d8a9` adds per-character glyph width tables (sans/serif/mono with bold scaling), CSS `white-space` collapsing + `
` + `text-indent`, replaced-element rendering for ``/`
`/``/` + \\ + \\ + \\ + \\ + ; + var document = try dom.Document.parse(std.testing.allocator, html); + defer document.deinit(); + + const nodes = try buildInteractiveSnapshot(std.testing.allocator, &document, document.root()); + defer freeSnapshot(std.testing.allocator, nodes); + + try std.testing.expectEqual(@as(usize, 4), nodes.len); + try std.testing.expect(nodes[0].node_id != 0); + try std.testing.expectEqualStrings("e0", nodes[0].ref); + try std.testing.expectEqualStrings("link", nodes[0].role); + try std.testing.expectEqualStrings("Docs", nodes[0].name); + try std.testing.expectEqualStrings("button", nodes[1].role); + try std.testing.expectEqualStrings("disabled=true", nodes[1].state); + try std.testing.expectEqualStrings("textbox", nodes[2].role); + try std.testing.expectEqualStrings("Email", nodes[2].name); + try std.testing.expectEqualStrings("hi@example.com", nodes[2].value); + try std.testing.expectEqualStrings("checkbox", nodes[3].role); + try std.testing.expectEqualStrings("checked=true", nodes[3].state); +} + +test "snapshot compact format is agent-friendly" { + const html = + \\ + \\
More
+ \\ Pricing + \\ + ; + var document = try dom.Document.parse(std.testing.allocator, html); + defer document.deinit(); + + const nodes = try buildInteractiveSnapshot(std.testing.allocator, &document, document.root()); + defer freeSnapshot(std.testing.allocator, nodes); + + const compact = try formatCompact(std.testing.allocator, nodes); + defer std.testing.allocator.free(compact); + + try std.testing.expect(std.mem.indexOf(u8, compact, "button \"More\" @e0 [expanded=true]") != null); + try std.testing.expect(std.mem.indexOf(u8, compact, "link \"Pricing\" @e1 desc=\"/pricing\"") != null); +} diff --git a/kuri-browser/tools/calibrate_widths.py b/kuri-browser/tools/calibrate_widths.py new file mode 100755 index 0000000..ef6527a --- /dev/null +++ b/kuri-browser/tools/calibrate_widths.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Calibrate per-character glyph width tables against real Chrome. + +This produces the FONT_SANS / FONT_SERIF tables and the mono ratio used by +``kuri-browser/src/engine.zig`` so SVG paint x-positions align with Chrome's +rasterized text. + +Approach +-------- +For each font family (sans-serif / Times / Courier) we generate an HTML page +containing a ```` per printable ASCII character (0x20..0x7E). A short +inline `` + +""" + + +# --------------------------------------------------------------------------- +# Chrome driver +# --------------------------------------------------------------------------- + +def chrome_dump_dom(chrome: str, html_path: Path, timeout: float) -> str: + """Run ``chrome --dump-dom`` against ``html_path`` and return the rendered + HTML stdout. Raises if Chrome fails or produces no usable output.""" + with tempfile.TemporaryDirectory(prefix="kuri-calib-") as profile_dir: + cmd = [ + chrome, + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--hide-scrollbars", + "--disable-background-networking", + "--disable-component-update", + "--no-first-run", + "--no-default-browser-check", + f"--user-data-dir={profile_dir}", + "--virtual-time-budget=2000", + "--run-all-compositor-stages-before-draw", + "--dump-dom", + f"file://{html_path}", + ] + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + stdout, stderr = proc.communicate() + if proc.returncode not in (0, -signal.SIGKILL): + raise SystemExit( + f"Chrome --dump-dom failed (rc={proc.returncode}):\n{stderr}" + ) + return stdout + + +_TITLE_RE = re.compile(r"__W__(.*?)__E__", re.DOTALL) + + +def parse_widths(html: str) -> dict[int, float]: + m = _TITLE_RE.search(html) + if not m: + # Try a broader fallback: hunt for the marker anywhere. + m2 = re.search(r"__W__(.*?)__E__", html, re.DOTALL) + if not m2: + raise SystemExit("Could not find width payload in Chrome output.") + payload = m2.group(1) + else: + payload = m.group(1) + raw = json.loads(payload) + out: dict[int, float] = {} + for k, v in raw.items(): + out[int(k, 16)] = float(v) + return out + + +def measure(chrome: str | None, font_css: str, timeout: float) -> dict[int, float]: + """Return measured pixel widths keyed by codepoint for ``font_css``.""" + if chrome is None: + raise RuntimeError("chrome path is required for measure()") + html = make_html(font_css) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".html", delete=False, encoding="utf-8" + ) as fh: + fh.write(html) + path = Path(fh.name) + try: + rendered = chrome_dump_dom(chrome, path, timeout) + return parse_widths(rendered) + finally: + try: + path.unlink() + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Zig source emission +# --------------------------------------------------------------------------- + +def _zig_char_literal(cp: int) -> str: + """Return the Zig source token used to index the table, e.g. ``' '`` or + ``'A'`` or ``0x7E`` for chars that need no special escape but where the + Zig literal is awkward.""" + c = chr(cp) + if c == "\\": + return "'\\\\'" + if c == "'": + return "'\\''" + if 0x20 <= cp <= 0x7E: + return f"'{c}'" + return f"0x{cp:02X}" + + +SANS_DEFAULT = 0.55 +SERIF_DEFAULT = 0.50 + + +def emit_table(name: str, ratios: dict[int, float], default: float) -> str: + lines = [ + f"const {name}: [128]f64 = blk: {{", + " var t: [128]f64 = undefined;", + " var i: usize = 0;", + f" while (i < 128) : (i += 1) t[i] = {default:.2f};", + " i = 0;", + " while (i < 0x20) : (i += 1) t[i] = 0;", + " t[0x7F] = 0;", + ] + for cp in PRINTABLE: + lit = _zig_char_literal(cp) + ratio = ratios.get(cp, default) + lines.append(f" t[{lit}] = {ratio:.3f};") + lines.append(" break :blk t;") + lines.append("};") + return "\n".join(lines) + + +def widths_to_ratios(widths: dict[int, float]) -> dict[int, float]: + return {cp: w / FONT_SIZE for cp, w in widths.items()} + + +def emit_all(sans: dict[int, float], serif: dict[int, float], mono_ratio: float) -> str: + parts = [ + "// Per-character glyph width tables tuned to Chrome's macOS UA fonts.", + "// Widths are in units of font_size. Bold adds ~6%. Italic does not widen", + "// (real italic fonts have the same advance widths as upright).", + "//", + "// Three families:", + "// - sans-serif (Helvetica/Arial-style proportions, default)", + "// - serif (Times-style, slightly narrower lowercase, wider some uppercase)", + f"// - monospace (every char same width, ~{mono_ratio:.2f})", + "", + emit_table("FONT_SANS", sans, SANS_DEFAULT), + "", + emit_table("FONT_SERIF", serif, SERIF_DEFAULT), + "", + f"// Mono ratio (Courier/Menlo advance width / em): {mono_ratio:.4f}", + ] + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--chrome", default=os.environ.get("CHROME", DEFAULT_CHROME)) + p.add_argument("--no-chrome", action="store_true", + help="skip Chrome and emit hand-tuned fallback values") + p.add_argument("--timeout", type=float, default=30.0) + p.add_argument("--json", action="store_true", + help="also print raw measured pixel widths as JSON") + return p.parse_args() + + +def main() -> int: + args = parse_args() + + chrome_path: str | None = args.chrome + use_chrome = not args.no_chrome + if use_chrome and (not chrome_path or not Path(chrome_path).exists()): + # Try $PATH lookup for ``google-chrome``. + alt = shutil.which("google-chrome") or shutil.which("chromium") + if alt: + chrome_path = alt + else: + print( + f"warning: Chrome not found at {chrome_path!r} — " + "falling back to hand-tuned values", + file=sys.stderr, + ) + use_chrome = False + + if use_chrome: + print(f"calibrating against {chrome_path}", file=sys.stderr) + sans_widths = measure( + chrome_path, + f"{FONT_SIZE}px system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif", + args.timeout, + ) + serif_widths = measure( + chrome_path, + f"{FONT_SIZE}px Times, 'Times New Roman', serif", + args.timeout, + ) + mono_widths = measure( + chrome_path, + f"{FONT_SIZE}px Menlo, Courier, 'Courier New', monospace", + args.timeout, + ) + sans = widths_to_ratios(sans_widths) + serif = widths_to_ratios(serif_widths) + mono_map = widths_to_ratios(mono_widths) + # All chars should be the same width; take the median of printable + # (skip 0x20 which some fonts render at half-width). + ratios = sorted(mono_map[cp] for cp in PRINTABLE if cp != 0x20) + mono = ratios[len(ratios) // 2] + if args.json: + print(json.dumps({ + "sans_px": sans_widths, + "serif_px": serif_widths, + "mono_px": mono_widths, + "mono_ratio": mono, + }, indent=2, sort_keys=True)) + else: + print("using hand-tuned fallback values", file=sys.stderr) + sans = dict(FALLBACK_SANS) + serif = dict(FALLBACK_SERIF) + mono = FALLBACK_MONO + + print(emit_all(sans, serif, mono)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/kuri-browser/tools/paint_parity.py b/kuri-browser/tools/paint_parity.py new file mode 100644 index 0000000..dd85fa8 --- /dev/null +++ b/kuri-browser/tools/paint_parity.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Compare kuri-browser native SVG paint against real Chrome pixels. + +This intentionally uses only the Python standard library so it can run on a +fresh checkout. It renders the target URL in Chrome, renders kuri-browser's SVG +paint output in Chrome at the same viewport, then computes exact-pixel and RGB +delta metrics. +""" + +from __future__ import annotations + +import argparse +import math +import os +from pathlib import Path +import shutil +import signal +import struct +import subprocess +import sys +import tempfile +import zlib + + +DEFAULT_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("url", nargs="?", default="https://example.com") + parser.add_argument("--kuri-browser", default="./zig-out/bin/kuri-browser") + parser.add_argument("--chrome", default=os.environ.get("CHROME", DEFAULT_CHROME)) + parser.add_argument("--viewport", default="1280x720") + parser.add_argument("--timeout", type=float, default=15.0) + parser.add_argument("--out-dir") + parser.add_argument("--keep-artifacts", action="store_true") + parser.add_argument("--direct-svg", action="store_true", help="rasterize the SVG file directly instead of through a no-margin HTML wrapper") + parser.add_argument("--chrome-virtual-time-ms", type=int, default=0, help="pass --virtual-time-budget to Chrome screenshots for heavy JS pages") + parser.add_argument("--chrome-user-agent", help="override Chrome's user agent for the reference and SVG-raster screenshots") + parser.add_argument("--paint-js", action="store_true", help="enable kuri-browser JS execution before native paint") + parser.add_argument("--paint-wait-selector", help="wait for a selector in the native paint JS runtime") + parser.add_argument("--paint-wait-eval", help="wait for a JS expression in the native paint JS runtime") + parser.add_argument("--require-exact", type=float, default=None, help="fail if exact pixel match percent is below this threshold") + return parser.parse_args() + + +def parse_viewport(value: str) -> tuple[int, int]: + parts = value.lower().split("x", 1) + if len(parts) != 2: + raise SystemExit(f"invalid viewport {value!r}; expected WIDTHxHEIGHT") + return int(parts[0]), int(parts[1]) + + +def run_checked(cmd: list[str], timeout: float, expected_file: Path | None = None) -> subprocess.CompletedProcess[str]: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + stdout, stderr = proc.communicate() + if expected_file is None or not expected_file.exists(): + raise SystemExit(f"command timed out before producing output: {' '.join(cmd)}\n{stderr}") + if proc.returncode not in (0, -signal.SIGKILL) and (expected_file is None or not expected_file.exists()): + raise SystemExit(f"command failed: {' '.join(cmd)}\n{stderr}") + return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr) + + +def chrome_screenshot( + chrome: Path, + url: str, + png_path: Path, + profile_dir: Path, + width: int, + height: int, + timeout: float, + virtual_time_ms: int = 0, + user_agent: str | None = None, +) -> None: + cmd = [ + str(chrome), + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--disable-background-networking", + "--disable-component-update", + "--no-first-run", + "--no-default-browser-check", + f"--user-data-dir={profile_dir}", + f"--window-size={width},{height}", + f"--screenshot={png_path}", + ] + if virtual_time_ms > 0: + cmd.append(f"--virtual-time-budget={virtual_time_ms}") + if user_agent: + cmd.append(f"--user-agent={user_agent}") + cmd.append(url) + run_checked(cmd, timeout, png_path) + if not png_path.exists() or png_path.stat().st_size == 0: + raise SystemExit(f"Chrome did not write screenshot: {png_path}") + + +def svg_background(svg_path: Path) -> str: + text = svg_path.read_text(errors="replace") + marker = 'fill="' + marker_pos = text.find(marker) + if marker_pos == -1: + return "#ffffff" + start = marker_pos + len(marker) + end = text.find('"', start) + if end == -1: + return "#ffffff" + return text[start:end] + + +def write_svg_wrapper(svg_path: Path, wrapper_path: Path, width: int, height: int) -> None: + background = svg_background(svg_path) + wrapper_path.write_text( + "" + f'' + f'' + "" + ) + + +def read_png(path: Path) -> tuple[int, int, int, list[list[int]]]: + data = path.read_bytes() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise SystemExit(f"{path}: not a PNG") + + pos = 8 + width = height = bit_depth = color_type = None + raw = b"" + while pos < len(data): + length = struct.unpack(">I", data[pos : pos + 4])[0] + pos += 4 + chunk_type = data[pos : pos + 4] + pos += 4 + chunk = data[pos : pos + length] + pos += length + 4 + if chunk_type == b"IHDR": + width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(">IIBBBBB", chunk) + if bit_depth != 8 or color_type not in (2, 6) or compression or filter_method or interlace: + raise SystemExit(f"{path}: unsupported PNG bit={bit_depth} color={color_type} interlace={interlace}") + elif chunk_type == b"IDAT": + raw += chunk + elif chunk_type == b"IEND": + break + + if width is None or height is None or color_type is None: + raise SystemExit(f"{path}: missing PNG header") + + channels = 4 if color_type == 6 else 3 + stride = width * channels + decoded = zlib.decompress(raw) + rows: list[list[int]] = [] + previous = [0] * stride + offset = 0 + + for _ in range(height): + filter_type = decoded[offset] + offset += 1 + scan = list(decoded[offset : offset + stride]) + offset += stride + row = [0] * stride + for i, value in enumerate(scan): + left = row[i - channels] if i >= channels else 0 + up = previous[i] + upper_left = previous[i - channels] if i >= channels else 0 + if filter_type == 0: + decoded_value = value + elif filter_type == 1: + decoded_value = (value + left) & 0xFF + elif filter_type == 2: + decoded_value = (value + up) & 0xFF + elif filter_type == 3: + decoded_value = (value + ((left + up) // 2)) & 0xFF + elif filter_type == 4: + p = left + up - upper_left + pa = abs(p - left) + pb = abs(p - up) + pc = abs(p - upper_left) + predictor = left if pa <= pb and pa <= pc else (up if pb <= pc else upper_left) + decoded_value = (value + predictor) & 0xFF + else: + raise SystemExit(f"{path}: unsupported PNG filter {filter_type}") + row[i] = decoded_value + rows.append(row) + previous = row + + return width, height, channels, rows + + +def compare_pngs(actual_path: Path, native_path: Path) -> dict[str, float | int]: + actual = read_png(actual_path) + native = read_png(native_path) + if actual[:2] != native[:2]: + raise SystemExit(f"dimension mismatch: actual={actual[0]}x{actual[1]} native={native[0]}x{native[1]}") + + width, height = actual[0], actual[1] + total = width * height + exact = 0 + changed = 0 + sum_abs = 0 + sum_squared = 0 + max_delta = 0 + + for y in range(height): + actual_row = actual[3][y] + native_row = native[3][y] + for x in range(width): + actual_i = x * actual[2] + native_i = x * native[2] + dr = abs(actual_row[actual_i] - native_row[native_i]) + dg = abs(actual_row[actual_i + 1] - native_row[native_i + 1]) + db = abs(actual_row[actual_i + 2] - native_row[native_i + 2]) + if dr == 0 and dg == 0 and db == 0: + exact += 1 + else: + changed += 1 + sum_abs += dr + dg + db + sum_squared += dr * dr + dg * dg + db * db + max_delta = max(max_delta, dr, dg, db) + + return { + "width": width, + "height": height, + "total_pixels": total, + "exact_pixels": exact, + "exact_percent": exact * 100.0 / total, + "changed_percent": changed * 100.0 / total, + "mean_abs_rgb_delta": sum_abs / (total * 3), + "rms_rgb_delta": math.sqrt(sum_squared / (total * 3)), + "max_channel_delta": max_delta, + } + + +def main() -> int: + args = parse_args() + width, height = parse_viewport(args.viewport) + chrome = Path(args.chrome) + kuri_browser = Path(args.kuri_browser) + if not chrome.exists(): + raise SystemExit(f"Chrome binary not found: {chrome}") + if not kuri_browser.exists(): + raise SystemExit(f"kuri-browser binary not found: {kuri_browser}; run `zig build` first") + + owned_tmp = args.out_dir is None + out_dir = Path(args.out_dir) if args.out_dir else Path(tempfile.mkdtemp(prefix="kuri-paint-parity.")) + out_dir.mkdir(parents=True, exist_ok=True) + actual_png = out_dir / "actual-chrome.png" + native_svg = out_dir / "native-paint.svg" + native_wrapper = out_dir / "native-paint-wrapper.html" + native_png = out_dir / "native-paint-rasterized.png" + + try: + paint_cmd = [str(kuri_browser), "paint", args.url, "--out", str(native_svg)] + if args.paint_js: + paint_cmd.append("--js") + if args.paint_wait_selector: + paint_cmd.extend(["--wait-selector", args.paint_wait_selector]) + if args.paint_wait_eval: + paint_cmd.extend(["--wait-eval", args.paint_wait_eval]) + run_checked(paint_cmd, args.timeout, native_svg) + chrome_screenshot( + chrome, + args.url, + actual_png, + out_dir / "chrome-actual-profile", + width, + height, + args.timeout, + args.chrome_virtual_time_ms, + args.chrome_user_agent, + ) + native_url = native_svg.resolve().as_uri() + raster_mode = "direct-svg" + if not args.direct_svg: + write_svg_wrapper(native_svg, native_wrapper, width, height) + native_url = native_wrapper.resolve().as_uri() + raster_mode = "html-wrapper" + chrome_screenshot( + chrome, + native_url, + native_png, + out_dir / "chrome-native-profile", + width, + height, + args.timeout, + args.chrome_virtual_time_ms, + args.chrome_user_agent, + ) + metrics = compare_pngs(actual_png, native_png) + + print("kuri-browser native paint pixel parity") + print(f"url: {args.url}") + print(f"viewport: {width}x{height}") + print(f"native-raster-mode: {raster_mode}") + print(f"native-js: {'yes' if args.paint_js or args.paint_wait_selector or args.paint_wait_eval else 'no'}") + print(f"chrome-virtual-time-ms: {args.chrome_virtual_time_ms}") + print(f"chrome-user-agent: {'custom' if args.chrome_user_agent else 'default'}") + print(f"artifacts: {out_dir}") + print(f"actual-png-bytes: {actual_png.stat().st_size}") + print(f"native-svg-bytes: {native_svg.stat().st_size}") + print(f"native-rasterized-png-bytes: {native_png.stat().st_size}") + print(f"exact-pixels: {metrics['exact_pixels']}/{metrics['total_pixels']} ({metrics['exact_percent']:.2f}%)") + print(f"changed-pixels: {metrics['changed_percent']:.2f}%") + print(f"mean-abs-rgb-delta: {metrics['mean_abs_rgb_delta']:.2f}/255") + print(f"rms-rgb-delta: {metrics['rms_rgb_delta']:.2f}/255") + print(f"max-channel-delta: {metrics['max_channel_delta']}/255") + + if args.require_exact is not None and metrics["exact_percent"] < args.require_exact: + print(f"verdict: fail, below required exact-pixel threshold {args.require_exact:.2f}%") + return 1 + print("verdict: measured, not a 1:1 renderer unless exact-pixel threshold is explicitly met") + return 0 + finally: + if owned_tmp and not args.keep_artifacts: + shutil.rmtree(out_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/readme.md b/readme.md index 6a4f9a3..7a15420 100644 --- a/readme.md +++ b/readme.md @@ -160,6 +160,10 @@ zig build test # run 252+ tests # Interactive browser — browse from your terminal ./zig-out/bin/kuri-browse https://example.com + +# Experimental standalone browser runtime — separate build, not production +(cd kuri-browser && zig build run -- render https://example.com) +(cd kuri-browser && zig build run -- bench --offline) ``` ### First run, shortest path @@ -328,6 +332,36 @@ The repo includes a user-extensible skill area: - `skills/custom/hackernews-page-2.md` is a concrete example custom skill - `.claude/skills/kuri-server/SKILL.md` stays in sync for Claude-style repo skills +The base skill now also explains which browser path to use: + +- `kuri` HTTP API: production Chrome/CDP automation with sessions, snapshots, actions, HAR, cookies, and screenshots +- `kuri-fetch`: standalone no-Chrome fetch/text extraction +- `kuri-browse`: interactive terminal browsing +- `kuri-agent`: scriptable CLI automation against the Kuri server +- `kuri-browser/`: experimental separate Zig-native browser runtime for parity work + +For the experimental browser CLI: + +```bash +cd kuri-browser +zig build run -- render https://news.ycombinator.com --selector ".titleline a" --dump text +zig build run -- render https://todomvc.com/examples/react/dist/ --js --wait-eval "document.querySelectorAll('.todo-list li').length >= 1" +zig build run -- parity --offline +zig build run -- bench --offline +zig build run -- serve-cdp --port 9333 +``` + +`kuri-browser serve-cdp` exposes Chrome-style HTTP discovery plus a minimal WebSocket JSON-RPC router for protocol smoke tests. Runtime eval returns V8-shaped CDP remote objects backed by QuickJS; this does not add a V8 dependency and is not full Playwright/Puppeteer compatibility yet. + +Screenshots in `kuri-browser` currently delegate to the main Kuri/CDP renderer. Start `./zig-out/bin/kuri` first, then: + +```bash +cd kuri-browser +zig build run -- screenshot https://example.com --out example.jpg --compress --kuri-base http://127.0.0.1:8080 +``` + +`--compress` captures a PNG baseline and JPEG candidate, writes the smaller file, and reports byte savings. Current local measurement on `https://example.com`: `20,523` bytes PNG to `18,183` bytes JPEG quality 50, saving `2,340` bytes or `11%`. + ### Advanced | Path | Description | diff --git a/skills/kuri-skill.md b/skills/kuri-skill.md index edf2f49..d7ae29c 100644 --- a/skills/kuri-skill.md +++ b/skills/kuri-skill.md @@ -16,6 +16,14 @@ Use this when driving `kuri` over HTTP as an agent loop. 5. Act with `/action`. 6. After navigation or DOM changes, re-run `/page/info` and take a fresh snapshot. +## Which browser path to use + +- Use `kuri` plus the HTTP API for real browser automation. This is the production path: Chrome/CDP, sessions, snapshots, actions, HAR, cookies, screenshots, and bot-detection handling. +- Use `kuri-fetch` for standalone no-Chrome text extraction. +- Use `kuri-browse` for an interactive terminal browser. +- Use `kuri-agent` for scriptable CLI automation against the Kuri server. +- Use `kuri-browser/` only when developing or evaluating the experimental Zig-native browser runtime. It is a separate build and is not wired into Kuri's root build. + ## Session-first pattern ```bash @@ -32,6 +40,35 @@ curl -s -H "X-Kuri-Session: $SESSION" "$BASE/action?action=click&ref=$MORE_REF" curl -s -H "X-Kuri-Session: $SESSION" "$BASE/page/info" ``` +## Experimental `kuri-browser` CLI + +Run these from the separate `kuri-browser/` workspace: + +```bash +cd kuri-browser +zig build run -- render https://news.ycombinator.com --selector ".titleline a" --dump text +zig build run -- render https://todomvc.com/examples/react/dist/ --js --wait-eval "document.querySelectorAll('.todo-list li').length >= 1" +zig build run -- bench --offline +zig build run -- parity --offline +zig build run -- serve-cdp --port 9333 +``` + +`serve-cdp` exposes Chrome-style HTTP discovery plus a minimal WebSocket JSON-RPC router. It can answer basic Browser/Target/Page/Runtime/DOM methods, and `Runtime.evaluate` returns V8-shaped CDP remote objects backed by QuickJS. It is useful for protocol smoke tests, but it is not Playwright/Puppeteer-compatible enough to replace Chrome yet. + +Screenshots currently use the existing Kuri/CDP renderer as a fallback, so start the normal Kuri server first: + +```bash +# terminal 1, repo root +zig build +./zig-out/bin/kuri + +# terminal 2 +cd kuri-browser +zig build run -- screenshot https://example.com --out example.jpg --compress --kuri-base http://127.0.0.1:8080 +``` + +`--compress` captures a PNG baseline and a JPEG candidate, writes the smaller file, and reports `original-bytes`, `bytes`, `saved-bytes`, and `saved-percent`. Current local measurement on `https://example.com`: `20,523` bytes PNG to `18,183` bytes JPEG quality 50, saving `2,340` bytes or `11%`. + ## Rules - Prefer `X-Kuri-Session` over repeating `tab_id`. @@ -41,6 +78,8 @@ curl -s -H "X-Kuri-Session: $SESSION" "$BASE/page/info" - Read snapshot `state` before acting on controls. Examples: `checked=false`, `disabled`, `readonly`, `expanded=false`, `selected`. - Treat refs as snapshot-local. Refresh them after navigation or major DOM updates. - Use HAR only when you need network or API discovery. +- Treat `kuri-browser serve-cdp` as an experimental minimal CDP shim, not a production automation endpoint. +- Treat `kuri-browser` screenshots as fallback-rendered by Kuri/CDP, not proof of native layout or paint. ## Optional wrapper diff --git a/src/js_engine.zig b/src/js_engine.zig index 10403a2..5e623a1 100644 --- a/src/js_engine.zig +++ b/src/js_engine.zig @@ -124,6 +124,17 @@ pub fn evalHtmlScriptsWithUrl(html: []const u8, url: ?[]const u8, allocator: std return engine.evalAlloc(allocator, "globalThis.__browdie_output"); } +/// Prepare an existing QuickJS engine with the current page HTML and URL. +/// This exposes the same DOM/window shims used by evalHtmlScriptsWithUrl. +pub fn prepareDomEngine(engine: *JsEngine, html: []const u8, url: ?[]const u8, allocator: std.mem.Allocator) void { + injectDomStubs(engine, html, url, allocator); +} + +/// Return the current captured document.write-style output from an existing engine. +pub fn outputAlloc(engine: *JsEngine, allocator: std.mem.Allocator) ?[]const u8 { + return engine.evalAlloc(allocator, "globalThis.__browdie_output"); +} + /// Inject Layer 3 DOM stubs into a JsEngine context. /// Provides: document.querySelector/All, getElementById, title, body, /// window.location, console.log, document.write/writeln. @@ -135,8 +146,7 @@ fn injectDomStubs(engine: *JsEngine, html: []const u8, url: ?[]const u8, allocat // Escape backslashes, quotes, and newlines for safe embedding. // Must null-terminate dynamic strings (QuickJS requires it). const escaped_html = escapeForJs(html, allocator) orelse ""; - const html_inject = std.fmt.allocPrint(allocator, - "globalThis.__browdie_html = \"{s}\";", .{escaped_html}) catch return; + const html_inject = std.fmt.allocPrint(allocator, "globalThis.__browdie_html = \"{s}\";", .{escaped_html}) catch return; const html_inject_z = allocator.dupeZ(u8, html_inject) catch return; _ = engine.exec(html_inject_z); @@ -154,6 +164,7 @@ fn injectDomStubs(engine: *JsEngine, html: []const u8, url: ?[]const u8, allocat // 4. Inject the full DOM shim (pure JS) _ = engine.exec(dom_shim_js); + _ = engine.exec(dom_runtime_enhancement_js); } /// Escape a string for embedding inside a double-quoted string literal (JS/JSON). @@ -210,6 +221,7 @@ const dom_location_template = \\ removeEventListener: function() {{}}, \\ dispatchEvent: function() {{ return true; }}, \\ getComputedStyle: function() {{ return {{}}; }}, + \\ matchMedia: function(query) {{ return {{ media: String(query || ''), matches: false, onchange: null, addListener: function() {{}}, removeListener: function() {{}}, addEventListener: function() {{}}, removeEventListener: function() {{}}, dispatchEvent: function() {{ return true; }} }}; }}, \\ requestAnimationFrame: function(fn) {{ if (typeof fn === 'function') fn(0); return 0; }}, \\ cancelAnimationFrame: function() {{}} \\ }}; @@ -247,7 +259,10 @@ const dom_shim_js = \\ Element.prototype.getElementsByTagName = function() { return []; }; \\ Element.prototype.getElementsByClassName = function() { return []; }; \\ Element.prototype.appendChild = function(c) { return c; }; + \\ Element.prototype.append = function() {}; + \\ Element.prototype.prepend = function() {}; \\ Element.prototype.removeChild = function(c) { return c; }; + \\ Element.prototype.remove = function() {}; \\ Element.prototype.addEventListener = function() {}; \\ Element.prototype.removeEventListener = function() {}; \\ Element.prototype.dispatchEvent = function() { return true; }; @@ -429,6 +444,1061 @@ const dom_shim_js = \\})(); ; +const dom_runtime_enhancement_js = + \\(function() { + \\ var source = globalThis.__browdie_html || ''; + \\ var HTML_NS = 'http://www.w3.org/1999/xhtml'; + \\ var SVG_NS = 'http://www.w3.org/2000/svg'; + \\ var VOID_TAGS = { area:1, base:1, br:1, col:1, embed:1, hr:1, img:1, input:1, link:1, meta:1, param:1, source:1, track:1, wbr:1 }; + \\ var documentRef = null; + \\ var existingWindow = globalThis.window || globalThis; + \\ var windowRef = globalThis; + \\ + \\ function lower(value) { return String(value || '').toLowerCase(); } + \\ function splitUrlParts(raw) { + \\ raw = String(raw || ''); + \\ var match = /^([A-Za-z][A-Za-z0-9+.-]*:)?(?:\/\/([^\/?#]*))?([^?#]*)(\?[^#]*)?(#.*)?$/.exec(raw) || []; + \\ var protocol = match[1] || ''; + \\ var host = match[2] || ''; + \\ var pathname = match[3] || '/'; + \\ var search = match[4] || ''; + \\ var hash = match[5] || ''; + \\ if (!pathname) pathname = '/'; + \\ return { protocol: protocol, host: host, pathname: pathname, search: search, hash: hash }; + \\ } + \\ function originFromHref(href) { + \\ var parts = splitUrlParts(href); + \\ return (parts.protocol && parts.host) ? parts.protocol + '//' + parts.host : ''; + \\ } + \\ function normalizePath(pathname) { + \\ var absolute = String(pathname || '/').charAt(0) === '/'; + \\ var parts = String(pathname || '/').split('/'); + \\ var out = []; + \\ for (var i = 0; i < parts.length; i += 1) { + \\ var part = parts[i]; + \\ if (!part || part === '.') continue; + \\ if (part === '..') { + \\ if (out.length) out.pop(); + \\ } else { + \\ out.push(part); + \\ } + \\ } + \\ return (absolute ? '/' : '') + out.join('/') || '/'; + \\ } + \\ function resolveUrlInput(input, base) { + \\ var raw = String(input || ''); + \\ var baseHref = String(base || (windowRef.location && windowRef.location.href) || ''); + \\ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw)) return raw; + \\ var baseNoHash = baseHref.split('#')[0]; + \\ var baseNoQuery = baseNoHash.split('?')[0]; + \\ var origin = originFromHref(baseHref); + \\ if (raw.charAt(0) === '/') return origin + raw; + \\ if (raw.charAt(0) === '#') return baseNoHash + raw; + \\ if (raw.charAt(0) === '?') return baseNoQuery + raw; + \\ var slash = baseNoQuery.lastIndexOf('/'); + \\ var dir = slash >= 0 ? baseNoQuery.slice(0, slash + 1) : baseNoQuery + '/'; + \\ return dir + raw; + \\ } + \\ function URLShim(input, base) { + \\ if (!(this instanceof URLShim)) return new URLShim(input, base); + \\ var href = resolveUrlInput(input, base); + \\ var parts = splitUrlParts(href); + \\ this.protocol = parts.protocol; + \\ this.host = parts.host; + \\ this.hostname = parts.host.split(':')[0] || ''; + \\ this.port = parts.host.indexOf(':') >= 0 ? parts.host.split(':').slice(1).join(':') : ''; + \\ this.pathname = normalizePath(parts.pathname || '/'); + \\ this.search = parts.search; + \\ this.hash = parts.hash; + \\ this.origin = this.protocol && this.host ? this.protocol + '//' + this.host : ''; + \\ this.href = this.origin + this.pathname + this.search + this.hash; + \\ } + \\ URLShim.prototype.toString = function() { return this.href; }; + \\ URLShim.prototype.toJSON = function() { return this.href; }; + \\ if (typeof globalThis.URL === 'undefined') { + \\ globalThis.URL = URLShim; + \\ windowRef.URL = URLShim; + \\ } + \\ if (globalThis.window && !globalThis.window.URL) globalThis.window.URL = globalThis.URL; + \\ + \\ Object.keys(existingWindow).forEach(function(key) { + \\ if (windowRef[key] === undefined) windowRef[key] = existingWindow[key]; + \\ }); + \\ + \\ function createStorage() { + \\ var store = Object.create(null); + \\ return { + \\ key: function(index) { + \\ var keys = Object.keys(store); + \\ return index >= 0 && index < keys.length ? keys[index] : null; + \\ }, + \\ getItem: function(key) { + \\ key = String(key); + \\ return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; + \\ }, + \\ setItem: function(key, value) { + \\ store[String(key)] = String(value); + \\ }, + \\ removeItem: function(key) { + \\ delete store[String(key)]; + \\ }, + \\ clear: function() { + \\ store = Object.create(null); + \\ } + \\ }; + \\ } + \\ + \\ function EventTarget() { + \\ this._listeners = Object.create(null); + \\ } + \\ + \\ EventTarget.prototype.addEventListener = function(type, listener) { + \\ if (!listener) return; + \\ type = String(type || ''); + \\ if (!this._listeners[type]) this._listeners[type] = []; + \\ this._listeners[type].push(listener); + \\ }; + \\ + \\ EventTarget.prototype.removeEventListener = function(type, listener) { + \\ type = String(type || ''); + \\ var list = this._listeners[type]; + \\ if (!list || !list.length) return; + \\ this._listeners[type] = list.filter(function(entry) { return entry !== listener; }); + \\ }; + \\ + \\ EventTarget.prototype.dispatchEvent = function(event) { + \\ if (!event || !event.type) return true; + \\ if (!event.target) event.target = this; + \\ event.currentTarget = this; + \\ var list = (this._listeners[event.type] || []).slice(); + \\ for (var i = 0; i < list.length; i += 1) { + \\ var listener = list[i]; + \\ if (typeof listener === 'function') { + \\ listener.call(this, event); + \\ } else if (listener && typeof listener.handleEvent === 'function') { + \\ listener.handleEvent(event); + \\ } + \\ if (event._stopImmediate) break; + \\ } + \\ if (!event._stopImmediate) { + \\ var handler = this['on' + event.type]; + \\ if (typeof handler === 'function') handler.call(this, event); + \\ } + \\ if (event.bubbles && !event._stop && this.parentNode) { + \\ this.parentNode.dispatchEvent(event); + \\ } + \\ return !event.defaultPrevented; + \\ }; + \\ + \\ function BaseEvent(type, init) { + \\ init = init || {}; + \\ this.type = String(type || ''); + \\ this.bubbles = !!init.bubbles; + \\ this.cancelable = !!init.cancelable; + \\ this.defaultPrevented = false; + \\ this.target = null; + \\ this.currentTarget = null; + \\ this.detail = init.detail !== undefined ? init.detail : null; + \\ this.keyCode = init.keyCode || 0; + \\ this.which = init.which || this.keyCode || 0; + \\ this.button = init.button || 0; + \\ } + \\ + \\ BaseEvent.prototype.preventDefault = function() { + \\ if (this.cancelable) this.defaultPrevented = true; + \\ }; + \\ BaseEvent.prototype.stopPropagation = function() { this._stop = true; }; + \\ BaseEvent.prototype.stopImmediatePropagation = function() { this._stop = true; this._stopImmediate = true; }; + \\ BaseEvent.prototype.initEvent = function(type, bubbles, cancelable) { + \\ this.type = String(type || ''); + \\ this.bubbles = !!bubbles; + \\ this.cancelable = !!cancelable; + \\ }; + \\ + \\ function CustomEvent(type, init) { BaseEvent.call(this, type, init); } + \\ CustomEvent.prototype = Object.create(BaseEvent.prototype); + \\ CustomEvent.prototype.constructor = CustomEvent; + \\ + \\ function MouseEvent(type, init) { BaseEvent.call(this, type, init); } + \\ MouseEvent.prototype = Object.create(BaseEvent.prototype); + \\ MouseEvent.prototype.constructor = MouseEvent; + \\ + \\ function StyleDeclaration() { this._props = Object.create(null); } + \\ StyleDeclaration.prototype.setProperty = function(name, value) { this._props[String(name)] = String(value); }; + \\ StyleDeclaration.prototype.getPropertyValue = function(name) { return this._props[String(name)] || ''; }; + \\ StyleDeclaration.prototype.removeProperty = function(name) { + \\ name = String(name); + \\ var previous = this._props[name] || ''; + \\ delete this._props[name]; + \\ return previous; + \\ }; + \\ Object.defineProperty(StyleDeclaration.prototype, 'cssText', { + \\ get: function() { + \\ var keys = Object.keys(this._props); + \\ return keys.map(function(key) { return key + ': ' + this._props[key]; }, this).join('; '); + \\ }, + \\ set: function(value) { + \\ this._props = Object.create(null); + \\ String(value || '').split(';').forEach(function(part) { + \\ var bits = part.split(':'); + \\ if (bits.length >= 2) this.setProperty(bits[0].trim(), bits.slice(1).join(':').trim()); + \\ }, this); + \\ } + \\ }); + \\ + \\ function Node(nodeType, nodeName, ownerDocument) { + \\ EventTarget.call(this); + \\ this.nodeType = nodeType; + \\ this.nodeName = nodeName; + \\ this.ownerDocument = ownerDocument || null; + \\ this.parentNode = null; + \\ this.childNodes = []; + \\ } + \\ Node.ELEMENT_NODE = 1; + \\ Node.TEXT_NODE = 3; + \\ Node.COMMENT_NODE = 8; + \\ Node.DOCUMENT_NODE = 9; + \\ Node.DOCUMENT_FRAGMENT_NODE = 11; + \\ Node.prototype = Object.create(EventTarget.prototype); + \\ Node.prototype.constructor = Node; + \\ + \\ Object.defineProperty(Node.prototype, 'firstChild', { get: function() { return this.childNodes.length ? this.childNodes[0] : null; } }); + \\ Object.defineProperty(Node.prototype, 'lastChild', { get: function() { return this.childNodes.length ? this.childNodes[this.childNodes.length - 1] : null; } }); + \\ Object.defineProperty(Node.prototype, 'nextSibling', { get: function() { + \\ if (!this.parentNode) return null; + \\ var siblings = this.parentNode.childNodes; + \\ var index = siblings.indexOf(this); + \\ return index >= 0 && index + 1 < siblings.length ? siblings[index + 1] : null; + \\ } }); + \\ Object.defineProperty(Node.prototype, 'previousSibling', { get: function() { + \\ if (!this.parentNode) return null; + \\ var siblings = this.parentNode.childNodes; + \\ var index = siblings.indexOf(this); + \\ return index > 0 ? siblings[index - 1] : null; + \\ } }); + \\ Object.defineProperty(Node.prototype, 'parentElement', { get: function() { + \\ return this.parentNode && this.parentNode.nodeType === Node.ELEMENT_NODE ? this.parentNode : null; + \\ } }); + \\ Object.defineProperty(Node.prototype, 'textContent', { + \\ get: function() { + \\ if (this.nodeType === Node.TEXT_NODE || this.nodeType === Node.COMMENT_NODE) return this.data || ''; + \\ return this.childNodes.map(function(child) { return child.textContent || ''; }).join(''); + \\ }, + \\ set: function(value) { + \\ if (this.nodeType === Node.TEXT_NODE || this.nodeType === Node.COMMENT_NODE) { + \\ this.data = String(value || ''); + \\ return; + \\ } + \\ this.childNodes = []; + \\ if (value !== null && value !== undefined && String(value).length > 0) { + \\ this.appendChild((this.ownerDocument || documentRef).createTextNode(String(value))); + \\ } + \\ } + \\ }); + \\ Object.defineProperty(Node.prototype, 'nodeValue', { + \\ get: function() { + \\ return (this.nodeType === Node.TEXT_NODE || this.nodeType === Node.COMMENT_NODE) ? (this.data || '') : null; + \\ }, + \\ set: function(value) { + \\ if (this.nodeType === Node.TEXT_NODE || this.nodeType === Node.COMMENT_NODE) this.data = String(value || ''); + \\ } + \\ }); + \\ + \\ Node.prototype.appendChild = function(child) { + \\ if (!child) return child; + \\ if (child.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + \\ while (child.firstChild) this.appendChild(child.firstChild); + \\ return child; + \\ } + \\ if (child.parentNode) child.parentNode.removeChild(child); + \\ child.parentNode = this; + \\ child.ownerDocument = this.nodeType === Node.DOCUMENT_NODE ? this : (this.ownerDocument || child.ownerDocument); + \\ this.childNodes.push(child); + \\ return child; + \\ }; + \\ + \\ Node.prototype.removeChild = function(child) { + \\ var index = this.childNodes.indexOf(child); + \\ if (index >= 0) { + \\ this.childNodes.splice(index, 1); + \\ child.parentNode = null; + \\ } + \\ return child; + \\ }; + \\ + \\ Node.prototype.append = function() { + \\ for (var i = 0; i < arguments.length; i += 1) { + \\ var child = arguments[i]; + \\ if (typeof child === 'string') child = (this.ownerDocument || documentRef).createTextNode(child); + \\ this.appendChild(child); + \\ } + \\ }; + \\ + \\ Node.prototype.prepend = function() { + \\ for (var i = arguments.length - 1; i >= 0; i -= 1) { + \\ var child = arguments[i]; + \\ if (typeof child === 'string') child = (this.ownerDocument || documentRef).createTextNode(child); + \\ this.insertBefore(child, this.firstChild); + \\ } + \\ }; + \\ + \\ Node.prototype.remove = function() { + \\ if (this.parentNode) this.parentNode.removeChild(this); + \\ }; + \\ + \\ Node.prototype.insertBefore = function(child, reference) { + \\ if (!reference) return this.appendChild(child); + \\ if (child.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + \\ var nodes = child.childNodes.slice(); + \\ for (var i = 0; i < nodes.length; i += 1) this.insertBefore(nodes[i], reference); + \\ return child; + \\ } + \\ if (child.parentNode) child.parentNode.removeChild(child); + \\ var index = this.childNodes.indexOf(reference); + \\ if (index < 0) return this.appendChild(child); + \\ child.parentNode = this; + \\ child.ownerDocument = this.nodeType === Node.DOCUMENT_NODE ? this : (this.ownerDocument || child.ownerDocument); + \\ this.childNodes.splice(index, 0, child); + \\ return child; + \\ }; + \\ + \\ Node.prototype.replaceChild = function(newChild, oldChild) { + \\ this.insertBefore(newChild, oldChild); + \\ this.removeChild(oldChild); + \\ return oldChild; + \\ }; + \\ + \\ Node.prototype.contains = function(node) { + \\ while (node) { + \\ if (node === this) return true; + \\ node = node.parentNode; + \\ } + \\ return false; + \\ }; + \\ + \\ Node.prototype.cloneNode = function(deep) { + \\ var clone; + \\ if (this.nodeType === Node.TEXT_NODE) clone = new TextNode(this.data, this.ownerDocument); + \\ else if (this.nodeType === Node.COMMENT_NODE) clone = new CommentNode(this.data, this.ownerDocument); + \\ else if (this.nodeType === Node.DOCUMENT_FRAGMENT_NODE) clone = new DocumentFragment(this.ownerDocument); + \\ else if (this.nodeType === Node.ELEMENT_NODE) { + \\ clone = new Element(this.localName || this.nodeName.toLowerCase(), this._attrs, this.ownerDocument); + \\ clone.style.cssText = this.style.cssText; + \\ clone.value = this.value; + \\ clone.checked = !!this.checked; + \\ clone.disabled = !!this.disabled; + \\ clone.selected = !!this.selected; + \\ clone.multiple = !!this.multiple; + \\ } else { + \\ clone = new Node(this.nodeType, this.nodeName, this.ownerDocument); + \\ } + \\ if (deep) { + \\ for (var i = 0; i < this.childNodes.length; i += 1) clone.appendChild(this.childNodes[i].cloneNode(true)); + \\ } + \\ return clone; + \\ }; + \\ + \\ function TextNode(data, ownerDocument) { + \\ Node.call(this, Node.TEXT_NODE, '#text', ownerDocument); + \\ this.data = String(data || ''); + \\ } + \\ TextNode.prototype = Object.create(Node.prototype); + \\ TextNode.prototype.constructor = TextNode; + \\ + \\ function CommentNode(data, ownerDocument) { + \\ Node.call(this, Node.COMMENT_NODE, '#comment', ownerDocument); + \\ this.data = String(data || ''); + \\ } + \\ CommentNode.prototype = Object.create(Node.prototype); + \\ CommentNode.prototype.constructor = CommentNode; + \\ + \\ function DocumentFragment(ownerDocument) { + \\ Node.call(this, Node.DOCUMENT_FRAGMENT_NODE, '#document-fragment', ownerDocument); + \\ } + \\ DocumentFragment.prototype = Object.create(Node.prototype); + \\ DocumentFragment.prototype.constructor = DocumentFragment; + \\ + \\ function Element(tagName, attrs, ownerDocument, namespaceURI) { + \\ Node.call(this, Node.ELEMENT_NODE, String(tagName || 'div').toUpperCase(), ownerDocument); + \\ this.tagName = this.nodeName; + \\ this.localName = this.tagName.toLowerCase(); + \\ this.namespaceURI = namespaceURI || HTML_NS; + \\ this._attrs = Object.create(null); + \\ this.style = new StyleDeclaration(); + \\ this.value = ''; + \\ this.checked = false; + \\ this.disabled = false; + \\ this.selected = false; + \\ this.multiple = false; + \\ if (attrs) { + \\ var keys = Object.keys(attrs); + \\ for (var i = 0; i < keys.length; i += 1) this.setAttribute(keys[i], attrs[keys[i]]); + \\ } + \\ } + \\ Element.prototype = Object.create(Node.prototype); + \\ Element.prototype.constructor = Element; + \\ Object.defineProperty(Element.prototype, 'id', { + \\ get: function() { return this.getAttribute('id') || ''; }, + \\ set: function(value) { this._attrs.id = String(value); } + \\ }); + \\ Object.defineProperty(Element.prototype, 'className', { + \\ get: function() { return this.getAttribute('class') || ''; }, + \\ set: function(value) { this._attrs['class'] = String(value); } + \\ }); + \\ Object.defineProperty(Element.prototype, 'children', { + \\ get: function() { return this.childNodes.filter(function(node) { return node.nodeType === Node.ELEMENT_NODE; }); } + \\ }); + \\ Object.defineProperty(Element.prototype, 'childElementCount', { + \\ get: function() { return this.children.length; } + \\ }); + \\ Object.defineProperty(Element.prototype, 'firstElementChild', { + \\ get: function() { return this.children.length ? this.children[0] : null; } + \\ }); + \\ Object.defineProperty(Element.prototype, 'lastElementChild', { + \\ get: function() { return this.children.length ? this.children[this.children.length - 1] : null; } + \\ }); + \\ Object.defineProperty(Element.prototype, 'nextElementSibling', { + \\ get: function() { + \\ var node = this.nextSibling; + \\ while (node && node.nodeType !== Node.ELEMENT_NODE) node = node.nextSibling; + \\ return node; + \\ } + \\ }); + \\ Object.defineProperty(Element.prototype, 'previousElementSibling', { + \\ get: function() { + \\ var node = this.previousSibling; + \\ while (node && node.nodeType !== Node.ELEMENT_NODE) node = node.previousSibling; + \\ return node; + \\ } + \\ }); + \\ Object.defineProperty(Element.prototype, 'innerHTML', { + \\ get: function() { return this.childNodes.map(serializeNode).join(''); }, + \\ set: function(value) { + \\ this.childNodes = []; + \\ var fragment = parseFragment(String(value || ''), this.ownerDocument || documentRef); + \\ while (fragment.firstChild) this.appendChild(fragment.firstChild); + \\ } + \\ }); + \\ Object.defineProperty(Element.prototype, 'outerHTML', { + \\ get: function() { return serializeNode(this); } + \\ }); + \\ Object.defineProperty(Element.prototype, 'innerText', { + \\ get: function() { return this.textContent; }, + \\ set: function(value) { this.textContent = value; } + \\ }); + \\ Object.defineProperty(Element.prototype, 'dataset', { + \\ get: function() { + \\ var data = Object.create(null); + \\ var keys = Object.keys(this._attrs); + \\ for (var i = 0; i < keys.length; i += 1) { + \\ var key = keys[i]; + \\ if (key.indexOf('data-') === 0) data[key.slice(5).replace(/-([a-z])/g, function(_, ch) { return ch.toUpperCase(); })] = this._attrs[key]; + \\ } + \\ return data; + \\ } + \\ }); + \\ Object.defineProperty(Element.prototype, 'classList', { + \\ get: function() { + \\ var element = this; + \\ function classes() { return String(element.getAttribute('class') || '').split(/\s+/).filter(Boolean); } + \\ return { + \\ add: function() { + \\ var list = classes(); + \\ for (var i = 0; i < arguments.length; i += 1) if (list.indexOf(arguments[i]) < 0) list.push(arguments[i]); + \\ element.setAttribute('class', list.join(' ')); + \\ }, + \\ remove: function() { + \\ var list = classes(); + \\ for (var i = 0; i < arguments.length; i += 1) list = list.filter(function(name) { return name !== arguments[i]; }, arguments); + \\ element.setAttribute('class', list.join(' ')); + \\ }, + \\ contains: function(name) { return classes().indexOf(String(name)) >= 0; }, + \\ toggle: function(name, force) { + \\ var exists = this.contains(name); + \\ if (force === true || (!exists && force !== false)) { this.add(name); return true; } + \\ this.remove(name); return false; + \\ }, + \\ item: function(index) { var list = classes(); return index >= 0 && index < list.length ? list[index] : null; }, + \\ get length() { return classes().length; }, + \\ toString: function() { return element.getAttribute('class') || ''; } + \\ }; + \\ } + \\ }); + \\ + \\ Element.prototype.getAttribute = function(name) { + \\ name = lower(name); + \\ return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null; + \\ }; + \\ Element.prototype.setAttribute = function(name, value) { + \\ name = lower(name); + \\ value = String(value); + \\ this._attrs[name] = value; + \\ if (name === 'id') this.id = value; + \\ if (name === 'class') this.className = value; + \\ if (name === 'value') this.value = value; + \\ if (name === 'style') this.style.cssText = value; + \\ if (name === 'checked') this.checked = true; + \\ if (name === 'disabled') this.disabled = true; + \\ if (name === 'selected') this.selected = true; + \\ if (name === 'multiple') this.multiple = true; + \\ }; + \\ Element.prototype.setAttributeNS = function(_, name, value) { this.setAttribute(name, value); }; + \\ Element.prototype.removeAttribute = function(name) { + \\ name = lower(name); + \\ delete this._attrs[name]; + \\ if (name === 'checked') this.checked = false; + \\ if (name === 'disabled') this.disabled = false; + \\ if (name === 'selected') this.selected = false; + \\ if (name === 'multiple') this.multiple = false; + \\ }; + \\ Element.prototype.removeAttributeNS = function(_, name) { this.removeAttribute(name); }; + \\ Element.prototype.hasAttribute = function(name) { return Object.prototype.hasOwnProperty.call(this._attrs, lower(name)); }; + \\ Element.prototype.getElementsByTagName = function(selector) { return queryAll(this, String(selector || '*')); }; + \\ Element.prototype.getElementsByClassName = function(className) { return queryAll(this, '.' + String(className || '')); }; + \\ Element.prototype.querySelector = function(selector) { + \\ var result = queryAll(this, selector); + \\ return result.length ? result[0] : null; + \\ }; + \\ Element.prototype.querySelectorAll = function(selector) { return queryAll(this, selector); }; + \\ Element.prototype.matches = function(selector) { return matchesSelector(this, selector); }; + \\ Element.prototype.closest = function(selector) { + \\ var node = this; + \\ while (node && node.nodeType === Node.ELEMENT_NODE) { + \\ if (matchesSelector(node, selector)) return node; + \\ node = node.parentElement; + \\ } + \\ return null; + \\ }; + \\ Element.prototype.getBoundingClientRect = function() { return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }; }; + \\ Element.prototype.focus = function() { if (documentRef) documentRef.activeElement = this; }; + \\ Element.prototype.blur = function() { if (documentRef && documentRef.activeElement === this) documentRef.activeElement = documentRef.body || null; }; + \\ Element.prototype.click = function() { this.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); }; + \\ + \\ function Document() { + \\ Node.call(this, Node.DOCUMENT_NODE, '#document', null); + \\ this.ownerDocument = this; + \\ this.documentElement = null; + \\ this.body = null; + \\ this.head = null; + \\ this.readyState = 'complete'; + \\ this.contentType = 'text/html'; + \\ this.characterSet = 'UTF-8'; + \\ this.charset = 'UTF-8'; + \\ this.referrer = ''; + \\ this.activeElement = null; + \\ this.defaultView = windowRef; + \\ this.implementation = { hasFeature: function() { return false; } }; + \\ this.location = windowRef.location; + \\ } + \\ Document.prototype = Object.create(Node.prototype); + \\ Document.prototype.constructor = Document; + \\ Document.prototype.createElement = function(tagName) { return new Element(tagName, {}, this); }; + \\ Document.prototype.createElementNS = function(namespaceURI, tagName) { return new Element(tagName, {}, this, namespaceURI || SVG_NS); }; + \\ Document.prototype.createTextNode = function(text) { return new TextNode(text, this); }; + \\ Document.prototype.createComment = function(text) { return new CommentNode(text, this); }; + \\ Document.prototype.createDocumentFragment = function() { return new DocumentFragment(this); }; + \\ Document.prototype.createEvent = function() { return new BaseEvent(''); }; + \\ Document.prototype.getElementById = function(id) { + \\ var result = queryAll(this, '#' + String(id || '')); + \\ return result.length ? result[0] : null; + \\ }; + \\ Document.prototype.getElementsByTagName = function(selector) { return queryAll(this, String(selector || '*')); }; + \\ Document.prototype.getElementsByClassName = function(className) { return queryAll(this, '.' + String(className || '')); }; + \\ Document.prototype.querySelector = function(selector) { + \\ var result = queryAll(this, selector); + \\ return result.length ? result[0] : null; + \\ }; + \\ Document.prototype.querySelectorAll = function(selector) { return queryAll(this, selector); }; + \\ Document.prototype.write = function(markup) { + \\ markup = String(markup || ''); + \\ globalThis.__browdie_output += markup; + \\ var target = this.body || this.documentElement || this; + \\ var fragment = parseFragment(markup, this); + \\ while (fragment.firstChild) target.appendChild(fragment.firstChild); + \\ }; + \\ Document.prototype.writeln = function(markup) { this.write(String(markup || '') + '\n'); }; + \\ Document.prototype.open = function() { if (this.body) this.body.childNodes = []; globalThis.__browdie_output = ''; }; + \\ Document.prototype.close = function() {}; + \\ Object.defineProperty(Document.prototype, 'title', { + \\ get: function() { + \\ var titleNode = this.querySelector('title'); + \\ return titleNode ? titleNode.textContent : ''; + \\ }, + \\ set: function(value) { + \\ value = String(value || ''); + \\ var titleNode = this.querySelector('title'); + \\ if (!titleNode) { + \\ if (!this.head) { + \\ this.head = this.createElement('head'); + \\ if (this.documentElement) this.documentElement.insertBefore(this.head, this.documentElement.firstChild); + \\ } + \\ titleNode = this.createElement('title'); + \\ this.head.appendChild(titleNode); + \\ } + \\ titleNode.textContent = value; + \\ } + \\ }); + \\ + \\ function serializeAttrs(node) { + \\ var keys = Object.keys(node._attrs || {}); + \\ if (!keys.length) return ''; + \\ return keys.map(function(key) { return ' ' + key + '="' + String(node._attrs[key]).replace(/"/g, '"') + '"'; }).join(''); + \\ } + \\ + \\ function serializeNode(node) { + \\ if (!node) return ''; + \\ if (node.nodeType === Node.TEXT_NODE) return String(node.data || ''); + \\ if (node.nodeType === Node.COMMENT_NODE) return ''; + \\ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE || node.nodeType === Node.DOCUMENT_NODE) { + \\ return node.childNodes.map(serializeNode).join(''); + \\ } + \\ var tag = node.localName || node.nodeName.toLowerCase(); + \\ var open = '<' + tag + serializeAttrs(node) + '>'; + \\ if (VOID_TAGS[tag]) return open; + \\ return open + node.childNodes.map(serializeNode).join('') + ''; + \\ } + \\ + \\ function parseAttributes(attrSource) { + \\ var attrs = Object.create(null); + \\ if (!attrSource) return attrs; + \\ var attrRegex = /([A-Za-z_:][A-Za-z0-9_:\-\.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + \\ var match; + \\ while ((match = attrRegex.exec(attrSource)) !== null) { + \\ attrs[lower(match[1])] = match[2] || match[3] || match[4] || ''; + \\ } + \\ return attrs; + \\ } + \\ + \\ function parseFragment(markup, ownerDocument) { + \\ var fragment = new DocumentFragment(ownerDocument); + \\ var stack = [fragment]; + \\ var i = 0; + \\ while (i < markup.length) { + \\ if (markup.slice(i, i + 4) === '', i + 4); + \\ if (commentEnd < 0) break; + \\ stack[stack.length - 1].appendChild(new CommentNode(markup.slice(i + 4, commentEnd), ownerDocument)); + \\ i = commentEnd + 3; + \\ continue; + \\ } + \\ if (markup.slice(i, i + 2) === '', i + 2); + \\ if (closeEnd < 0) break; + \\ var closingTag = lower(markup.slice(i + 2, closeEnd).trim().split(/\s+/)[0]); + \\ while (stack.length > 1) { + \\ var candidate = stack.pop(); + \\ if (candidate.localName === closingTag) break; + \\ } + \\ i = closeEnd + 1; + \\ continue; + \\ } + \\ if (markup.charAt(i) === '<' && (markup.charAt(i + 1) === '!' || markup.charAt(i + 1) === '?')) { + \\ var directiveEnd = markup.indexOf('>', i + 2); + \\ if (directiveEnd < 0) break; + \\ i = directiveEnd + 1; + \\ continue; + \\ } + \\ if (markup.charAt(i) === '<') { + \\ var openMatch = /^<([A-Za-z][A-Za-z0-9:_-]*)([\s\S]*?)>/.exec(markup.slice(i)); + \\ if (openMatch) { + \\ var fullTag = openMatch[0]; + \\ var tagName = lower(openMatch[1]); + \\ var attrSource = openMatch[2] || ''; + \\ var selfClosing = /\/\s*>$/.test(fullTag) || !!VOID_TAGS[tagName]; + \\ var element = new Element(tagName, parseAttributes(attrSource), ownerDocument, tagName === 'svg' ? SVG_NS : HTML_NS); + \\ stack[stack.length - 1].appendChild(element); + \\ i += fullTag.length; + \\ if (!selfClosing) { + \\ if (tagName === 'script' || tagName === 'style') { + \\ var closeToken = ''; + \\ var lowerMarkup = markup.toLowerCase(); + \\ var closeIndex = lowerMarkup.indexOf(closeToken, i); + \\ var rawText = closeIndex >= 0 ? markup.slice(i, closeIndex) : markup.slice(i); + \\ if (rawText.length) element.appendChild(new TextNode(rawText, ownerDocument)); + \\ i = closeIndex >= 0 ? closeIndex + closeToken.length : markup.length; + \\ } else { + \\ stack.push(element); + \\ } + \\ } + \\ continue; + \\ } + \\ } + \\ var nextTag = markup.indexOf('<', i); + \\ var text = markup.slice(i, nextTag >= 0 ? nextTag : markup.length); + \\ if (text.length) stack[stack.length - 1].appendChild(new TextNode(text, ownerDocument)); + \\ if (nextTag < 0) break; + \\ if (nextTag === i) { + \\ stack[stack.length - 1].appendChild(new TextNode('<', ownerDocument)); + \\ i += 1; + \\ continue; + \\ } + \\ i = nextTag; + \\ } + \\ return fragment; + \\ } + \\ + \\ function traverse(root, visit) { + \\ var nodes = root.childNodes ? root.childNodes.slice().reverse() : []; + \\ while (nodes.length) { + \\ var node = nodes.pop(); + \\ visit(node); + \\ if (node.childNodes && node.childNodes.length) { + \\ for (var i = node.childNodes.length - 1; i >= 0; i -= 1) nodes.push(node.childNodes[i]); + \\ } + \\ } + \\ } + \\ + \\ function splitSelectorGroups(selector) { + \\ var groups = []; + \\ var current = ''; + \\ var bracketDepth = 0; + \\ var quote = ''; + \\ for (var i = 0; i < selector.length; i += 1) { + \\ var ch = selector.charAt(i); + \\ if (quote) { + \\ current += ch; + \\ if (ch === quote) quote = ''; + \\ continue; + \\ } + \\ if (ch === '"' || ch === '\'') { quote = ch; current += ch; continue; } + \\ if (ch === '[') { bracketDepth += 1; current += ch; continue; } + \\ if (ch === ']') { bracketDepth = Math.max(0, bracketDepth - 1); current += ch; continue; } + \\ if (ch === ',' && bracketDepth === 0) { + \\ if (current.trim()) groups.push(current.trim()); + \\ current = ''; + \\ continue; + \\ } + \\ current += ch; + \\ } + \\ if (current.trim()) groups.push(current.trim()); + \\ return groups; + \\ } + \\ + \\ function parseSimpleSelector(part) { + \\ var selector = { tag: null, id: null, classes: [], attrs: [] }; + \\ var i = 0; + \\ var tagMatch = /^[A-Za-z*][A-Za-z0-9:_-]*/.exec(part); + \\ if (tagMatch) { + \\ selector.tag = lower(tagMatch[0]); + \\ i = tagMatch[0].length; + \\ } + \\ while (i < part.length) { + \\ var ch = part.charAt(i); + \\ if (ch === '#') { + \\ var idMatch = /^#([A-Za-z0-9:_-]+)/.exec(part.slice(i)); + \\ if (idMatch) { selector.id = idMatch[1]; i += idMatch[0].length; continue; } + \\ } + \\ if (ch === '.') { + \\ var classMatch = /^\.([A-Za-z0-9:_-]+)/.exec(part.slice(i)); + \\ if (classMatch) { selector.classes.push(classMatch[1]); i += classMatch[0].length; continue; } + \\ } + \\ if (ch === '[') { + \\ var end = part.indexOf(']', i); + \\ if (end < 0) break; + \\ var body = part.slice(i + 1, end); + \\ var attrMatch = /^([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(.+)))?$/.exec(body.trim()); + \\ if (attrMatch) selector.attrs.push({ name: lower(attrMatch[1]), value: attrMatch[2] || attrMatch[3] || (attrMatch[4] ? attrMatch[4].trim() : null) }); + \\ i = end + 1; + \\ continue; + \\ } + \\ i += 1; + \\ } + \\ return selector; + \\ } + \\ + \\ function parseSelectorChain(selector) { + \\ var parts = []; + \\ var current = ''; + \\ var bracketDepth = 0; + \\ var quote = ''; + \\ var combinator = null; + \\ for (var i = 0; i < selector.length; i += 1) { + \\ var ch = selector.charAt(i); + \\ if (quote) { + \\ current += ch; + \\ if (ch === quote) quote = ''; + \\ continue; + \\ } + \\ if (ch === '"' || ch === '\'') { quote = ch; current += ch; continue; } + \\ if (ch === '[') { bracketDepth += 1; current += ch; continue; } + \\ if (ch === ']') { bracketDepth = Math.max(0, bracketDepth - 1); current += ch; continue; } + \\ if (bracketDepth === 0 && (ch === '>' || /\s/.test(ch))) { + \\ if (current.trim()) { + \\ parts.push({ selector: parseSimpleSelector(current.trim()), combinator: combinator }); + \\ current = ''; + \\ combinator = null; + \\ } + \\ if (ch === '>') combinator = '>'; + \\ else if (!combinator) combinator = ' '; + \\ continue; + \\ } + \\ current += ch; + \\ } + \\ if (current.trim()) parts.push({ selector: parseSimpleSelector(current.trim()), combinator: combinator }); + \\ if (parts.length) parts[0].combinator = null; + \\ return parts; + \\ } + \\ + \\ function matchesSimple(node, selector) { + \\ if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; + \\ if (selector.tag && selector.tag !== '*' && node.localName !== selector.tag) return false; + \\ if (selector.id && node.getAttribute('id') !== selector.id) return false; + \\ for (var i = 0; i < selector.classes.length; i += 1) { + \\ if (!node.classList.contains(selector.classes[i])) return false; + \\ } + \\ for (var j = 0; j < selector.attrs.length; j += 1) { + \\ var attr = selector.attrs[j]; + \\ if (!node.hasAttribute(attr.name)) return false; + \\ if (attr.value !== null && node.getAttribute(attr.name) !== attr.value) return false; + \\ } + \\ return true; + \\ } + \\ + \\ function matchesChain(node, parts, index) { + \\ if (!matchesSimple(node, parts[index].selector)) return false; + \\ if (index === 0) return true; + \\ var combinator = parts[index].combinator || ' '; + \\ if (combinator === '>') { + \\ var parent = node.parentElement; + \\ return !!parent && matchesChain(parent, parts, index - 1); + \\ } + \\ var current = node.parentElement; + \\ while (current) { + \\ if (matchesChain(current, parts, index - 1)) return true; + \\ current = current.parentElement; + \\ } + \\ return false; + \\ } + \\ + \\ function queryAll(root, selector) { + \\ selector = String(selector || '').trim(); + \\ if (!selector) return []; + \\ var groups = splitSelectorGroups(selector).map(parseSelectorChain); + \\ var results = []; + \\ function maybeAdd(node) { + \\ if (!node || node.nodeType !== Node.ELEMENT_NODE) return; + \\ for (var i = 0; i < groups.length; i += 1) { + \\ if (groups[i].length && matchesChain(node, groups[i], groups[i].length - 1)) { + \\ if (results.indexOf(node) < 0) results.push(node); + \\ return; + \\ } + \\ } + \\ } + \\ if (root.nodeType === Node.ELEMENT_NODE) maybeAdd(root); + \\ traverse(root.nodeType === Node.DOCUMENT_NODE ? root : root, maybeAdd); + \\ return results; + \\ } + \\ + \\ function matchesSelector(node, selector) { + \\ selector = String(selector || '').trim(); + \\ if (!selector || !node || node.nodeType !== Node.ELEMENT_NODE) return false; + \\ var groups = splitSelectorGroups(selector); + \\ for (var i = 0; i < groups.length; i += 1) { + \\ var chain = parseSelectorChain(groups[i]); + \\ if (chain.length && matchesChain(node, chain, chain.length - 1)) return true; + \\ } + \\ return false; + \\ } + \\ + \\ function initializeDocument(doc) { + \\ var htmlNode = null; + \\ var headNode = null; + \\ var bodyNode = null; + \\ traverse(doc, function(node) { + \\ if (node.nodeType !== Node.ELEMENT_NODE) return; + \\ if (!htmlNode && node.localName === 'html') htmlNode = node; + \\ if (!headNode && node.localName === 'head') headNode = node; + \\ if (!bodyNode && node.localName === 'body') bodyNode = node; + \\ }); + \\ if (!htmlNode) { + \\ htmlNode = doc.createElement('html'); + \\ while (doc.firstChild) htmlNode.appendChild(doc.firstChild); + \\ doc.appendChild(htmlNode); + \\ } + \\ if (!headNode) { + \\ headNode = doc.createElement('head'); + \\ htmlNode.insertBefore(headNode, htmlNode.firstChild); + \\ } + \\ if (!bodyNode) { + \\ bodyNode = doc.createElement('body'); + \\ htmlNode.appendChild(bodyNode); + \\ } + \\ doc.documentElement = htmlNode; + \\ doc.head = headNode; + \\ doc.body = bodyNode; + \\ doc.activeElement = bodyNode; + \\ doc.URL = windowRef.location ? windowRef.location.href : ''; + \\ doc.domain = windowRef.location ? windowRef.location.hostname : ''; + \\ doc.location = windowRef.location; + \\ return doc; + \\ } + \\ + \\ function parseDocument(markup) { + \\ var doc = new Document(); + \\ var fragment = parseFragment(markup, doc); + \\ while (fragment.firstChild) doc.appendChild(fragment.firstChild); + \\ return initializeDocument(doc); + \\ } + \\ + \\ function updateLocationParts(href) { + \\ href = String(href || ''); + \\ var index = href.indexOf('://'); + \\ var protocol = index >= 0 ? href.slice(0, index + 1) : ''; + \\ var rest = index >= 0 ? href.slice(index + 3) : href; + \\ var slash = rest.indexOf('/'); + \\ var host = slash >= 0 ? rest.slice(0, slash) : rest; + \\ var path = slash >= 0 ? rest.slice(slash) : '/'; + \\ var hashIndex = path.indexOf('#'); + \\ var hash = hashIndex >= 0 ? path.slice(hashIndex) : ''; + \\ var beforeHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; + \\ var searchIndex = beforeHash.indexOf('?'); + \\ var search = searchIndex >= 0 ? beforeHash.slice(searchIndex) : ''; + \\ var pathname = searchIndex >= 0 ? beforeHash.slice(0, searchIndex) : beforeHash; + \\ var hostBits = host.split(':'); + \\ var hostname = hostBits[0] || ''; + \\ var port = hostBits.length > 1 ? hostBits.slice(1).join(':') : ''; + \\ windowRef.location.href = href; + \\ windowRef.location.protocol = protocol; + \\ windowRef.location.host = host; + \\ windowRef.location.hostname = hostname; + \\ windowRef.location.port = port; + \\ windowRef.location.pathname = pathname || '/'; + \\ windowRef.location.search = search; + \\ windowRef.location.hash = hash; + \\ windowRef.location.origin = protocol ? protocol + '//' + host : ''; + \\ if (documentRef) { + \\ documentRef.URL = href; + \\ documentRef.domain = hostname; + \\ documentRef.location = windowRef.location; + \\ } + \\ } + \\ + \\ function navigateTo(nextHref, replace) { + \\ var previousHash = windowRef.location.hash || ''; + \\ updateLocationParts(nextHref); + \\ if ((windowRef.location.hash || '') !== previousHash) windowRef.dispatchEvent(new BaseEvent('hashchange')); + \\ windowRef.dispatchEvent(new BaseEvent('popstate')); + \\ if (replace) windowRef.history.state = windowRef.history.state || {}; + \\ } + \\ + \\ if (!(windowRef instanceof EventTarget)) { + \\ windowRef._listeners = Object.create(null); + \\ windowRef.addEventListener = EventTarget.prototype.addEventListener; + \\ windowRef.removeEventListener = EventTarget.prototype.removeEventListener; + \\ windowRef.dispatchEvent = EventTarget.prototype.dispatchEvent; + \\ } + \\ windowRef.onhashchange = windowRef.onhashchange || null; + \\ windowRef.onpopstate = windowRef.onpopstate || null; + \\ windowRef.localStorage = windowRef.localStorage || createStorage(); + \\ windowRef.sessionStorage = windowRef.sessionStorage || createStorage(); + \\ windowRef.history = windowRef.history || {}; + \\ windowRef.history.state = windowRef.history.state || null; + \\ windowRef.history.pushState = function(state, _, href) { + \\ this.state = state; + \\ if (href !== undefined && href !== null) navigateTo(String(href), false); + \\ }; + \\ windowRef.history.replaceState = function(state, _, href) { + \\ this.state = state; + \\ if (href !== undefined && href !== null) navigateTo(String(href), true); + \\ }; + \\ windowRef.history.back = function() {}; + \\ windowRef.history.forward = function() {}; + \\ windowRef.history.go = function() {}; + \\ windowRef.location.assign = function(href) { navigateTo(String(href), false); }; + \\ windowRef.location.replace = function(href) { navigateTo(String(href), true); }; + \\ windowRef.location.reload = function() {}; + \\ windowRef.location.toString = function() { return this.href || ''; }; + \\ updateLocationParts(windowRef.location && windowRef.location.href ? windowRef.location.href : ''); + \\ + \\ documentRef = parseDocument(source); + \\ documentRef.defaultView = windowRef; + \\ documentRef.referrer = ''; + \\ documentRef.implementation = { hasFeature: function() { return false; } }; + \\ documentRef.location = windowRef.location; + \\ windowRef.document = documentRef; + \\ windowRef.self = windowRef; + \\ windowRef.window = windowRef; + \\ windowRef.Node = Node; + \\ windowRef.Element = Element; + \\ windowRef.HTMLElement = Element; + \\ windowRef.HTMLDocument = Document; + \\ windowRef.Document = Document; + \\ windowRef.DocumentFragment = DocumentFragment; + \\ windowRef.Text = TextNode; + \\ windowRef.Comment = CommentNode; + \\ windowRef.NodeList = Array; + \\ windowRef.HTMLCollection = Array; + \\ windowRef.HTMLHtmlElement = Element; + \\ windowRef.HTMLHeadElement = Element; + \\ windowRef.HTMLBodyElement = Element; + \\ windowRef.HTMLAnchorElement = Element; + \\ windowRef.HTMLButtonElement = Element; + \\ windowRef.HTMLFormElement = Element; + \\ windowRef.HTMLIFrameElement = Element; + \\ windowRef.HTMLImageElement = Element; + \\ windowRef.HTMLInputElement = Element; + \\ windowRef.HTMLLabelElement = Element; + \\ windowRef.HTMLLinkElement = Element; + \\ windowRef.HTMLMetaElement = Element; + \\ windowRef.HTMLOptionElement = Element; + \\ windowRef.HTMLScriptElement = Element; + \\ windowRef.HTMLSelectElement = Element; + \\ windowRef.HTMLSpanElement = Element; + \\ windowRef.HTMLStyleElement = Element; + \\ windowRef.HTMLTextAreaElement = Element; + \\ windowRef.HTMLTitleElement = Element; + \\ windowRef.SVGElement = Element; + \\ windowRef.Event = BaseEvent; + \\ windowRef.CustomEvent = CustomEvent; + \\ windowRef.MouseEvent = MouseEvent; + \\ windowRef.MutationObserver = windowRef.MutationObserver || function() { this.observe = function() {}; this.disconnect = function() {}; this.takeRecords = function() { return []; }; }; + \\ windowRef.getComputedStyle = function(node) { return node && node.style ? node.style : new StyleDeclaration(); }; + \\ windowRef.matchMedia = windowRef.matchMedia || function(query) { return { media: String(query || ''), matches: false, onchange: null, addListener: function() {}, removeListener: function() {}, addEventListener: function() {}, removeEventListener: function() {}, dispatchEvent: function() { return true; } }; }; + \\ windowRef.requestAnimationFrame = windowRef.requestAnimationFrame || function(fn) { if (typeof fn === 'function') fn(0); return 0; }; + \\ windowRef.cancelAnimationFrame = windowRef.cancelAnimationFrame || function() {}; + \\ globalThis.window = windowRef; + \\ globalThis.self = windowRef; + \\ globalThis.document = documentRef; + \\ globalThis.Node = Node; + \\ globalThis.Element = Element; + \\ globalThis.HTMLElement = Element; + \\ globalThis.HTMLDocument = Document; + \\ globalThis.Document = Document; + \\ globalThis.DocumentFragment = DocumentFragment; + \\ globalThis.Text = TextNode; + \\ globalThis.Comment = CommentNode; + \\ globalThis.NodeList = Array; + \\ globalThis.HTMLCollection = Array; + \\ globalThis.HTMLHtmlElement = Element; + \\ globalThis.HTMLHeadElement = Element; + \\ globalThis.HTMLBodyElement = Element; + \\ globalThis.HTMLAnchorElement = Element; + \\ globalThis.HTMLButtonElement = Element; + \\ globalThis.HTMLFormElement = Element; + \\ globalThis.HTMLIFrameElement = Element; + \\ globalThis.HTMLImageElement = Element; + \\ globalThis.HTMLInputElement = Element; + \\ globalThis.HTMLLabelElement = Element; + \\ globalThis.HTMLLinkElement = Element; + \\ globalThis.HTMLMetaElement = Element; + \\ globalThis.HTMLOptionElement = Element; + \\ globalThis.HTMLScriptElement = Element; + \\ globalThis.HTMLSelectElement = Element; + \\ globalThis.HTMLSpanElement = Element; + \\ globalThis.HTMLStyleElement = Element; + \\ globalThis.HTMLTextAreaElement = Element; + \\ globalThis.HTMLTitleElement = Element; + \\ globalThis.SVGElement = Element; + \\ globalThis.Event = BaseEvent; + \\ globalThis.CustomEvent = CustomEvent; + \\ globalThis.MouseEvent = MouseEvent; + \\ globalThis.localStorage = windowRef.localStorage; + \\ globalThis.sessionStorage = windowRef.sessionStorage; + \\ globalThis.history = windowRef.history; + \\ globalThis.location = windowRef.location; + \\})(); +; + // --- Tests --- test "extractInlineScripts finds script bodies" { @@ -568,6 +1638,56 @@ test "DOM stubs: document.querySelectorAll by tag" { try std.testing.expectEqualStrings("2", output.?); } +test "DOM stubs: parses doctype and dynamic descendant selectors" { + const html = + ""; + const output = try evalHtmlScripts(html, std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("1", output.?); +} + +test "DOM stubs: id and className properties reflect selector attributes" { + const html = + ""; + const output = try evalHtmlScripts(html, std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("1", output.?); +} + +test "DOM stubs: append and remove mutate child lists" { + const html = + ""; + const output = try evalHtmlScripts(html, std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("hello|", output.?); +} + test "DOM stubs: document.querySelector by id selector" { const html = "found"; const output = try evalHtmlScripts(html, std.testing.allocator); @@ -625,6 +1745,22 @@ test "DOM stubs: window.location.search and hash" { try std.testing.expectEqualStrings("?q=1&r=2|#sec", output.?); } +test "DOM stubs: URL constructor resolves relative paths" { + const html = ""; + const output = try evalHtmlScriptsWithUrl(html, "https://example.com/a/b/c", std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("/a/next|?q=1|#s", output.?); +} + +test "DOM stubs: matchMedia exposes media query list shape" { + const html = ""; + const output = try evalHtmlScripts(html, std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("false|(prefers-color-scheme: dark)", output.?); +} + test "DOM stubs: console.log does not crash" { const html = ""; const output = try evalHtmlScripts(html, std.testing.allocator); @@ -649,6 +1785,14 @@ test "DOM stubs: document.createElement" { try std.testing.expectEqualStrings("DIV:new", output.?); } +test "DOM stubs: window properties resolve as globals" { + const html = ""; + const output = try evalHtmlScripts(html, std.testing.allocator); + defer if (output) |o| std.testing.allocator.free(o); + try std.testing.expect(output != null); + try std.testing.expectEqualStrings("7|7", output.?); +} + test "DOM stubs: document.readyState" { const html = ""; const output = try evalHtmlScripts(html, std.testing.allocator);