800 Commits

Author SHA1 Message Date
Nitya Timalsina
129e8e8340 Restructure README with table of contents, benchmarks table, and expanded documentation
Add comprehensive table of contents, convert benchmark data to table format, expand use cases section with AI agents/scraping/testing examples, add architecture diagram reference, reorganize build/test sections with collapsible details, and include FAQ section. Improve formatting throughout with horizontal rules and better section hierarchy.
2026-03-20 05:59:34 -06:00
Halil Durak
a865b86fa5 Merge pull request #1925 from lightpanda-io/nikneym/promise-error
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Return correct errors in promise rejections
2026-03-20 14:05:21 +03:00
Halil Durak
de28d14aff give up on switch (comptime kind), prefer union(enum) 2026-03-20 13:35:12 +03:00
Karl Seguin
4cdc24326a Merge pull request #1918 from lightpanda-io/shadowroot_adoptedstyle
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add `adoptedStyleSheets` property to ShadowRoot, just like Document
2026-03-20 07:11:49 +08:00
Karl Seguin
cf46f0097a Merge pull request #1915 from lightpanda-io/unhandled_rejection_improvements
Improve unhandled rejection
2026-03-20 07:11:35 +08:00
Pierre Tachoire
d94fd2a43b Merge pull request #1793 from lightpanda-io/wpt-selfhost
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Move WPT runs on a dedicated host
2026-03-19 17:35:21 +01:00
Pierre Tachoire
8c5e737669 ci: use mem-limit with wptrunner 2026-03-19 15:40:18 +01:00
Pierre Tachoire
fb29a1c5bf ci: adjust wpt serve wait time 2026-03-19 15:40:18 +01:00
Halil Durak
94190f93af return correct errors from promises 2026-03-19 16:30:09 +03:00
Halil Durak
93e239f682 bind more ECMAScript errors 2026-03-19 16:27:51 +03:00
Karl Seguin
a2e59af44c Merge pull request #1911 from lightpanda-io/fix/turnstile-300030-missing-navigator-apis
Fix/turnstile 300030 missing navigator apis
2026-03-19 20:26:27 +08:00
Karl Seguin
00c962bdd8 Merge pull request #1914 from lightpanda-io/semantic-tree-depth
SemanticTree: add progressive discoverability
2026-03-19 20:12:02 +08:00
Karl Seguin
1fa87442b8 log not_implemented on navigator.getBattery 2026-03-19 20:11:03 +08:00
Karl Seguin
ac5400696a Merge pull request #1916 from lightpanda-io/request_abort
Add Request.signal
2026-03-19 20:07:12 +08:00
Adrià Arrufat
5062273b7a SemanticTree: use CDPNode.Id for NodeData id 2026-03-19 20:29:54 +09:00
Adrià Arrufat
9c2393351d SemanticTree: simplify max_depth logic 2026-03-19 20:25:20 +09:00
Adrià Arrufat
f0cfe3ffc8 SemanticTree: use logger better
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-19 20:15:56 +09:00
Pierre Tachoire
615fcffb99 Merge pull request #1924 from lightpanda-io/wba-test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Adjust wba test
2026-03-19 10:47:23 +01:00
Karl Seguin
13b746f9e4 Merge pull request #1919 from lightpanda-io/remove-make-shell
build: remove shell target from Makefile
2026-03-19 17:39:19 +08:00
Adrià Arrufat
e90fce4c55 Merge pull request #1920 from lightpanda-io/markdown-renderer-refactor
markdown: refactor renderer into a struct to simplify argument passing
2026-03-19 18:26:57 +09:00
Pierre Tachoire
59175437b5 wpt: force a wakeup of the wbauth server before the test 2026-03-19 09:40:47 +01:00
Pierre Tachoire
e950384b9b ci: sleep 5s to wait node startup 2026-03-19 09:36:47 +01:00
Pierre Tachoire
78440350dc ci: slow down execution 2026-03-19 09:32:27 +01:00
Pierre Tachoire
f435297949 ci: adjust WPT daily start time 2026-03-19 09:32:26 +01:00
Pierre Tachoire
54d1563cf3 ci: run WPT tests on a dedicated server 2026-03-19 09:32:13 +01:00
Adrià Arrufat
f36499b806 markdown: refactor renderer into a struct to simplify argument passing 2026-03-19 15:19:11 +09:00
Adrià Arrufat
fa1dd5237d build: remove shell target from Makefile 2026-03-19 13:24:41 +09:00
Karl Seguin
2b9d5fd4d9 Add adoptedStyleSheets property to ShadowRoot, just like Document
Used in github.
2026-03-19 12:09:10 +08:00
Karl Seguin
964fa0a8aa Add Request.signal
Allows aborting a fetch. Improves github integration
2026-03-19 11:40:16 +08:00
Karl Seguin
db01158d2d Improve unhandled rejection
We now pay attention to the type of event that causes the unhandled exception.
This allows us to trigger the window.rejectionhandled event when that is the
correct type. It also lets us no-op for other event types which should not
trigger rejectionhandled or unhandledrejection.

Fixes stackoverflow in github integration.
2026-03-19 11:36:39 +08:00
Adrià Arrufat
e997f8317e SemanticTree: add tests for backendDOMNodeId and maxDepth 2026-03-19 12:25:02 +09:00
Karl Seguin
a88c21cdb5 Fix Navigator Additions
Follow up to https://github.com/lightpanda-io/browser/pull/1884

Fixes build, uses arena/finalizer for PermissionStatus. Fixes tests. A few other
small cleanups.
2026-03-19 09:41:13 +08:00
Adrià Arrufat
7a7c4b9f49 SemanticTree): add backendNodeId and maxDepth support 2026-03-19 10:18:08 +09:00
Karl Seguin
edd0c5c83f Merge pull request #1900 from lightpanda-io/input-event
Some checks failed
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
distapch InputEvent on input/TextArea changes
2026-03-19 06:39:44 +08:00
Francis Bouvier
c6861829c3 Merge pull request #1907 from lightpanda-io/README-remove-js-runtime
Some checks failed
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
REAMDE: again references to js-runtime
2026-03-18 16:48:16 +01:00
Francis Bouvier
e14c8b3025 REAMDE: again references to js-runtime 2026-03-18 16:45:44 +01:00
Francis Bouvier
5bc00c595c Merge pull request #1906 from lightpanda-io/README-remove-js-runtime
README: remove again references to js-runtime
2026-03-18 16:44:53 +01:00
Francis Bouvier
db5fb40de0 README: remove again references to js-runtime 2026-03-18 16:42:31 +01:00
Pierre Tachoire
4e6a357e6e use initTrusted for InputEvent 2026-03-18 16:41:28 +01:00
Francis Bouvier
6cf515151d Merge pull request #1905 from lightpanda-io/README-remove-js-runtime
README: remove reference to zig-js-runtime
2026-03-18 16:41:08 +01:00
Pierre Tachoire
bf6e4cf3a6 disaptch InputEvent on input/TextArea changes 2026-03-18 16:40:21 +01:00
Francis Bouvier
60936baa96 README: remove reference to zig-js-runtime 2026-03-18 16:39:26 +01:00
Pierre Tachoire
c29f72a7e8 Merge pull request #1898 from lightpanda-io/keyboard-event-bubble
Keyboard events are bubbling, cancelable and composed
2026-03-18 16:26:15 +01:00
Adrià Arrufat
d4427e4370 Merge pull request #1894 from lightpanda-io/semantic-tree-interactive
SemanticTree: implement interactiveOnly filter and optimize token usage
2026-03-18 22:33:45 +09:00
Karl Seguin
b85ec04175 Merge pull request #1902 from lightpanda-io/fix/emulation-set-user-agent-override
Fix/emulation set user agent override
2026-03-18 20:05:26 +08:00
Karl Seguin
da05ba0eb7 log on ignored setUserAgentOverride 2026-03-18 19:46:37 +08:00
Karl Seguin
414a68abeb Merge pull request #1899 from lightpanda-io/idle_task_fix
only run idle tasks from the root page
2026-03-18 19:41:58 +08:00
Karl Seguin
52455b732b Merge pull request #1885 from lightpanda-io/danling_context_fallback
Fallback to the Incumbent Context when the Current Context is dangling
2026-03-18 19:41:38 +08:00
Pierre Tachoire
ba71268eb3 Keyboard events are bubbling, cancelable and composed
According to the specs: https://w3c.github.io/uievents/#event-type-keyup
2026-03-18 12:36:00 +01:00
Adrià Arrufat
694aac5ce8 browser.interactive: optimize role checks with StaticStringMap 2026-03-18 20:10:15 +09:00
Adrià Arrufat
cbab0b712a SemanticTree: simplify TextVisitor printing logic 2026-03-18 20:07:11 +09:00
Karl Seguin
1aee3db521 only run idle tasks from the root page 2026-03-18 19:03:38 +08:00
Pierre Tachoire
f634c9843d Merge pull request #1893 from lightpanda-io/link_onload_rel
Some checks failed
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Expand rel's that trigger a link's onload
2026-03-18 09:41:10 +01:00
Pierre Tachoire
e1e45d1c5d Merge pull request #1796 from lightpanda-io/wp/mrdimidium/telemetry-common-network
Use common network runtime for telemetry messages
2026-03-18 09:34:19 +01:00
Adrià Arrufat
ff288c8aa2 browser.interactive: use for-else expression in role checks 2026-03-18 12:04:53 +09:00
Adrià Arrufat
e1b14a6833 SemanticTree: enable prune by default 2026-03-18 11:25:38 +09:00
Adrià Arrufat
015edc3848 SemanticTree: implement interactiveOnly filter and optimize token usage 2026-03-18 10:56:56 +09:00
Karl Seguin
bd2406f803 Merge pull request #1891 from lightpanda-io/form-requestSubmit
Some checks failed
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Implement Form.requestSubmit
2026-03-18 08:55:04 +08:00
Karl Seguin
3c29e7dbd4 Expand rel's that trigger a link's onload
Was only "stylesheet", not also includes "preload" and "modulepreload"
2026-03-18 08:53:05 +08:00
Nikolay Govorov
586413357e Close all cdp clients on shutdown 2026-03-17 23:30:36 +00:00
Nikolay Govorov
9a055a61a6 Limit telemetry body size 2026-03-17 23:23:10 +00:00
Nikolay Govorov
5fb561dc9c Used ring buffer for telemetry events buffer 2026-03-17 23:23:08 +00:00
Nikolay Govorov
b14ae02548 Move comments and bound checks 2026-03-17 23:23:05 +00:00
Nikolay Govorov
51fb08e6aa Create multi interface in Runtime on demand 2026-03-17 23:23:01 +00:00
Nikolay Govorov
a6d699ad5d Use common network runtime for telemetry messages 2026-03-17 23:21:57 +00:00
Karl Seguin
8372b45cc5 Merge pull request #1877 from lightpanda-io/xhr_and_fetch_blob_urls
Support blob urls in XHR and Fetch
2026-03-18 07:02:56 +08:00
Pierre Tachoire
1739ae6b9a check submit element and form into Form.requestSubmit 2026-03-17 21:34:48 +01:00
Pierre Tachoire
ba62150f7a add Form.requestSubmit(submitter) 2026-03-17 17:05:30 +01:00
Nikolay Govorov
8143a61955 Merge pull request #1888 from lightpanda-io/wp/mrdimidium/clenup-ci
Some checks failed
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Cleanup CI a little bit
2026-03-17 15:47:18 +00:00
Nikolay Govorov
e16c479781 Merge pull request #1886 from lightpanda-io/wp/mrdimidium/enable-git-version-in-ci
Use `git_version` option for version command
2026-03-17 14:29:47 +00:00
Nikolay Govorov
c0c4e26d63 removes artifacts of the past from CI 2026-03-17 14:24:22 +00:00
Nikolay Govorov
b252aa71d0 Use git_version option for version command 2026-03-17 13:25:15 +00:00
Pierre Tachoire
9ef8d9c189 Merge pull request #1887 from lightpanda-io/disable_observer_weak_ref
disable observer weak ref
2026-03-17 14:09:43 +01:00
Karl Seguin
9f27416603 zig fmt 2026-03-17 20:03:31 +08:00
Karl Seguin
0729f4a03a Merge pull request #1872 from lightpanda-io/wp/mrdimidium/fix-cdp-close
Gracefull close ws socket
2026-03-17 19:58:48 +08:00
Karl Seguin
21f7b95db9 disable observer weak ref
https://github.com/lightpanda-io/browser/pull/1870 doesn't work. I think there
are ways for the inspector to move objects into a context that skips our
reference count (those remote objects?). This disables weak references for
MutationObserver and IntersectionObserver. The issue is probably more widespread
but these are two types CDP drivers us _a lot_ via inspector, so this should
fix a number of immediate crashes.

I believe the correct fix is to remove Origin and store things at the Session-
level.
2026-03-17 19:54:21 +08:00
Nikolay Govorov
4125a5aa1e Merge pull request #1874 from JasonOA888/fix/add-git-version-option
feat: add `git_version` build option for release version detection
2026-03-17 11:27:27 +00:00
Nikolay Govorov
6d0dc6cb1e Gracefull close ws socket 2026-03-17 11:15:12 +00:00
Nikolay Govorov
0675c23217 Merge pull request #1883 from Tenith01/fix/port-already-in-use
fix: show actionable error when server port is already in use
2026-03-17 10:53:36 +00:00
Karl Seguin
d0e6a1f5bb Merge pull request #1882 from Tenith01/fix/window-onerror-special-case
fix: special-case Window#onerror per WHATWG spec (5-arg signature)
2026-03-17 18:36:11 +08:00
Karl Seguin
91afe08235 Merge pull request #1878 from mvanhorn/osc/1770-window-event
Implement window.event property
2026-03-17 18:35:30 +08:00
Karl Seguin
041d9d41fb Fallback to the Incumbent Context when the Current Context is dangling
This specifically fixes a WPT crash running:
/html/browsers/browsing-the-web/history-traversal/001.html

(And probably a few others).

Isolate::GetCurrentContext can return a 'detached' context. And, for us, that's
a problem, because 'detached' v8::Context references a js.Context that we've
deinit'd. This seems to only happen when frames pass values around to other
frames and then those frames are removed. It might also require some async'ing,
I'm not sure.

To solve this, when we destroy a js.Context, we store null in the v8::Context's
embedder data, removing the link to our (dead) js.Context. When we load a
js.Context from a v8.Context, we check for null. If it is null, we return the
Incumbent context instead. This should never be null, as it's always the context
currently executing code.

I'm not sure if falling back to the Incumbent context is always correct, but
it does solve the crash.
2026-03-17 18:04:44 +08:00
Karl Seguin
7009fb5899 Merge pull request #1880 from lightpanda-io/logfilter-init-slice
Some checks failed
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
LogFilter: init with slice and silence tests
2026-03-17 17:42:23 +08:00
Tenith01
d2003c7c9a fix: stub navigator.permissions, storage, deviceMemory to unblock Turnstile 2026-03-17 14:12:13 +05:30
Tenith01
ce002b999c fix: special-case Window#onerror per WHATWG spec (5-arg signature) 2026-03-17 13:49:59 +05:30
Adrià Arrufat
5b1056862a Merge pull request #1879 from lightpanda-io/fix-leak-add-from-element
ScriptManager: fix memory leak and resource handover
2026-03-17 16:33:49 +09:00
Tenith01
cc4ac99b4a fix: show actionable error when server port is already in use 2026-03-17 13:02:55 +05:30
Adrià Arrufat
46df341506 ScriptManager: defer resource handover until request success 2026-03-17 15:45:11 +09:00
Adrià Arrufat
b698e2d078 LogFilter: init with slice and silence tests 2026-03-17 13:42:35 +09:00
Karl Seguin
5cc5e513dd Merge pull request #1876 from lightpanda-io/more-mcp-tools
Add click, fill, and scroll DOM interaction tools to MCP and CDP
2026-03-17 12:39:35 +08:00
Adrià Arrufat
e048b0372f ScriptManager: fix memory leak and resource handover
Release the arena when an inline script is empty and ensure the
handover flag is set correctly for all script execution modes.
2026-03-17 13:32:29 +09:00
Adrià Arrufat
d7aaa1c870 Merge branch 'main' into more-mcp-tools 2026-03-17 13:26:44 +09:00
Adrià Arrufat
463aac9b59 browser.actions: refactor click to use trusted MouseEvent 2026-03-17 13:22:55 +09:00
Karl Seguin
d9cdd78138 Merge pull request #1875 from lightpanda-io/history_test_stability
Try to improve stability of history test
2026-03-17 12:21:29 +08:00
Adrià Arrufat
44a83c0e1c browser.actions: use .wrap directly
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-17 12:55:10 +09:00
Matt Van Horn
96f24a2662 Implement window.event property
Add the deprecated-but-widely-used window.event property that returns
the Event currently being handled. Returns undefined when no event is
being dispatched.

Implementation saves and restores window._current_event around handler
invocation in both dispatchDirect and dispatchNode, supporting nested
event dispatch correctly.

Fixes #1770

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:42:45 -07:00
Karl Seguin
5d2801c652 Support blob urls in XHR and Fetch
Used quite a bit in WPT. Not sure how common this is in real world though.
2026-03-17 10:31:32 +08:00
Karl Seguin
deb08b7880 Try to improve stability of history test
Tests cannot navigate away from the page page. If they do, the testRunner will
crash, as it tries to access `assertOk` on a page that no longer exists. This
commit hacks the history test, using an iframe, to try to test the history API
without navigating off the main page.
2026-03-17 08:15:49 +08:00
JasonOA888
96e5054ffc feat: add git_version build option for release version detection
- Add git_version option to build.zig (similar to git_commit)
- Update version command to output git_version when available
- Falls back to git_commit when not on a tagged release
- CI can pass -Dgit_version=$(git describe --tags --exact-match) for releases

Fixes #1867
2026-03-17 07:41:11 +08:00
Karl Seguin
c9753a690d Merge pull request #1863 from jnMetaCode/fix/cdp-missing-disable-methods
Some checks failed
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix(cdp): add missing disable methods to Performance, Inspector, Security domains
2026-03-17 07:21:14 +08:00
Karl Seguin
27aaf46630 Merge pull request #1873 from lightpanda-io/fix/domexception-default-messages
Fix/domexception default messages
2026-03-17 07:20:15 +08:00
Karl Seguin
84190e1e06 fix test for new messages 2026-03-17 07:07:16 +08:00
Karl Seguin
b0b1f755ea Merge pull request #1870 from lightpanda-io/mutation_observer_rc
Switch to reference counting for Mutation Observer and Intersection O…
2026-03-17 06:43:45 +08:00
Karl Seguin
fcf1d30c77 Merge pull request #1864 from lightpanda-io/trusted_cdp_clicks
click event dispastched from CDP should be trusted
2026-03-17 06:43:32 +08:00
Karl Seguin
3c532e5aef Merge pull request #1846 from lightpanda-io/origin_cdp_fix
Fix use-after-free with certain CDP scripts
2026-03-17 06:43:07 +08:00
Karl Seguin
3efcb2705d Merge pull request #1840 from lightpanda-io/script_manager_arena_pool
Move ScriptManager to ArenaPool.
2026-03-17 06:42:48 +08:00
Karl Seguin
c25f389e91 Merge pull request #1817 from lightpanda-io/frames_postMessage
window.postMessage across frames
2026-03-17 06:42:32 +08:00
Karl Seguin
533f4075a3 Merge pull request #1868 from lightpanda-io/bom_charset
Some checks failed
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
Set charset based on BOM
2026-03-16 23:36:44 +08:00
Adrià Arrufat
f508d37426 lp: validate params in node actions and rename variables 2026-03-16 23:50:15 +09:00
Adrià Arrufat
548c6eeb7a browser.actions: remove redundant result ignores 2026-03-16 23:45:07 +09:00
Adrià Arrufat
c8265f4807 browser.actions: improve error handling 2026-03-16 23:41:22 +09:00
Adrià Arrufat
a74e46debf actions: make scroll coordinates optional
Updates the scroll action to accept optional x and y coordinates. This
allows scrolling on a single axis without resetting the other to zero.
2026-03-16 22:44:37 +09:00
Karl Seguin
1ceaabe69f Switch to reference counting for Mutation Observer and Intersection Observer
This may be a stopgap.

Our identity model assumes that v8 won't allow cross-origin access. It turns out
that with CDP and Inspector, this isn't true. Inspectors can break / violate
cross-origin restrictions. The result is that 2 origins can see the same zig
instance, which causes 2 v8::Objects to reference the same Zig instance.

This likely causes some consistency issue. Like, if you take mo in 1 context,
and write an arbitrary property, mo.hack = true, you won't observe that in the
2nd context (because it's a different v8::Object). But, it _is_ the same Zig
instance, so if you set a known/real property, it will be updated.

That's probably a pretty minor issue. The bigger issue is that it can result in
a use-after-free when using explicit strong/weak ref:

1 - Mutation observer is created in Origin1
2 - It's automatically set to weak
3 - Something is observed, the reference is made strong
4 - The MO is accessed from Origin2
5 - Creates a new v8::Object
6 - Sets it to weak
7 - Object goes out of scope in Origin2
8 - Finalizer is called  <- free
9 - MO is manipulated in Origin 1 <- use after free

Maybe the right option is to have a single shared identity map. I need to think
about it. As a stopgap, switching to reference counting (which we already
support) shold prevent the use-after free. While we'll still create 2
v8::Objects, they'll each acquireRef (_rc = 2) and thus it won't be freed until
they both release i
Maybe the right option is to have a single shared identity map. I need to think
about it. As a stopgap, switching to reference counting (which we already
support) shold prevent the use-after free. While we'll still create 2
v8::Objects, they'll each acquireRef (_rc = 2) and thus it won't be freed until
they both release it.
2026-03-16 20:56:18 +08:00
Pierre Tachoire
91a2441ed8 Merge pull request #1829 from salmanmkc/upgrade-github-actions-node24
Upgrade GitHub Actions for Node 24 compatibility
2026-03-16 12:19:22 +01:00
Pierre Tachoire
2ecbc833a9 Merge pull request #1858 from lightpanda-io/flaky-wbatest
ci: fix wba flaky test
2026-03-16 11:13:43 +01:00
Pierre Tachoire
dac456d98c ci: fix wba flaky test
Sometimes the GHA secret isn't dump in file correctly.
So this commit inject the value directly to the command line
2026-03-16 10:57:40 +01:00
Karl Seguin
422320d9ac Set charset based on BOM
Small follow up to https://github.com/lightpanda-io/browser/pull/1837 If we
sniff the content type from the byte order mark (BOM), then we should set the
charset. This has higher precedence than sniffing the content type from the
content of the document (e.g. meta tags)
2026-03-16 17:54:01 +08:00
Karl Seguin
18b635936c Merge pull request #1837 from mvanhorn/osc/531-charset-prescan
Some checks failed
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
Implement charset detection from first 1024 bytes of HTML
2026-03-16 17:39:58 +08:00
Karl Seguin
7b2895ef08 click event dispastched from CDP should be trusted 2026-03-16 17:33:12 +08:00
jnMetaCode
b09e9f7398 fix(cdp): add missing disable method to Security
Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:21:20 +08:00
jnMetaCode
ac651328c3 fix(cdp): add missing disable method to Inspector
Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:21:18 +08:00
jnMetaCode
0380df1cb4 fix(cdp): add missing disable method to Performance
Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:21:14 +08:00
jnMetaCode
21421d5b53 fix(dom): add default messages for all DOMException error codes
The getMessage() fallback returned raw tag names like
"wrong_document_error" instead of human-readable messages.
Fill in all 18 error codes with messages based on the
WebIDL spec error descriptions.

Closes #82

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:20:29 +08:00
jnMetaCode
80c309aa69 fix(cdp): add noop Emulation.setUserAgentOverride to prevent Playwright crash
Playwright calls Emulation.setUserAgentOverride when creating a
browser context with a custom user agent. Without this handler,
Lightpanda returns UnknownMethod which crashes the Playwright
driver.

Add a noop handler matching the existing pattern for other
Emulation methods (setDeviceMetricsOverride, setEmulatedMedia, etc.)
so the CDP handshake can proceed.

Fixes #1436

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-16 17:07:56 +08:00
Adrià Arrufat
f5bc7310b1 actions: refactor node type checks for idiomatic flattening 2026-03-16 16:38:21 +09:00
Adrià Arrufat
21e9967a8a actions: simplify function names 2026-03-16 16:31:33 +09:00
Adrià Arrufat
32f450f803 browser: centralize node interaction logic
Extracts click, fill, and scroll logic from CDP and MCP domains into a
new dedicated actions module to reduce code duplication.
2026-03-16 14:22:15 +09:00
Adrià Arrufat
1972142703 mcp: add tests for click, fill, and scroll actions 2026-03-16 14:16:20 +09:00
Adrià Arrufat
b10d866e4b Add click, fill, and scroll interaction tools
Adds click, fill, and scroll functionality to both CDP and MCP
to support programmatic browser interactions.
2026-03-16 13:55:37 +09:00
Matt Van Horn
b373fb4a42 Address review feedback: fix endless loop, use stdlib, add charset flag
- Use std.ascii.eqlIgnoreCase instead of custom asciiEqlIgnoreCase
- Fix infinite loop in findAttrValue when attribute has no '=' sign
  (e.g. self-closing <meta foo="bar"/>)
- Add is_default_charset flag to Mime struct so prescan only overrides
  charset when Content-Type header didn't set one explicitly
- Add regression test for the self-closing meta loop case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:20:45 -07:00
Adrià Arrufat
ddd34dc57b Merge pull request #1836 from mvanhorn/osc/1822-fix-axvalue-integer-string
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix: serialize AXValue integer as string per CDP spec
2026-03-16 09:55:54 +09:00
Karl Seguin
265c5aba2e Merge pull request #1850 from navidemad/fix/cdp-websocket-timeout-during-navigation
Fix CDP WebSocket connection dying during complex page navigation
2026-03-16 08:41:36 +08:00
Adrià Arrufat
21fc6d1cf6 cdp: explain buffer size for int serialization 2026-03-16 09:41:28 +09:00
Karl Seguin
1a7fe6129c Merge pull request #1847 from lightpanda-io/blob_fixes
Fix issues with blobs
2026-03-16 08:34:38 +08:00
Karl Seguin
37462a16c5 Merge pull request #1853 from lightpanda-io/fix-ignore-partition-key
Fix ignore partition key
2026-03-16 08:19:09 +08:00
Karl Seguin
323ec0046c zig fmt 2026-03-16 07:36:14 +08:00
Karl Seguin
dc7c6984fb Merge pull request #1852 from lightpanda-io/fix-domparser-error-doc
Fix domparser error doc
2026-03-16 07:35:50 +08:00
Karl Seguin
92f7248a16 Merge pull request #1851 from lightpanda-io/fix-fetch-error-reject
Fix fetch error reject
2026-03-16 07:35:38 +08:00
Karl Seguin
1ec3e156fb Fix partitionKey ignore PR
Fixes https://github.com/lightpanda-io/browser/pull/1821 so that it compiles
2026-03-16 07:28:14 +08:00
Karl Seguin
1121bed49b remove test that I guess isn't reliable (CI?) 2026-03-16 07:20:57 +08:00
Karl Seguin
0eb43fb530 Fix test
Fixes test associated with https://github.com/lightpanda-io/browser/pull/1827
2026-03-16 07:16:27 +08:00
Karl Seguin
1f50dc38c3 Merge pull request #1845 from navidemad/fix-cdp-unknown-domain-disconnect
fix(cdp): don't kill WebSocket on unknown domain/method errors
2026-03-16 07:14:18 +08:00
Karl Seguin
a9d044ec10 revert domparser test change that belongs to a different PR 2026-03-16 07:11:06 +08:00
Navid EMAD
1bdf464ef2 Fix CDP WebSocket connection dying during complex page navigation
The CDP timeout handler in httpLoop had two compounding bugs:

1. Unit mismatch: timestamp(.monotonic) returns seconds, but
   ms_remaining is in milliseconds. The comparison and subtraction
   mixed units.

2. Double-counting: In the .done branch, elapsed was computed as
   absolute time since last_message, but last_message was never
   updated in this branch. Each iteration subtracted the growing
   total elapsed seconds from an already-decremented ms_remaining.

During complex page loads, Session._wait() returns .done rapidly
(due to JS macrotask execution, background tasks, or errors). Each
rapid .done return subtracted the growing elapsed (seconds) from
ms_remaining (milliseconds), draining it to zero in ~2 seconds
instead of the configured 10-second timeout.

Fix: use milliTimestamp() for consistent units, update last_message
in the .done branch for incremental elapsed tracking, and use >= for
correct boundary comparison.

Fixes #1849

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:07:23 +01:00
katie-lpd
a70da0d176 Update README.md 2026-03-15 16:57:09 +01:00
katie-lpd
8c52b8357c Update README.md 2026-03-15 16:33:53 +01:00
Karl Seguin
0243c6b450 Fix issues with blobs
https://github.com/lightpanda-io/browser/pull/1775 made blobs finalizable and
https://github.com/lightpanda-io/browser/pull/1795 made it possible to navigate
from blobs (important for WPT tests). This fixes a number of issues related to
both.

First, weak/strong ref'ing a value now uses the resolved value. When registering
a finalizer, we use the resolved value (the most specific type in the prototype
chain). For this reason, when toggling a weak/strong ref, we have to use the
same resolved value. This solves a segfault where a File is created, but
extended as a Blob (e.g. in createObjectURL).

Next, two issues were fixed when navigating to an invalid blob. First, the frame
is properly removed from the parent list on frame navigation error. Second,
on frame navigation error, we don't stop _all_ other navigations, we just log
the error and move on to the next frame.
2026-03-15 21:03:55 +08:00
Adrià Arrufat
f7071447cb Merge pull request #1834 from evalstate/mcp-ping
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
feat(mcp): add ping request handling
2026-03-15 18:15:51 +09:00
Halil Durak
c038bfafa1 Merge pull request #1772 from lightpanda-io/nikneym/failing-body-onload-tests
Add failing `body.onload` tests
2026-03-15 10:58:39 +03:00
sjhddh
4d60f56e66 test: add test case for fetch throwing TypeError on network errors 2026-03-15 07:26:18 +00:00
sjhddh
56d3cf51e8 test: update empty xml parse error case in domparser.html 2026-03-15 07:25:47 +00:00
sjhddh
3013e3a9e6 fix(net): fetch() should reject with a TypeError on network errors 2026-03-15 07:25:47 +00:00
Navid EMAD
fe9b2e672b fix(test): update tests to match new CDP error handling behavior
processMessage no longer returns Zig errors when dispatchCommand fails —
it sends a CDP error response and continues. Update all expectError calls
to use processMessage + expectSentError instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 05:52:20 +01:00
Karl Seguin
3e9fa4ca47 Fix use-after-free with certain CDP scripts
Origins were introduced to group memory/data that can be owned by multiple
frames (on the same origin). There's a general idea that the initial "opaque"
origin is very transient and should get replaced before any actual JavaScript
is executed (because the real origin is setup as soon as we get the header from
the response, long before we execute any script).

But...with CDP, this guarantee doesn't hold There's nothing stop a CDP script
from executing javascript at any point, including while the main page is still
being loaded. This can result on allocations made on the opaque origin which
is promptly discarded.

To solve this, this commit introduced origin takeover. Rather than just
transferring any data from one origin (the opaque) to the new one and then
deinit' the opaque one (which is what results in user-after-free), the new
origin simply maintains a list of opaque origins it has "taken-over"and is
responsible for freeing it (in its own deinit). This ensures that any allocation
made in the opaque origin remain valid.
2026-03-15 12:00:42 +08:00
Navid EMAD
a2e66f85a1 fix(cdp): don't kill WebSocket on unknown domain/method errors
When a CDP command with an unrecognized domain (e.g. `NonExistent.method`)
was sent, the error response was correctly returned but the connection
died immediately after. This happened because dispatch() re-returned the
error after sending the error response, which propagated up through
processMessage() → handleMessage() where `catch return false` closed
the WebSocket connection.

Now the error is only propagated if sendError itself fails (e.g. broken
pipe). Otherwise dispatch() returns normally and the read loop continues.

Fixes #1843

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 04:36:47 +01:00
Karl Seguin
a9b9cf14c3 Merge pull request #1841 from lightpanda-io/reject_error
Improve ergonomics around rejecting a promise with a proper JS error
2026-03-15 10:19:24 +08:00
Karl Seguin
d4b941cf30 zig fmt 2026-03-15 10:06:20 +08:00
Karl Seguin
4b6bf29b83 Improve ergonomics around rejecting a promise with a proper JS error 2026-03-15 09:55:13 +08:00
Karl Seguin
a8b147dfc0 update v8 2026-03-15 09:24:42 +08:00
Karl Seguin
65627c1296 Move ScriptManager to ArenaPool.
This removes the BufferPool. The BufferPool was per-ScriptManager and only
usable for the response. The ArenaPool is shared across pages and threads, so
can provide much better re-use. Furthermore, the ArenaPool provides an
Allocator, so that a Script's URL or inline content can be owned by the arena/
script itself, rather than the page arena.
2026-03-15 09:18:13 +08:00
Matt Van Horn
3dcdaa0a9b Implement charset detection from first 1024 bytes of HTML
Per the HTML spec, browsers should detect charset from <meta> tags
in the first 1024 bytes of a document when the HTTP Content-Type
header doesn't specify one.

Adds Mime.prescanCharset() which scans for:
- <meta charset="X">
- <meta http-equiv="Content-Type" content="...;charset=X">

Integrates into the page loading flow to set the detected charset
on the Mime when no explicit HTTP charset was provided.

Fixes #531
2026-03-14 14:15:40 -07:00
Matt Van Horn
5bc00045c7 fix: serialize AXValue integer as string per CDP spec
The CDP Accessibility spec defines AXValue.value as always being a
string, but integer values were serialized as JSON numbers. This
breaks CDP clients with strict deserialization (e.g., Rust serde).

Fixes #1822
2026-03-14 14:09:49 -07:00
evalstate
93ea95af24 feat(mcp): add ping request handling 2026-03-14 17:48:29 +00:00
Karl Seguin
f754773bf6 window.postMessage across frames
Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/160

Improves postMessage support, specifically for use across frames. This commit
also addresses a few other issues (identified while implementing this).

1 - Since macrotasks can schedule more macrotasks, we need to check the time-to-
next microtask after all microtasks have completed.

2 - frame's onload callback is triggered from the frame's context, but has to
    execute on the parents contet.
2026-03-14 21:04:50 +08:00
Karl Seguin
42bb2f3c58 Merge pull request #1823 from lightpanda-io/remove_double_free
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Remove frame double-free on navigate error
2026-03-14 19:36:27 +08:00
hobostay
68337a6989 Fix compilation errors: add missing log import and remove duplicate
- Add missing `const log = @import("../../log.zig");` in network.zig
- Remove duplicate `log` declaration inside setCdpCookie in storage.zig
  (already declared at file scope)

Fixes compilation errors:
- src/cdp/domains/network.zig:124:9: error: use of undeclared identifier 'log'
- src/cdp/domains/storage.zig:135:15: error: local constant shadows declaration of 'log'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:32:48 +08:00
Salman Muin Kayser Chishti
bf6dbedbe4 Upgrade GitHub Actions for Node 24 compatibility
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-03-14 09:11:46 +00:00
sjhddh
a204f40968 fix(dom): return parsererror document on XML parse failure 2026-03-14 08:36:06 +00:00
Karl Seguin
fe3faa0a5a Merge pull request #1825 from sjhddh/fix-tracking-allocator-resize
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix: only increment TrackingAllocator reallocation_count on successful resizes
2026-03-14 15:48:49 +08:00
Karl Seguin
39d5a25258 Merge pull request #1820 from hobostay/fix-tracking-allocator-stats
Fix TrackingAllocator reallocation_count being incremented on failed operations
2026-03-14 15:48:13 +08:00
Karl Seguin
f4044230fd Merge pull request #1824 from sjhddh/fix-option-gettext-leak
fix: resolve memory leak in Option.getText() by using page arena
2026-03-14 15:45:53 +08:00
sjhddh
4d6d8d9a83 fix(test): properly count successful reallocations in TrackingAllocator 2026-03-14 06:57:04 +00:00
sjhddh
c4176a282f fix: resolve memory leak in Option.getText() by using page arena 2026-03-14 06:50:26 +00:00
Karl Seguin
1352839472 Remove frame double-free on navigate error
The explicit deinit isn't needed as here's already an errdefer in play.
2026-03-14 14:02:58 +08:00
Karl Seguin
535128da71 Merge pull request #1814 from lightpanda-io/nikneym/window-onload-alias
Make `body.onload` getter/setter alias to `window.onload`
2026-03-14 13:30:10 +08:00
hobostay
099550dddc Ignore partitionKey in cookie operations to support Puppeteer page.setCookie()
Puppeteer's page.setCookie() internally calls Network.deleteCookies twice
before setting a cookie. The second call includes a partitionKey field for
CHIPS (partitioned cookies), which caused Lightpanda to return NotImplemented.

Since Lightpanda doesn't support partitioned cookies, we now silently ignore
the partitionKey parameter and proceed with the cookie operation based on
name/domain/path matching.

This change affects:
- Network.deleteCookies: no longer rejects requests with partitionKey
- Network.setCookie (via setCdpCookie): no longer rejects cookies with partitionKey

Fixes #1818

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:18:42 +08:00
hobostay
7fe26bc966 Fix TrackingAllocator reallocation_count being incremented on failed operations
The reallocation_count counter was being incremented regardless of whether
the resize/remap operations succeeded. This led to inaccurate memory
allocation statistics.

- resize: Only increment when rawResize returns true (success)
- remap: Only increment when rawRemap returns non-null (success)

This fixes the TODO comments that were present in the code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 13:10:11 +08:00
Halil Durak
cc6587d6e5 make body.onload getter/setter alias to window.onload 2026-03-13 18:49:26 +03:00
Halil Durak
8b310ce993 add failing body.onload tests 2026-03-13 17:23:26 +03:00
Karl Seguin
be8ba53263 Merge pull request #1811 from lightpanda-io/script_handling
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Better script handling.
2026-03-13 21:40:19 +08:00
Pierre Tachoire
043d48d1c7 Merge pull request #1812 from lightpanda-io/longer-sleep
ci: add a longer sleep to wait for node start on wba test
2026-03-13 13:59:03 +01:00
Karl Seguin
e8fe80189b Merge pull request #1808 from lightpanda-io/cdp_startup_frames
Tweak CDP startup messages.
2026-03-13 19:24:14 +08:00
Pierre Tachoire
0e48f317cb ci: add a longer sleep to wait for node start on wba test 2026-03-13 12:22:48 +01:00
Karl Seguin
867745c71d Tweak CDP startup messages.
1 - When Target.setAutoAttach is called, send the `Target.attachedToTarget`
    event before sending the response. This matches Chrome's behavior and
    it stops playwright from thinking there's no target and making extra calls,
    e.g. to Target.attachedToTarget.

2 - Use the same dummy frameId for all startup messages. I'm not sure why we
    have STARTUP-P and STARTUP-B. Using the same frame (a) makes more sense to
    me (b) doesn't break any existing integration tests, and (c) improves this
    scenario: https://github.com/lightpanda-io/browser/issues/1800
2026-03-13 19:07:47 +08:00
Karl Seguin
a1a7919f74 Better script handling.
Dynamic scripts have script.async == true by default (we handled this correctly
in the ScriptManager, but we didn't return the right value when .async was
accessed).

Inline scripts only consider direct children, not the entire tree.

Empty inline scripts are executed at a later time if text is inserted into them
2026-03-13 19:05:23 +08:00
Pierre Tachoire
c3de47de90 Merge pull request #1810 from lightpanda-io/fix_cookie_loading
Ensure valid cookie isn't interpreted as null
2026-03-13 11:26:24 +01:00
Pierre Tachoire
dd35bdfeb4 Merge pull request #1809 from lightpanda-io/fix_flaky_test
Fix a flaky frame test
2026-03-13 10:17:01 +01:00
Karl Seguin
07c3aec34f Ensure valid cookie isn't interpreted as null
Use an explicit type when @ptrCast() is assigned to an optional to ensure the
value isn't interpreted as null.
2026-03-13 17:00:59 +08:00
Karl Seguin
bce3e8f7c6 Fix a flaky frame test
Loading `sub 1.html` has a side effect - it increments window.top..sub1_count).
So it should be used careful. It was being used in `about_blank_renavigate` as
a placeholder which _should_ not get navigated, but there's no strict guarantee
about when it gets canceled.
2026-03-13 15:39:34 +08:00
Pierre Tachoire
ba9777e754 Merge pull request #1609 from lightpanda-io/web-bot-auth
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Web Bot Auth
2026-03-13 08:31:25 +01:00
Pierre Tachoire
7040801dfa Merge pull request #1790 from lightpanda-io/structuredClone_serializer
Add window.structuredClone
2026-03-13 08:29:49 +01:00
Karl Seguin
4f8a6b62b8 Add window.structuredClone
Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/156

Uses V8::Serializer and V8::Deserializer which handles built-in types, e.g.
regex. But it doesn't handle Zig types by default. This is something we need
to hook in, using the delegate callbacks. Which we can do after.

Meant to replace https://github.com/lightpanda-io/browser/pull/1785
2026-03-13 07:28:33 +08:00
Karl Seguin
d3dad772cf Merge pull request #1806 from lightpanda-io/update_zig_v8_action
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
update action.yml to latest zig-v8
2026-03-13 07:26:18 +08:00
Karl Seguin
944b672fea Merge pull request #1792 from lightpanda-io/Canvas_getImageData
Add dummy getImageData to canvas
2026-03-13 07:23:05 +08:00
Karl Seguin
b1c54aa92d Merge pull request #1795 from lightpanda-io/navigate_blob_url
Allow navigation from a blob URL.
2026-03-13 07:22:50 +08:00
Karl Seguin
4ca6f43aeb Merge pull request #1803 from lightpanda-io/fix-redirection-cookies
parse cookies on redirection during header callback
2026-03-13 07:17:51 +08:00
Karl Seguin
f09e66e1cc update action.yml to latest zig-v8 2026-03-13 07:15:23 +08:00
Karl Seguin
8b7a4ceaaa Merge pull request #1794 from lightpanda-io/update-docker-zig
update zig-v8 in dockerfile
2026-03-13 07:14:35 +08:00
Pierre Tachoire
51e90f5971 parse cookies on redirection during header callback
THe change to handle bot `\n` and `\r\n` for end HTTP headers skip the
cookie parsing in case of redirection.
2026-03-12 18:42:51 +01:00
Muki Kiboigo
8db64772b7 add URL getHost test 2026-03-12 09:04:13 -07:00
Muki Kiboigo
bf0be60b89 use new validator for e2e test 2026-03-12 09:04:13 -07:00
Muki Kiboigo
172481dd72 add e2e tests w/ web bot auth 2026-03-12 09:04:13 -07:00
Muki Kiboigo
c6c0492c33 fix request authentication with web bot auth 2026-03-12 09:04:13 -07:00
Muki Kiboigo
fca29a8be2 add WebBotAuth unit tests 2026-03-12 09:04:13 -07:00
Muki Kiboigo
d365240f91 fix cli argument for WebBotAuth domain 2026-03-12 09:04:12 -07:00
Muki Kiboigo
1ed61d4783 simplify parsePemPrivateKey 2026-03-12 09:04:12 -07:00
Muki Kiboigo
a1fb11ae33 make pem private key buffers smaller with comments 2026-03-12 09:04:12 -07:00
Muki Kiboigo
9971816711 use transfer arena to sign webbotauth request 2026-03-12 09:04:12 -07:00
Muki Kiboigo
c38d9a3098 auth challenge only on use_proxy 2026-03-12 09:04:12 -07:00
Muki Kiboigo
02198de455 add support for WebBotAuth in Client 2026-03-12 09:04:10 -07:00
Muki Kiboigo
6cd8202310 add WebBotAuth and support for ed25119 to crypto 2026-03-12 09:03:15 -07:00
Muki Kiboigo
4d7b7d1d42 add web bot auth args 2026-03-12 09:03:15 -07:00
Karl Seguin
e4e21f52b5 Allow navigation from a blob URL.
These are used a lot in WPT test.
2026-03-12 18:58:10 +08:00
Pierre Tachoire
84e1cd08b6 update zig-v8 in dockerfile 2026-03-12 11:54:06 +01:00
Pierre Tachoire
7796753e7a Merge pull request #1791 from lightpanda-io/wp/mrdimidium/update-v8
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Update zig-v8
2026-03-12 11:48:27 +01:00
Karl Seguin
880205e874 Add dummy getImageData to canvas
Probably doesn't solve many (if any) WPT tests, but it moves them further along.
2026-03-12 17:53:00 +08:00
Nikolay Govorov
1b96087b08 Update zig-v8 2026-03-12 08:50:33 +00:00
Karl Seguin
aa246c9e9f Merge pull request #1788 from lightpanda-io/range_cleanup
Add cleanup to Range
2026-03-12 16:45:05 +08:00
Karl Seguin
f1d311d232 Merge pull request #1781 from lightpanda-io/wp/mrdimidium/telemetry-network
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Use global connections poll
2026-03-12 13:46:51 +08:00
Karl Seguin
e4f7fca10d Merge pull request #1789 from lightpanda-io/fix-test-warnings
testing: add LogFilter utility for scoped log suppression
2026-03-12 13:40:13 +08:00
Adrià Arrufat
3d6d669a50 testing: add LogFilter utility for scoped log suppression 2026-03-12 13:56:53 +09:00
Nikolay Govorov
c4097e2b7e remove dead-code 2026-03-12 03:55:48 +00:00
Karl Seguin
619d27c773 Add cleanup to Range
In https://github.com/lightpanda-io/browser/pull/1774 we started to track Ranges
in the page in order to correctly make them "live". But, without correct
lifetime, they would continue to be "live" even if out of scope in JS.

This commit adds finalizers to Range via reference counting similar to Events.
It _is_ possible for a Range to outlive its page, so we can't just remove the
range from the Page's _live_range list - the page might not be valid. This
commit gives every page an unique id and the ability to try and get the page
by id from the session. By capturing the page_id at creation-time, a Range
can defensively remove itself from the page's list. If the page is already
gone, then there's no need to do anything.
2026-03-12 10:38:07 +08:00
Karl Seguin
1522c90294 Merge pull request #1787 from lightpanda-io/dummy-filelist
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add FileList Web API stub
2026-03-12 06:37:00 +08:00
Karl Seguin
794e15ce21 Merge pull request #1786 from lightpanda-io/fontfaceset-load
FontFaceSet is now an EventTarget
2026-03-12 06:36:22 +08:00
Karl Seguin
34771b835e Merge pull request #1783 from lightpanda-io/custom_element_dynamic_markup_handling
Throw on dynamic markup in custom element callbacks during parsing
2026-03-12 06:27:22 +08:00
Karl Seguin
8df51b232a Merge pull request #1784 from lightpanda-io/origin_arena
Use origin.arena for values that are tied to the origin
2026-03-12 06:26:20 +08:00
Karl Seguin
13b8ce18b2 Merge pull request #1780 from lightpanda-io/anchor_and_form_target
Add support for target attribute on anchors and forms
2026-03-12 06:26:08 +08:00
Pierre Tachoire
448386e52b Add FileList Web API stub
Next.js hydration references FileList as a global for feature detection.
Register a minimal stub (length=0, item()→null) so the type exists in
the global scope and the reference check doesn't throw.
2026-03-11 22:31:12 +01:00
Pierre Tachoire
bf07659dd5 FontFaceSet is now an EventTarget
Dispatch loading and loaddone events on load() call
2026-03-11 22:18:42 +01:00
Karl Seguin
16dfad0895 Use origin.arena for values that are tied to the origin
Of note, the TAO and identity map entry has to use the origin arena, not
the context arena, as those can outlive the context.
2026-03-11 21:55:58 +08:00
Adrià Arrufat
f61449c31c Merge pull request #1776 from lightpanda-io/semantic-tree
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add native Semantic Tree extraction engine for AI agents
2026-03-11 21:01:04 +09:00
Adrià Arrufat
60699229ca Merge branch 'main' into semantic-tree 2026-03-11 20:52:39 +09:00
Karl Seguin
e1dd26b307 Throw on dynamic markup in custom element callbacks during parsing
Custom element callbacks aren't allowed to call document.open/close/write while
parsing.

Fixes WPT crash:
/custom-elements/throw-on-dynamic-markup-insertion-counter-reactions.html
2026-03-11 18:41:06 +08:00
Pierre Tachoire
7d835ef99d Merge pull request #1778 from lightpanda-io/wp/mrdimidium/libcurl-malloc
Use zig allocator for libcurl
2026-03-11 10:13:13 +01:00
Karl Seguin
0971df4dfc Merge pull request #1782 from lightpanda-io/silence_shutdown_error_on_non_linux
Don't log SocketNotConnected when shutting down listener on non-Linux
2026-03-11 16:39:15 +08:00
Halil Durak
9fb57fbac0 Merge pull request #1771 from lightpanda-io/nikneym/compile-function
Prefer `ScriptCompiler::CompileFunction` to compile attribute listeners
2026-03-11 11:38:16 +03:00
Karl Seguin
48ead90850 Don't log SocketNotConnected when shutting down listener on non-Linux
On BSD, a listening socket isn't considered connected, so this error is
expected. Maybe we shouldn't call shutdown at all, but I guess it's safer this
way.
2026-03-11 16:29:44 +08:00
Pierre Tachoire
cc88bb7feb Merge pull request #1777 from lightpanda-io/mcp-missing-lp-commands
mcp: add interactiveElements and structuredData tools
2026-03-11 09:11:48 +01:00
Karl Seguin
a725e2aa6a Merge pull request #1774 from egrs/range-chardata-mutations
update live ranges after CharacterData and DOM mutations
2026-03-11 16:04:41 +08:00
Karl Seguin
ee637c3662 Add support for target attribute on anchors and forms 2026-03-11 15:49:30 +08:00
Adrià Arrufat
65d7a39554 SemanticTree: use payload captures for CData.Text checks
Improves conciseness and idiomatic Zig style by replacing .is(CData.Text) != null and .as() with direct payload captures in if statements.
2026-03-11 16:39:59 +09:00
Adrià Arrufat
37735b1caa SemanticTree: use StaticStringMap for structural role check
Improves performance and readability of isStructuralRole. Also includes minor syntax cleanup in AXNode.
2026-03-11 16:37:24 +09:00
Pierre Tachoire
c8f8d79f45 Merge pull request #1775 from lightpanda-io/arena_blob
Use arena from ArenaPool for Blob (and File)
2026-03-11 08:35:27 +01:00
Adrià Arrufat
1866e7141e SemanticTree: cast with as
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-11 16:33:39 +09:00
Adrià Arrufat
feccc9f5ce AXNode: remove unused mock JSON lifecycle methods
Simplifies TextCaptureWriter by removing unused methods, ensuring future changes to writeName will fail at build time if new methods are required.
2026-03-11 16:25:34 +09:00
Adrià Arrufat
af803da5c8 cdp.lp: use enum for getSemanticTree format param
Leverages std.json.parse to automatically validate the format param into a type-safe enum.
2026-03-11 16:21:43 +09:00
egrs
25c89c9940 Revert "remove ranges from live list on GC finalization"
This reverts commit 625d424199.
2026-03-11 08:04:53 +01:00
egrs
697a2834c2 Revert "fix CI: store list pointer on AbstractRange to avoid Page type mismatch"
This reverts commit 056b8bb536.
2026-03-11 08:04:51 +01:00
egrs
056b8bb536 fix CI: store list pointer on AbstractRange to avoid Page type mismatch
The bridge.finalizer resolves Page through its own module graph, which
can differ from Range.zig's import in release builds. Store a pointer
to the live_ranges list directly on AbstractRange so deinit can remove
without accessing Page fields.
2026-03-11 07:58:31 +01:00
egrs
625d424199 remove ranges from live list on GC finalization
Add a weak finalizer to Range that removes its linked list node from
Page._live_ranges when V8 garbage-collects the JS Range object. This
prevents the list from growing unboundedly and avoids iterating over
stale entries during mutation updates.
2026-03-11 07:27:39 +01:00
Adrià Arrufat
5329d05005 interactive: optimize getTextContent single-chunk path
Avoids an unnecessary double allocation and maintains a zero-copy fast path for single-chunk text extraction.
2026-03-11 15:27:12 +09:00
egrs
d2c55da6c9 address review: move per-range logic to AbstractRange, simplify collapsed check
Move the per-range update logic from Page into AbstractRange methods
(updateForCharacterDataReplace, updateForSplitText, updateForNodeInsertion,
updateForNodeRemoval). Page now just iterates the list and delegates.

Remove redundant start_container == end_container check in insertNode —
collapsed already implies same container.
2026-03-11 07:26:20 +01:00
Adrià Arrufat
2e6dd3edfe browser.EventManager: remove unused hasListener function 2026-03-11 15:18:14 +09:00
Nikolay Govorov
a95b4ea7b9 Use global connections poll 2026-03-11 05:44:59 +00:00
Nikolay Govorov
c891eff664 Use zig allocator for libcurl 2026-03-11 03:34:27 +00:00
Adrià Arrufat
68564ca714 mcp: add interactiveElements and structuredData tools 2026-03-11 11:09:19 +09:00
Adrià Arrufat
ca931a11be AXNode: add spacing between concatenated text nodes
When calculating accessible names for elements without explicit labels, multiple descendant text nodes were previously concatenated directly together. This adds a space between distinct text node contents to prevent words from sticking together.
2026-03-11 10:45:07 +09:00
Adrià Arrufat
6c7272061c cli: enable pruning for semantic_tree_text dump mode
Previously, semantic_tree_text hardcoded prune = false, which bypassed the structural node filters and allowed empty none nodes to pollute the root of the text dump.
2026-03-11 10:38:12 +09:00
Adrià Arrufat
4f262e5bed SemanticTree: filter computed names for generic containers
This prevents token bloat in JSON/text dumps and ensures that StaticText leaf nodes are not incorrectly pruned when structural containers (like none, table) hoist their text.
2026-03-11 10:22:40 +09:00
Karl Seguin
ff26b0d5a4 Use arena from ArenaPool for Blob (and File) 2026-03-11 09:21:54 +08:00
Adrià Arrufat
a6ccc72d15 interactive: properly concatenate text content for accessible names
This fixes a bug where only the first text node was being returned, causing fragmented text nodes (e.g. <span>Sub</span><span>mit</span>) to be missing their trailing text.
2026-03-11 09:57:08 +09:00
Karl Seguin
487ee18358 Merge pull request #1742 from lightpanda-io/context_origins
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Context origins
2026-03-11 08:54:53 +08:00
Karl Seguin
dc3d2e9790 Remove root context check from Env
This was only added [very briefly] when Env managed Origins, which it no longer
does.
2026-03-11 08:44:52 +08:00
Karl Seguin
f6d0e484b0 transfer finalizers on origin change 2026-03-11 08:44:52 +08:00
Karl Seguin
4cea9aba3c update v8 dep 2026-03-11 08:44:51 +08:00
Karl Seguin
7348a68c84 merge main 2026-03-11 08:44:51 +08:00
Karl Seguin
7d90c3f582 Move origin lookup to Session
With the last commit, this becomes the more logical place to hold this as it
ties into the Session's awareness of the root page's lifetime.
2026-03-11 08:44:51 +08:00
Karl Seguin
2a103fc94a Use Session as a container for cross-frame resources
The introduction of frames means that data is no longer tied to a specific Page
or Context. 255b9a91cc introduced Origins for
v8 values shared across frames of the same origin. The commit highlighted the
lifetime mismatched that we now have with data that can outlive 1 frame. A
specific issue with that commit was the finalizers were still Context-owned.
But like any other piece of data, that isn't right; aside from modules, nothing
should be context-owned.

This commit continues where the last left off and moves finalizers from Context
to Origin. This is done in a separate commit because it introduces significant
changes. Currently, finalizers take a *Page, but that's no longer correct. A
value created in one Page, can outlive the Page. We need another container. I
original thought to use Origin, but that isn't know to CDP/MCP. Instead, I
decide to enhance the Session.

Session is now the owner of the page.arena, the page.factory and the
page.arena_pool. Finalizers are given a *Session which they can use to release
their arena.
2026-03-11 08:44:49 +08:00
Karl Seguin
753391b7e2 Add origins safety cleanup when destroying the context for the root page 2026-03-11 08:43:41 +08:00
Karl Seguin
94ce5edd20 Frames on the same origin share v8 data
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/153

In some ways this is an extension of
https://github.com/lightpanda-io/browser/pull/1635 but it has more implications
with respect to correctness.

A js.Context wraps a v8::Context. One of the important thing it adds is the
identity_map so that, given a Zig instance we always return the same v8::Object.

But imagine code running in a frame. This frame has its own Context, and thus
its own identity_map. What happens when that frame does:

```js
window.top.frame_loaded = true;
```

From Zig's point of view, `Window.getTop` will return the correct Zig instance.
It will return the *Window references by the "root" page. When that instance is
passed to the bridge, we'll look for the v8::Object in the Context's
`identity_map` but wont' find it. The mapping exists in the root context
`identity_map`, but not within this frame. So we create a new v8::Object and now
our 1 zig instance has N v8::Objects for every page/frame that tries to access
it.

This breaks cross-frame scripting which should work, at least to some degree,
even when frames are on the same origin.

This commit adds a `js.Origin` which contains the `identity_map`, along with our
other `v8::Global` storage. The `Env` now contains a `*js.Origin` lookup,
mapping an origin string (e.g. lightpanda.io:443) to an *Origin. When a Page's
URL is changed, we call `self.js.setOrigin(new_url)` which will then either get
or create an origin from the Env's origin lookup map.

js.Origin is reference counted so that it remains valid so long as at least 1
frame references them.

There's some special handling for null-origins (i.e. about:blank). At the root,
null origins get a distinct/isolated Origin. For a frame, the parent's origin
is used.

Above, we talked about `identity_map`, but a `js.Context` has 8 other fields
to track v8 values, e.g. `global_objects`, `global_functions`,
`global_values_temp`, etc. These all must be shared by frames on the same
origin. So all of these have also been moved to js.Origin. They've also been
merged so that we now have 3 fields: `identity_map`, `globals` and `temps`.

Finally, when the origin of a context is changed, we set the v8::Context's
SecurityToken (to that origin). This is a key part of how v8 allows cross-
context access.
2026-03-11 08:43:40 +08:00
Nikolay Govorov
3626f70d3e Merge pull request #1759 from lightpanda-io/wp/mrdimidum/net-poll-runtime
Network poll runtime
2026-03-10 23:38:07 +00:00
Nikolay Govorov
24cc24ed50 Fix Robots deinit 2026-03-10 23:28:40 +00:00
Karl Seguin
dd29ba4664 Merge pull request #1767 from egrs/css-value-normalization-gaps
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
extend CSS value normalization to cover more properties
2026-03-11 06:28:34 +08:00
egrs
7927ad8fcf route appendData through replaceData for spec compliance
Per DOM spec, appendData(data) is defined as replaceData(length, 0, data).
While the range update would be a no-op (offset=length, count=0), routing
through replaceData ensures consistent code path and spec compliance.
2026-03-10 20:27:05 +01:00
egrs
d23453ce45 update live ranges after CharacterData and DOM mutations
Per DOM spec, all live ranges must have their boundary offsets updated
when CharacterData content changes (insertData, deleteData, replaceData,
splitText) or when nodes are inserted/removed from the tree.

Track live ranges via an intrusive linked list on Page. After each
mutation, iterate and adjust start/end offsets per the spec algorithms.

Also fix Range.deleteContents loop that read _end_offset on each
iteration (now decremented by the range update), and Range.insertNode
that double-incremented _end_offset for non-collapsed ranges.

Route textContent, nodeValue, and data setters through replaceData
so range updates fire consistently.

Fixes 9 WPT test files (all now 100%): Range-mutations-insertData,
deleteData, replaceData, splitText, appendChild, insertBefore,
removeChild, appendData, dataChange (~1330 new passing subtests).
2026-03-10 19:59:04 +01:00
Halil Durak
a22040efa9 update body.onload test 2026-03-10 19:16:35 +03:00
Halil Durak
ba3da32ce6 spread new stringToPersistedFunction 2026-03-10 19:16:20 +03:00
Halil Durak
9d2ba52160 adapt stringToPersistedFunction to compileFunction
This is just a thin wrapper around it now.
2026-03-10 19:15:53 +03:00
Halil Durak
e610506df4 Local: initial compileFunction 2026-03-10 18:14:35 +03:00
Pierre Tachoire
dd91d28bfa Merge pull request #1761 from lightpanda-io/wp/mrdimidium/c-tsan
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Enable tsan for c libs
2026-03-10 15:33:33 +01:00
Pierre Tachoire
1ebf7460fe Merge pull request #1768 from lightpanda-io/inspector_cleanup
Call `resetContextGroup` on page removal
2026-03-10 15:32:47 +01:00
Pierre Tachoire
8c930e5c33 Merge pull request #1769 from lightpanda-io/form_action
Add Form.action getter/setter
2026-03-10 15:31:34 +01:00
egrs
4fb2f7474c remove incorrect entries from normalization maps
- Remove scale, contain-intrinsic-size, animation-range, text-box-edge
  from isTwoValueShorthand: these have asymmetric or 3-value semantics
  that make "X X" → "X" collapse incorrect.
- Remove line-height from isLengthProperty: bare 0 is the unitless
  number multiplier, not a length (Chrome serializes as "0" not "0px").
- Fix test: background-size "cover cover" is invalid CSS, use "auto auto".
2026-03-10 14:08:28 +01:00
Karl Seguin
5301f79989 Add Form.action getter/setter 2026-03-10 20:58:31 +08:00
egrs
6a7f7fdf15 extend CSS value normalization to cover more properties
Add missing properties to isLengthProperty (0→0px) and
isTwoValueShorthand (duplicate value collapse) maps based
on WPT test failures in css/css-sizing, css/css-align,
css/css-scroll-snap, css/css-logical, and others.

New length properties: scroll-margin/padding-*, column-width,
column-rule-width, grid-column/row-gap, outline, shape-margin,
offset-distance, translate, animation-range-*, block-step-size,
text-decoration-inset, and *-rule-*-inset (CSS Gaps).

New two-value shorthands: scroll-padding-block/inline,
scroll-snap-align, background-size, border-image-repeat,
mask-repeat/size, contain-intrinsic-size, scale, text-box-edge,
animation-range, grid-gap.
2026-03-10 13:53:27 +01:00
Karl Seguin
11fb5f990e Call resetContextGroup on page removal
Calling it here ensures that the inspector gets reset on internal page
navigation. We were seeing intermittent segfaults on a problematic WPT tests
(/encoding/legacy-mb-japanese/euc-jp/) which I believe this solves.

(The tests are still broken. Because we don't support form targets, they cause
the root page to reload in a tight cycle, causing a lot of context creation /
destruction, which I thin was the issue. This commit doesn't fix the broken test
but it hopefully fixes the crash).

Also, clear out the Inspector's default_context when the default context is
destroyed. (This was the first thing I did to try to fix the crash, it didn't
work, but I believe it's correct).
2026-03-10 20:50:58 +08:00
Adrià Arrufat
d1ee0442ea Merge branch 'main' into semantic-tree 2026-03-10 21:48:49 +09:00
Adrià Arrufat
62f31ea24a Merge pull request #1765 from egrs/lp-get-structured-data
add LP.getStructuredData CDP command
2026-03-10 21:48:18 +09:00
egrs
f4ca5313e6 use std.mem.startsWith, group duplicate property keys into arrays
Address review feedback:
- replace custom startsWith helper with std.mem.startsWith
- writeProperties now groups repeated keys (e.g. multiple og:image)
  into JSON arrays; single-occurrence keys remain strings
- add test for duplicate key serialization
2026-03-10 13:18:25 +01:00
Adrià Arrufat
064e7b404b SemanticTree: unify interactivity detection logic 2026-03-10 19:02:55 +09:00
Karl Seguin
dfd90bd564 Merge pull request #1754 from lightpanda-io/css_value_normalization
Apply some normalization to CSS values
2026-03-10 17:36:27 +08:00
Pierre Tachoire
55508eb418 Merge pull request #1763 from lightpanda-io/has_direct_listener
Add a hasDirectListeners to EventManager
2026-03-10 10:28:39 +01:00
Pierre Tachoire
2a4fa4ed6f Merge pull request #1762 from lightpanda-io/xml_get_elements_by_tag_name
Node matching using tag name string comparison on non-HTML nodes
2026-03-10 10:27:47 +01:00
Pierre Tachoire
cf7c9f6372 Merge pull request #1760 from lightpanda-io/response_blob
Add new Response and Request methods
2026-03-10 10:26:16 +01:00
Pierre Tachoire
ec68c3207d Merge pull request #1764 from lightpanda-io/js_val_args
Better support for variadic js.Value parameter (e.g. console.log)
2026-03-10 10:16:27 +01:00
Pierre Tachoire
ecf140f3d6 Merge pull request #1766 from lightpanda-io/screenshot-size
cdp: reszie the screenshot to 1920x1080
2026-03-10 10:15:46 +01:00
Pierre Tachoire
13f73b7b87 Merge pull request #1750 from lightpanda-io/url_set_username_password
Add setters to URL.username and URL.password
2026-03-10 10:15:10 +01:00
Pierre Tachoire
12c5bcd24f cdp: reszie the screenshot to 1920x1080
To be consistent w/ layout size returned
2026-03-10 10:09:53 +01:00
Adrià Arrufat
56f47ee574 Merge branch 'main' into semantic-tree 2026-03-10 17:26:34 +09:00
egrs
74f0436ac7 merge main, resolve conflicts with getInteractiveElements 2026-03-10 09:25:12 +01:00
egrs
22d31b1527 add LP.getStructuredData CDP command 2026-03-10 09:19:51 +01:00
Karl Seguin
9f3bca771a Merge pull request #1755 from lightpanda-io/cdp-page-layout-metrics
cdp: add a dummy Page.getLayoutMetrics
2026-03-10 16:16:17 +08:00
Adrià Arrufat
4e16d90a81 Merge pull request #1757 from egrs/lp-get-interactive-elements
add LP.getInteractiveElements CDP command
2026-03-10 17:15:18 +09:00
Pierre Tachoire
d669d5c153 cdp: add a dummy Page.getLayoutMetrics 2026-03-10 08:54:48 +01:00
Karl Seguin
343d985e96 Better support for variadic js.Value parameter (e.g. console.log)
The bridge will prefer to map a Zig array to a JS Array, but in the case of
a []js.Value, it should be willing to map anything into it.
2026-03-10 15:40:18 +08:00
egrs
dc3958356d address review feedback
- TreeWalker.Full instead of FullExcludeSelf so querying a specific
  nodeId evaluates the root element itself
- resolve href to absolute URL via URL.resolve
- isDisabled checks ancestor <fieldset disabled> with legend exemption
- parameter order: allocator before *Page per convention
2026-03-10 08:13:01 +01:00
Karl Seguin
c4e85c3277 Add a hasDirectListeners to EventManager
Allows checking if a direct listener exists, if it doesn't, event creation can
be skipped.

I looked at a couple sites, the benefits of this is small.
Most sites don't seem to trigger that many direct dispatches and when they do,
they seem to have a listener 50-75% of the time.
2026-03-10 14:57:40 +08:00
Karl Seguin
89e46376dc Merge pull request #1752 from lightpanda-io/build-zig-fmt-check
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
build: add code formatting check
2026-03-10 14:04:28 +08:00
Karl Seguin
8eeb34dba8 Node matching using tag name string comparison on non-HTML nodes
NodeLive (used by, e.g. getElementsByTagName) needs to revert to the non-
optimized string-comparison for non-HTML tags.

This should help fix https://github.com/lightpanda-io/browser/issues/1214
2026-03-10 13:42:54 +08:00
Nikolay Govorov
7171305972 Enable tsan for c libs 2026-03-10 03:16:50 +00:00
Nikolay Govorov
2b0c223425 Some code-review fixes 2026-03-10 03:00:55 +00:00
Nikolay Govorov
8f960ab0f7 Uses posix pipe for shutdown network runtime 2026-03-10 03:00:53 +00:00
Nikolay Govorov
60350efa10 Only one listener in network.Runtime 2026-03-10 03:00:52 +00:00
Nikolay Govorov
687f577562 Move accept loop to common runtime 2026-03-10 03:00:50 +00:00
Nikolay Govorov
8e59ce9e9f Prepare global NetworkRuntime module 2026-03-10 03:00:47 +00:00
Karl Seguin
33d75354a2 Add new Response and Request methods
-Response.blob
-Response.clone
-Request.blob
-Request.text
-Request.json
-Request.arrayBuffer
-Request.bytes
-Request.clone
2026-03-10 09:05:06 +08:00
Adrià Arrufat
a318c6263d SemanticTree: improve visibility, AX roles and xpath generation
- Use `checkVisibility` for more accurate element visibility detection.
- Add support for color, date, file, and month AX roles.
- Optimize XPath generation by tracking sibling indices during the walk.
- Refine interactivity detection for form elements.
2026-03-10 09:23:06 +09:00
Karl Seguin
0e4a65efb7 Merge pull request #1758 from lightpanda-io/http-auth-challenge
http: handle auth challenge for non-proxy auth
2026-03-10 06:39:14 +08:00
Karl Seguin
b88134cf04 Merge pull request #1756 from lightpanda-io/cdp-screenshot
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
cdp: add dummy page.captureScreenshot
2026-03-10 06:37:33 +08:00
Karl Seguin
2aaa212dbc Merge pull request #1753 from lightpanda-io/document_applets
document.applets should always return an empty collection
2026-03-10 06:35:23 +08:00
Karl Seguin
1e37990938 Merge pull request #1741 from lightpanda-io/DOMParser_invalid_xml
Throw exception, as expected, on empty input to DOMParser.parseFromSt…
2026-03-10 06:32:48 +08:00
egrs
a417c73bf7 add LP.getInteractiveElements CDP command
Returns a structured list of all interactive elements on a page:
buttons, links, inputs, ARIA widgets, contenteditable regions, and
elements with event listeners. Includes accessible names, roles,
listener types, and key attributes.

Event listener introspection (both addEventListener and inline
handlers) is unique to LP — no other browser exposes this to
automation code.
2026-03-09 19:46:12 +01:00
Pierre Tachoire
37c34351ee http: handle auth challenge for non-proxy auth 2026-03-09 19:23:36 +01:00
Pierre Tachoire
8672232ee2 cdp: add dummy page.captureScreenshot 2026-03-09 17:38:57 +01:00
Adrià Arrufat
83ba974f94 SemanticTree: optimize tree walking and xpath generation
- Use a reusable buffer for XPaths to reduce allocations.
- Improve `display: none` detection with proper CSS parsing.
- Pass parent name to children to avoid redundant AXNode lookups.
- Use `getElementById` for faster datalist lookups.
2026-03-09 22:53:39 +09:00
Adrià Arrufat
85ebbe8759 SemanticTree: improve accessibility tree and name calculation
- Add more structural roles (banner, navigation, main, list, etc.).
- Implement fallback for accessible names (SVG titles, image alt text).
- Skip children for leaf-like semantic nodes to reduce redundancy.
- Disable pruning in the default semantic tree view.
2026-03-09 21:04:47 +09:00
Adrià Arrufat
61cba3f6eb Merge branch 'main' into semantic-tree 2026-03-09 20:13:47 +09:00
Karl Seguin
3ad10ff8d0 Add support for normalization anchor-size css value
vibed this. Seems esoteric, but it helps over 1000 WPT cases pass in
/css/css-anchor-position/anchor-size-parse-valid.html
2026-03-09 18:25:01 +08:00
Karl Seguin
183643547b document.applets should always return an empty collection
Add a new .empty mode to HTMLCollection.

Fixes WPT /shadow-dom/leaktests/html-collection.html
2026-03-09 18:06:22 +08:00
Adrià Arrufat
5568340b9a build: add code formatting check 2026-03-09 18:48:38 +09:00
Karl Seguin
1399bd3065 Apply some normalization to CSS values
"10px 10px" should present as "10px".  A length of "0" should present as "0px"

Fixes a handful of WPT tests.
2026-03-09 17:47:59 +08:00
Karl Seguin
9172e16e80 Merge pull request #1751 from lightpanda-io/zig-fmt-face
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
zig fmt
2026-03-09 17:34:17 +08:00
Adrià Arrufat
3e5f602396 zig fmt 2026-03-09 18:25:09 +09:00
Adrià Arrufat
3c97332fd8 feat(dump): add semantic_tree and semantic_tree_text formats
Adds support for dumping the semantic tree in JSON or text format
via the --dump option. Updates the Config enum and usage help.
2026-03-09 18:23:52 +09:00
Karl Seguin
379a3f27b8 Merge pull request #1744 from egrs/add-range-client-rect
add Range.getBoundingClientRect and getClientRects
2026-03-09 17:17:17 +08:00
Karl Seguin
ecec932a47 Add setters to URL.username and URL.password
Also, preserve port when setting host.
2026-03-09 17:13:12 +08:00
egrs
e239f69f69 delegate Range rect methods to container element
Instead of always returning zeros, delegate getBoundingClientRect and
getClientRects to the common ancestor container element. Return zeros
only when the range is collapsed or has no element ancestor.
2026-03-09 10:09:11 +01:00
Adrià Arrufat
c77cb317c4 Merge branch 'main' into semantic-tree 2026-03-09 18:08:10 +09:00
Karl Seguin
034b089433 Merge pull request #1749 from lightpanda-io/empty_is_and_where_pseudoselector
Empty :is() and :where() pseudoselectors are valid (and return nothing)
2026-03-09 16:55:43 +08:00
Karl Seguin
c0db96482c Merge pull request #1748 from lightpanda-io/font_face_optimization
Optimize FontFace
2026-03-09 16:55:28 +08:00
Karl Seguin
ffa8fa0a6f Merge pull request #1745 from lightpanda-io/renavigate_memory_leak
Fix leak introduced in inner navigation refactoring
2026-03-09 16:55:12 +08:00
Karl Seguin
7e1d459a2d Merge pull request #1746 from egrs/fix-module-relative-imports
fix module re-import when previous compilation failed
2026-03-09 16:44:43 +08:00
Karl Seguin
71c4fce87f Empty :is() and :where() pseudoselectors are valid (and return nothing) 2026-03-09 16:39:44 +08:00
Karl Seguin
e91da78ebb Optimize FontFace
Follow up to https://github.com/lightpanda-io/browser/pull/1743

Allow eager cleanup with finalizer. User properties for (what are currently)
constants.
2026-03-09 16:08:17 +08:00
egrs
8adad6fa61 fix module re-import when previous compilation failed
When a module's compilation fails after its imported_modules entry has
been consumed by waitForImport, sibling modules that import the same
dependency would get UnknownModule errors. Fix by re-preloading modules
whose cache entry exists but has no compiled module.
2026-03-09 08:58:07 +01:00
Karl Seguin
b47004bb7c Merge pull request #1743 from egrs/add-fontface-constructor
add FontFace constructor and FontFaceSet.add()
2026-03-09 15:57:59 +08:00
Karl Seguin
08a7fb4de0 Fix leak introduced in inner navigation refactoring
A inner-navigate event can override an existing pending queued navigation. When
it does, the previously queued navigation has to be cleaned up. We were doing
this, but it must have been stripped out when navigation was refactored to work
with frames.
2026-03-09 15:51:26 +08:00
Karl Seguin
c17a9b11cc Merge pull request #1740 from egrs/fix-dynamic-inline-scripts
execute dynamically inserted inline script elements
2026-03-09 15:43:28 +08:00
egrs
245a92a644 use node.firstChild() directly per review feedback
node is already available in scope — no need to traverse back through
script.asConstElement().asConstNode().
2026-03-09 08:31:54 +01:00
Pierre Tachoire
6b313946fe Merge pull request #1739 from lightpanda-io/wpt-procs
wpt: use a pool of browser to run tests
2026-03-09 08:29:16 +01:00
egrs
4586fb1d13 add Range.getBoundingClientRect and getClientRects
headless stubs returning zero-valued DOMRect / empty list per CSSOM
View spec. fixes "getBoundingClientRect is not a function" errors on
sites where layout code calls this on Range objects (e.g. airbnb).
2026-03-09 08:23:19 +01:00
egrs
aa051434cb add FontFace constructor and FontFaceSet.add()
headless stub for the FontFace API — constructor stores family/source,
status is always "loaded", load() resolves immediately. enables sites
that use new FontFace() for programmatic font loading (e.g. boursorama).
2026-03-09 08:14:41 +01:00
Adrià Arrufat
c3a53752e7 CDP: simplify AXNode name extraction logic 2026-03-09 15:34:59 +09:00
Karl Seguin
f3e1204fa1 Throw exception, as expected, on empty input to DOMParser.parseFromString
https://github.com/lightpanda-io/browser/issues/1738
2026-03-09 13:46:36 +08:00
Adrià Arrufat
0a5eb93565 SemanticTree: Implement compound component metadata 2026-03-09 13:42:53 +09:00
Adrià Arrufat
b8a3135835 SemanticTree: add pruning support and move logic to walk 2026-03-09 13:02:03 +09:00
Adrià Arrufat
330dfccb89 webapi/Element: add missing block tags and reorganize checks 2026-03-09 11:23:52 +09:00
Adrià Arrufat
d80e926015 SemanticTree: unify tree traversal using visitor pattern 2026-03-09 11:09:27 +09:00
Adrià Arrufat
2a2b067633 mcp: fix wrong merge 2026-03-09 10:37:21 +09:00
Adrià Arrufat
be73c14395 SemanticTree: rename dump to dumpJson and update log tags 2026-03-09 10:29:32 +09:00
Adrià Arrufat
9cd5afe5b6 Merge branch 'main' into semantic-tree 2026-03-09 10:18:54 +09:00
Pierre Tachoire
1cb5d26344 wpt: use a pool of browser to run tests 2026-03-08 20:55:15 +01:00
egrs
ec9a2d8155 execute dynamically inserted inline script elements
Scripts created via createElement('script') with inline content (textContent,
text, or innerHTML) and inserted into the DOM now execute per the HTML spec.
Previously all dynamically inserted scripts without a src attribute were
skipped, breaking most JS framework hydration patterns.
2026-03-08 16:17:52 +01:00
Adrià Arrufat
4ba40f2295 CDP: implement intelligent pruning for textified semantic tree output 2026-03-08 22:48:22 +09:00
Adrià Arrufat
b674c2e448 CDP/MCP: add highly compressed text format for semantic tree 2026-03-08 22:42:00 +09:00
Karl Seguin
0227afffc8 Merge pull request #1735 from egrs/fix-missing-dom-exception-flags
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add missing dom_exception flags to bridge declarations
2026-03-08 16:36:56 +08:00
Adrià Arrufat
b8139a6e83 CDP/MCP: improve Stagehand compatibility for semantic tree 2026-03-08 15:48:44 +09:00
Adrià Arrufat
bde5fc9264 Merge branch 'main' into semantic-tree 2026-03-08 08:18:08 +09:00
Karl Seguin
6a421a1d96 Merge pull request #1734 from lightpanda-io/mcp_safer_navigate
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix page re-navigate
2026-03-08 07:17:39 +08:00
egrs
4f55a0f1d0 add missing dom_exception flags to bridge declarations
atob, Performance.measure, and Navigation methods (back, forward,
navigate, traverseTo, updateCurrentEntry) return DOMException errors
but were missing the dom_exception flag, causing them to throw generic
Error objects instead of proper DOMException instances in JavaScript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:34:28 +01:00
Karl Seguin
3de55899fa fix test 2026-03-07 11:04:22 +08:00
Karl Seguin
ae4ad713ec Fix page re-navigate
It isn't safe/correct to call `navigate` on the same page multiple times. A page
is meant to have 1 navigate call. The caller should either remove the page
and create a new one, or call Session.replacePage.

This commit removes the *Page from the MCP Server and instead interacts with
the session to create or remove+create the page as needed, and lets the Session
own the *Page.

It also adds a bit of defensiveness around parameter parsing, e.g. calling
{"method": "tools/call"} (without an id) now errors instead of crashing.
2026-03-07 10:19:37 +08:00
Karl Seguin
21313adf9c Merge pull request #1728 from lightpanda-io/about_blank
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Optimize about:blank loading in general and for frames specifically
2026-03-06 23:38:11 +08:00
Karl Seguin
9c1293ca45 Merge pull request #1729 from lightpanda-io/target_navigation
Add target-aware(ish) navigation
2026-03-06 23:38:01 +08:00
Karl Seguin
1cb1e6b680 Merge pull request #1720 from lightpanda-io/frame_scheduled_navigation
Improve frame sub-navigation
2026-03-06 23:37:49 +08:00
Karl Seguin
ed6ddeaa4c Merge pull request #1732 from lightpanda-io/custom_element_clone_take_2
Fix cloning custom element with constructor which attaches the element
2026-03-06 23:37:29 +08:00
Karl Seguin
de08a89e6b Merge pull request #1726 from lightpanda-io/fix_keyboard_event_leak
Release KeyboardEvent if it isn't used
2026-03-06 23:37:15 +08:00
Karl Seguin
dd42ef1920 Merge pull request #1727 from lightpanda-io/halt_tests_on_arena_leak
Halt tests (@panic) on ArenaLeak or double-free
2026-03-06 23:35:33 +08:00
Pierre Tachoire
dd192be689 Merge pull request #1730 from lightpanda-io/wpt-concurrency
wpt: increase concurrency
2026-03-06 16:26:30 +01:00
Pierre Tachoire
52250ed10e wpt: increase concurrency 2026-03-06 15:59:28 +01:00
Karl Seguin
4a26cd8d68 Halt tests (@panic) on ArenaLeak or double-free
These are too hard to see during a full test run.
2026-03-06 20:41:57 +08:00
Karl Seguin
2ca972c3c8 Merge pull request #1731 from lightpanda-io/revert-rs-arena
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Revert pool arena usage w/ ReadableStream
2026-03-06 19:28:44 +08:00
Karl Seguin
74c0d55a6c Fix cloning custom element with constructor which attaches the element
This is a follow up to ca0f77bdee that applies
the same fix to all places where cloneNode is used and then the cloned element
is inserted. A helper was added more of a means of documentation than to DRY
up the code.
2026-03-06 17:38:16 +08:00
Pierre Tachoire
3271e1464e Revert pool arena usage w/ ReadableStream
Revert "update ref counting for new ReadableStream usages"
This reverts commit c64500dd85.

Revert "add reference counting for ReadableStream"
This reverts commit 812ad3f49e.

Revert "use a pool arena with ReadableStream"
This reverts commit 8e8a1a7541.
2026-03-06 10:21:36 +01:00
Karl Seguin
cabd62b48f Optimize about:blank loading in general and for frames specifically
Instead of going through the parser, just create / append the 3 elements.

iframe without a src automatically loads about:blank. This is important, because
the following is valid:

```js
const iframe = document.createElement('iframe');
document.documentElement.appendChild(iframe);

// documentElement should exist and should be the HTML of the blank page.
iframe.contentDocument.documentElement.appendChild(...);
```

Builds on top of https://github.com/lightpanda-io/browser/pull/1720
2026-03-06 17:15:43 +08:00
Karl Seguin
58c2355c8b Merge pull request #1725 from egrs/fix-mcp-test-hang-aarch64
initialize all App fields after allocator.create
2026-03-06 17:11:40 +08:00
Karl Seguin
bfe2065b9f Add target-aware(ish) navigation
All inner navigations have an originator and a target. Consider this:

```js
aframe.contentDocument.querySelector('#link').click();
```

The originator is the context in which this JavaScript is called, the target is
`aframe. Importantly, relative URLs are resolved based on the originator. This
commit adds that.

This is only a first step, there are other aspect to this relationship that
isn't addressed yet, like differences in behavior if the originator and target
are on different origins, and specific target targetting via the things like
the "target" attribute. What this commit does though is address the normal /
common case.

It builds on top of https://github.com/lightpanda-io/browser/pull/1720
2026-03-06 16:57:28 +08:00
egrs
9332b1355e initialize all App fields after allocator.create
Same pattern as 3dea554e (mcp/Server.zig): allocator.create returns
undefined memory, and struct field defaults (shutdown: bool = false)
are not applied when fields are set individually. Use self.* = .{...}
to ensure all fields including defaults are initialized.
2026-03-06 09:37:55 +01:00
Adrià Arrufat
45705a3e29 webapi: move tag category logic to Tag enum 2026-03-06 16:34:23 +09:00
Adrià Arrufat
e0f0b9f210 SemanticTree: use AXRole enum for interactive role check 2026-03-06 16:26:08 +09:00
Adrià Arrufat
f2832447d4 SemanticTree: optimize tag and role filtering
* Refactored tag ignoring logic to use the el.getTag() enum switch
  instead of string comparisons, improving performance and safety.
* Replaced string comparisons for interactive roles with
  std.StaticStringMap.
* Renamed internal dumpNode method to dump for brevity.
2026-03-06 16:12:57 +09:00
Adrià Arrufat
471ba5baf6 String: refactor isAllWhitespace into String 2026-03-06 15:52:53 +09:00
Adrià Arrufat
248851701f Refactor: move SemanticTree to core and expose via MCP tools 2026-03-06 15:44:03 +09:00
Adrià Arrufat
0f46277b1f CDP: implement LP.getSemanticTree for native semantic DOM extraction 2026-03-06 15:29:32 +09:00
Karl Seguin
679e703754 Release KeyboardEvent if it isn't used 2026-03-06 09:12:58 +08:00
Karl Seguin
7322f90af4 Merge pull request #1722 from lightpanda-io/fetch_wait_for_background
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Run the message loop more!
2026-03-06 08:22:41 +08:00
Karl Seguin
e869df98c9 Merge pull request #1723 from lightpanda-io/cleanup-treewalker-helpers
TreeWalker: remove unused methods
2026-03-06 08:19:03 +08:00
Pierre Tachoire
e499d36126 Merge pull request #1724 from lightpanda-io/dockerfile-remove-submodules
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Dockerfile: remove git submodule initialization
2026-03-05 15:19:42 +01:00
Adrià Arrufat
cac66d7fad Dockerfile: remove git submodule initialization 2026-03-05 22:18:38 +09:00
Adrià Arrufat
320aaf0e33 TreeWalker: remove unused methods
They were introduced in:

- https://github.com/lightpanda-io/browser/pull/1718
2026-03-05 21:51:22 +09:00
Karl Seguin
178a175e99 Merge pull request #1698 from lightpanda-io/readablestream-pool-arena
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
use a pool arena with ReadableStream
2026-03-05 18:57:06 +08:00
Karl Seguin
5fdf1cb2d1 Run the message loop more!
In https://github.com/lightpanda-io/browser/pull/1651 we started to run the
message loop a lot more. One specific case we added for `fetch` was when there
were no scheduled tasks or HTTP, but background tasks, we'd wait for them to
complete.

One case we missed though is if WE do have a schedule tasks, but it's too far
into the future. In that case, we would just exit. This now adds the same logic
for checking and waiting for any background tasks in that case.
2026-03-05 18:51:34 +08:00
Pierre Tachoire
c64500dd85 update ref counting for new ReadableStream usages 2026-03-05 11:47:48 +01:00
Pierre Tachoire
812ad3f49e add reference counting for ReadableStream 2026-03-05 11:47:48 +01:00
Pierre Tachoire
8e8a1a7541 use a pool arena with ReadableStream 2026-03-05 11:47:47 +01:00
Karl Seguin
4863b3df6e Merge pull request #1721 from lightpanda-io/fix_mcp_unintialized_memory
Ensure that mcp.Server is correctly initialized
2026-03-05 17:11:57 +08:00
Karl Seguin
768c3a533b Simplify navigation logic.
Must of the complexity in the previous commit had to do with the fact that
about:blank is processed synchronously, meaning that we could process a
scheduled navigation -> page.navigate -> scheduled navigation:

```
let iframe = document.createElement('iframe');
iframe.addEventListner('load', () => {
  iframe.src = "about:blank";
});
```

This is an infinite loop which is going to be a problem no mater what, but there
are different degrees of problems this can cause, e.g. looping forever vs use-
after-free or other undefined behavior.

The new approach does 2 passes through scheduled navigations, first processing
"asynchronous" navigation (anything not "about:blank"), then processing
synchronous navigation ("about:blank"). The main advantage is that if the
synchronous navigation causes more synchronous navigation, it won't be
processed until the next tick. PLUS, we can detect about:blank that loads
about:blank and stop it (which might not be to spec, but seems right to do
nonetheless). This 2-pass approach removes the need for a couple of checks and
makes everything else simpler.
2026-03-05 17:06:23 +08:00
Karl Seguin
3dea554e9e Ensure that mcp.Server is correctly initialized
It relies on default field values, e.g. for mutex: std.Thread.Mutex = .{}, but
doesn't initialize the structure, just the pointer on the heap resulting in a
crash.
2026-03-05 16:32:25 +08:00
Karl Seguin
16d4f6e4e1 Merge pull request #1718 from lightpanda-io/enhance-treewalker
Enhance TreeWalker
2026-03-05 15:28:04 +08:00
Karl Seguin
9c7ecf221e Improve frame sub-navigation
This makes frame sub-navigation "work" for all page navigations (click, form
submit, location.top...) as well as setting the iframe.src.

Fixes at least 2 WPT crashes.

BUT, the implementation still isn't 100% correct, with two known issues:

1. Navigation currently happens in the context where it's called, not the
   context of the frame. So if Page1 accesses Frame1 and causes it to navigate,
   e.g. f1.contentDocument.querySelector('#link').click(), it's Page1 that will
   be navigated, since the JS is being executed in the Page1 context.
   This should be relatively easy to fix.

2. There are particularly complicated cases in WPT where a frame is navigated
   inside of its own load, creating an endless loop. There's some partial
   support for this as-is, but it doesn't work correctly and it currently is
   defensive and likely will not continue to navigate. This is particularly true
   when sub-navigation is done to about:blank within the frame's on load event.
   (Which is probably not a real concern, but an issue for some WPT tests)

Although it shares a lot with the original navigation code, there are many more
edge cases here, possibly due to being developed along side WPT tests. The
source of most of the complexity is the synchronous handling of "about:blank"
in page.navigate, which can result in a scheduled navigation synchronously
causing more scheduled navigation. (Specifically because
`self.documentIsComplete();` is called from page.navigate in that case). It
might be worth seeing if something can be done about that, to simplify this new
code (removing the double queue, removing the flag, simplifying pre-existing
schedule checks ,...)
2026-03-05 15:09:39 +08:00
Adrià Arrufat
26db481d46 markdown: refactor content discovery to use TreeWalker 2026-03-05 14:36:15 +09:00
Adrià Arrufat
3256a57230 TreeWalker: add sibling navigation and skipChildren 2026-03-05 14:29:42 +09:00
Karl Seguin
cbc30587ff Merge pull request #1717 from lightpanda-io/improve-markdown-links
Improve markdown links
2026-03-05 13:09:16 +08:00
Adrià Arrufat
a27de38c03 markdown: encode resolved URLs in links and images 2026-03-05 13:57:42 +09:00
Adrià Arrufat
e2f1609116 markdown: use aria-label or title for empty links 2026-03-05 11:27:51 +09:00
Adrià Arrufat
ea66a91a95 markdown: resolve absolute URLs and skip empty links 2026-03-05 10:48:18 +09:00
Pierre Tachoire
0d87c352b2 Merge pull request #1716 from lightpanda-io/wpt-again
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: for wpt run with --concurrency=3
2026-03-04 18:04:07 +01:00
Pierre Tachoire
918f6ce0e6 ci: for wpt run with --concurrency=3 2026-03-04 15:54:48 +01:00
Karl Seguin
6c5efe6ce0 Merge pull request #1715 from lightpanda-io/cdp-frame-navigate
cdp: don't dispatch executionContextsCleared on frame navigation
2026-03-04 22:02:30 +08:00
Karl Seguin
f0be6675e7 Merge pull request #1714 from lightpanda-io/fix-req-id
cdp: fix req id resolver, they are REQ- not RID-
2026-03-04 21:59:04 +08:00
Pierre Tachoire
6a8174a15c cdp: don't dispatch executionContextsCleared on frame navigation 2026-03-04 14:45:21 +01:00
Pierre Tachoire
40c3f1b618 cdp: fix req id resolver, they are REQ- not RID- 2026-03-04 13:00:16 +01:00
Pierre Tachoire
6dd2dac049 Merge pull request #1704 from lightpanda-io/non-ascii-css-key
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
css: fix crash in consumeName() on UTF-8 multibyte sequences
2026-03-04 12:35:14 +01:00
Karl Seguin
b39bbb557f Merge pull request #1713 from lightpanda-io/dynamic_module_instantiation
Force dynamic module instantiation if not already instantiated
2026-03-04 16:27:06 +08:00
Karl Seguin
f7682cba67 Force dynamic module instantiation if not already instantiated
I couldn't come up with a reproducible case where this was needed, but we're
seeing some crash reports indicate that this is happening.
2026-03-04 16:12:11 +08:00
Pierre Tachoire
f94c07160a Merge pull request #1712 from lightpanda-io/css-selector-quote
Handle commas inside quoted attributes
2026-03-04 09:00:01 +01:00
Karl Seguin
bbe6692580 Merge pull request #1711 from lightpanda-io/iframe_about_blank
iframe handling for src = "about:blank"
2026-03-04 15:56:26 +08:00
Karl Seguin
9266a1c4d9 Merge pull request #1709 from lightpanda-io/expand_event_dispatch_handle_scope
Use a single HandleScope for event dispatch
2026-03-04 15:56:13 +08:00
Pierre Tachoire
220d80f05f Handle commas inside quoted attributes
In CSS selector, commas inside quoted attribute are not selector separators, but part of
the attribute value.
2026-03-04 08:49:33 +01:00
Karl Seguin
9144c909dd Merge pull request #1710 from lightpanda-io/custom_element_clone
Support for clone custom elements that attach them self in their cons…
2026-03-04 15:47:39 +08:00
Karl Seguin
7981fcec84 iframe handling for src = "about:blank"
Don't try to resolve an iframe's source if it's about:blank

Extend the page's handling of about:blank to render an empty document
2026-03-04 15:43:07 +08:00
Pierre Tachoire
71264c56fc Merge pull request #1696 from lightpanda-io/textencoder-stream
Add TextEncoderStream and TextDecoderStream implementation
2026-03-04 07:58:56 +01:00
Karl Seguin
ca0f77bdee Support for clone custom elements that attach them self in their constructor
When we createElement, we assume the element is detached. This is usually true
except for Custom Elements where the constructor can do anything, including
connecting the element. This broken assumption results in cloneNode crashing.
2026-03-04 14:54:34 +08:00
Karl Seguin
fc8b1b8549 Use a single HandleScope for event dispatch
https://github.com/lightpanda-io/browser/pull/1690 narrowed the lifetime of
HandleScopes to once per listener. I think that was just an accident of
refactoring, and not some intentional choice.

The narrower HandleScope lifetime makes it so that when we do run microtask
queue at the end of event dispatching, some locals in the queue may not longer
be valid.

HS1
  HS2
    queueMicrotask(func)
  runMicrotask

In the above flow, `func` is only valid while HS2 is alive, so when we run
the microtask queue in HS1, it is no longer valid.
2026-03-04 11:43:09 +08:00
Karl Seguin
bc8c44f62f Merge pull request #1707 from lightpanda-io/nikneym/details
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add `HTMLDetailsElement`
2026-03-04 07:44:11 +08:00
Karl Seguin
01fab5c92a Merge pull request #1706 from lightpanda-io/cdp-attach-to-browser
cdp: fix send CDP raw command with Playwright
2026-03-04 07:40:05 +08:00
Karl Seguin
1c07d786a0 Merge pull request #1705 from lightpanda-io/nikneym/track
` Track`: implement kind and constants
2026-03-04 07:34:12 +08:00
Karl Seguin
6f0cd87d1c Merge pull request #1703 from lightpanda-io/client_and_script_manager
Fix a few issues in Client
2026-03-04 07:32:14 +08:00
Karl Seguin
e44308cba2 Merge pull request #1695 from lightpanda-io/iframe_src_nav
Iframe src nav
2026-03-04 07:27:23 +08:00
Karl Seguin
50245c5157 Merge pull request #1667 from lightpanda-io/terminate_isolate
On Client.stop, terminate the isolate
2026-03-04 07:27:10 +08:00
Pierre Tachoire
9ca5188e12 cdp: set consistent target's default
with about:blank for url and empty title.
2026-03-03 17:24:08 +01:00
Pierre Tachoire
e25c33eaa6 Merge pull request #1673 from arrufat/mcp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add Model Context Protocol (MCP) server support
2026-03-03 15:18:34 +01:00
Pierre Tachoire
56cc881ac0 Fcdp: fix attachtToTarget and attachToBrowserTarget resp 2026-03-03 15:01:53 +01:00
Adrià Arrufat
7bddc0a89c mcp: remove search and over tools 2026-03-03 22:50:06 +09:00
Halil Durak
50896bdc9d HTMLDetailsElement: add tests 2026-03-03 15:12:12 +03:00
Halil Durak
8dd4567828 HTMLDetailsElement: implement HTMLDetailsElement 2026-03-03 15:12:02 +03:00
Pierre Tachoire
06ef6d3e6a cdp: attachToTarget must add the session id 2026-03-03 12:58:00 +01:00
Pierre Tachoire
14b58e8062 add target.attachToBrowserTarget 2026-03-03 12:58:00 +01:00
Pierre Tachoire
eee232c12c cdp: allow multiple calls to attachToTarget
Playwright, when creating a new CDPSession, sends an
attachToBrowserTarget followed by another attachToTarget to re-attach
itself to the existing target.

see playwright/axtree.js from demo/ repository.
2026-03-03 12:58:00 +01:00
Halil Durak
febe321aef Track: add tests 2026-03-03 14:41:05 +03:00
Halil Durak
28777ac717 Track: implement kind and constants 2026-03-03 14:40:53 +03:00
Pierre Tachoire
13b008b56c css: fix crash in consumeName() on UTF-8 multibyte sequences
advance() asserts that each byte it steps over is either an ASCII byte
or a UTF-8 sequence leader, never a continuation byte (0x80–0xBF).
consumeName() was calling advance(1) for all non-ASCII bytes
('\x80'...'\xFF'), processing multi-byte sequences one byte at a time.
For a two-byte sequence like é (0xC3 0xA9), the second iteration landed
on the continuation byte 0xA9 and triggered the assertion, crashing the
browser in Debug mode.

Fix: replace advance(1) with consumeChar() for all non-ASCII bytes.
consumeChar() reads the lead byte, derives the sequence length via
utf8ByteSequenceLength, and advances the full code point in one step,
so the position never rests on a continuation byte.

Observed on saintcyrlecole.caliceo.com, whose root element carries an
inline style with custom property names containing French accented
characters (--color-store-bulles-été-fg, etc.). The crash aborted JS
execution before the Angular app could render any dynamic content.
2026-03-03 11:13:30 +01:00
Karl Seguin
403f42bf38 Merge pull request #1702 from arrufat/cdp-namespaces
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add LP domain for CDP and getMarkdown method
2026-03-03 18:08:45 +08:00
Karl Seguin
523efbd85a Fix a few issues in Client
Most significantly, if removing from the multi fails, the connection
is added to a "dirty" list for the removal to be retried later. Looking at
the curl source code, remove fails on a recursive call, and we've struggled with
recursive calls before, so I _think_ this might be happening (it fails in other
cases, but I suspect if it _is_ happening, it's for this reason). The retry
happens _after_ `perform`, so it cannot fail for due to recursiveness. If it
fails at this point, we @panic. This is harsh, but it isn't easily recoverable
and before putting effort into it, I'd like to know that it's actually happening.

Fix potential use of undefined when a 401-407 request is received, but no
'WWW-Authenticate' or 'Proxy-Authenticate' header is received.

Don't call `curl_multi_remove_handle` on an easy that hasn't been added yet do
to error. Specifically, if `makeRequest` fails during setup, transfer_conn is
nulled so that `transfer.deinit()` doesn't try to remove the connection. And the
conn is removed from the `in_use` queue and made `available` again.

On Abort, if getting the private fails (extremely unlikely), we now still try
to remove the connection from the multi.

Added a few more fields to the famous "ScriptManager.Header recall" assertion.
2026-03-03 18:02:06 +08:00
Pierre Tachoire
fcacc8bfc6 remove the isString type check into TransformStream write 2026-03-03 09:40:32 +01:00
Adrià Arrufat
b2e301418f cdp.lp: use page.document instead of window._document 2026-03-03 17:11:16 +09:00
Adrià Arrufat
334a2e44a1 lp: simplify dom_node resolution in getMarkdown 2026-03-03 17:08:43 +09:00
Pierre Tachoire
252b3c3bf6 Ignore BOM only when the option is set on TextDecoderStream 2026-03-03 09:04:41 +01:00
Adrià Arrufat
c9121a03d2 cdp: move LP.getMarkdown test to lp domain 2026-03-03 16:39:31 +09:00
Adrià Arrufat
cc93180d57 cdp: add LP domain and getMarkdown method
This PR introduces a custom CDP domain 'LP' (Lightpanda) to expose browser-specific tools. The first method, 'LP.getMarkdown', allows retrieving a Markdown representation of the DOM or a specific node by its 'nodeId'. This is optimized for AI agents and LLM-based scraping tasks.
2026-03-03 16:35:48 +09:00
Pierre Tachoire
24221748e1 Merge pull request #1699 from lightpanda-io/textencoder-stream-enhancements
Textencoder stream enhancements
2026-03-03 08:12:07 +01:00
Pierre Tachoire
4062a425cb Merge pull request #1700 from lightpanda-io/minor_cleanup
Remove unused file and unused .gitignore paths
2026-03-03 08:09:44 +01:00
Karl Seguin
cce533ebb6 Merge pull request #1701 from arrufat/markdown-test-namespace
markdown: namespace tests
2026-03-03 14:21:24 +08:00
Adrià Arrufat
48df38cbfe mcp: improve evaluate error reporting and refactor tool result types 2026-03-03 15:17:59 +09:00
Adrià Arrufat
f982f073df mcp: optimize memory re-use and add thread safety to Server.sendResponse 2026-03-03 14:50:13 +09:00
Adrià Arrufat
34999f12ca mcp: migrate tests to expectJson 2026-03-03 14:40:20 +09:00
Adrià Arrufat
c8d5665653 mcp: use testing allocator in tests 2026-03-03 14:32:29 +09:00
Adrià Arrufat
ddebaf87d0 markdown: namespace tests 2026-03-03 14:22:45 +09:00
Adrià Arrufat
6b80cd6109 mcp: namespace tests 2026-03-03 14:19:36 +09:00
Karl Seguin
7635d8d2a5 Remove unused file and unused .gitignore paths 2026-03-03 12:08:53 +08:00
Karl Seguin
141ae053db leverage JS bridge's type mapping 2026-03-03 11:43:13 +08:00
Karl Seguin
10ec4ff814 Create Zig wrapper generator for js.Function creation
This allows us to leverage the Caller.Function.call method, which does type
mapping, caching, etc... and allows the Zig function callback to be written like
any other Zig WebAPI function.
2026-03-03 11:41:00 +08:00
Pierre Tachoire
d2da0b7c0e remove useless _page field from WritableStream* 2026-03-02 18:09:46 +01:00
Pierre Tachoire
7d0548406e Move V8 pipe callback helpers into js/ layer
ReadableStream.zig was the only webapi file importing v8 directly.
Extract the repeated newFunctionWithData / callback boilerplate into
js/Local (newFunctionWithData) and js/Caller (initFromHandle,
FunctionCallbackInfo.getData), and update ReadableStream and Context
to use them.
2026-03-02 17:33:56 +01:00
Adrià Arrufat
634e3e35a0 mcp: re-enable tests 2026-03-02 23:12:16 +09:00
Adrià Arrufat
da3dc58199 Merge branch 'main' into mcp 2026-03-02 23:01:55 +09:00
Adrià Arrufat
4f99df694b mcp: simplify minify and remove eval quota 2026-03-02 22:46:20 +09:00
Pierre Tachoire
c121dbbd67 Add desiredSize accessor to WritableStreamDefaultWriter
Returns 1 when writable (default high water mark), 0 when closed,
and null when errored, matching the spec behavior for streams
without a custom queuing strategy.
2026-03-02 14:41:03 +01:00
Pierre Tachoire
c1c0a7d494 Skip enqueue of empty chunks in TextDecoderStream
After BOM stripping or when receiving an empty Uint8Array, the
decoded input can be zero-length. Per spec, empty chunks should
produce no output rather than enqueuing an empty string.
2026-03-02 14:30:39 +01:00
Pierre Tachoire
0749f60702 Preserve chunk value types through ReadableStream enqueue/read
When JS called controller.enqueue(42), the value was coerced to the
string "42" because Chunk only had uint8array and string variants.
Add a js_value variant that persists the raw JS value handle, and
expose enqueueValue(js.Value) as the JS-facing enqueue method so
numbers, booleans, and objects round-trip with their original types.
2026-03-02 14:24:49 +01:00
Adrià Arrufat
982b8e2d72 mcp: remove redundant mcp from test references 2026-03-02 22:24:17 +09:00
Adrià Arrufat
6e7c8d7ae2 mcp: consolidate tests and streamline parameter parsing 2026-03-02 22:18:02 +09:00
Adrià Arrufat
3c858f522b mcp: simplify minify function 2026-03-02 22:04:55 +09:00
Adrià Arrufat
f2a30f8cdd mcp: don't forget to flush 2026-03-02 21:46:49 +09:00
Adrià Arrufat
43785bfab4 mcp: simplify handleList implementations 2026-03-02 21:30:47 +09:00
Adrià Arrufat
78edf6d324 mcp: simplify I/O architecture and remove test harness 2026-03-02 21:25:07 +09:00
Adrià Arrufat
73565c4493 mcp: optimize dispatching and simplify test harness
- Use StaticStringMap and enums for method, tool, and resource lookups.
- Implement comptime JSON minification for tool schemas.
- Refactor router and harness to use more efficient buffered polling.
- Consolidate integration tests and add synchronous unit tests.
2026-03-02 20:53:14 +09:00
Pierre Tachoire
ca0ef18bdf Implement async piping for ReadableStream.pipeThrough/pipeTo
Replace synchronous queue-draining approach with async promise-based
piping using V8's thenAndCatch callbacks. PipeState struct manages the
async read loop: reader.read() returns a Promise, onReadFulfilled
extracts {done, value}, writes chunks to the writable side, and
recurses via pumpRead() until the stream closes.
2026-03-02 12:17:17 +01:00
Pierre Tachoire
6ed011e2f8 Add pipeThrough and pipeTo to ReadableStream
Implement synchronous piping that drains queued chunks from a
ReadableStream into a WritableStream. pipeThrough accepts any
{readable, writable} pair (TransformStream, TextDecoderStream, etc.)
and returns the readable side. pipeTo writes all chunks to a
WritableStream and resolves when complete.
2026-03-02 12:06:18 +01:00
Pierre Tachoire
23d322452a Add TextDecoderStream to decode UTF-8 byte streams into strings
Mirrors TextEncoderStream: wraps a TransformStream with a Zig-level
transform that converts Uint8Array chunks to strings. Supports the
same constructor options as TextDecoder (label, fatal, ignoreBOM).
2026-03-02 11:49:01 +01:00
Pierre Tachoire
5d3b965d28 Implement WritableStream, TransformStream, and TextEncoderStream
Add the missing Streams API types needed for TextEncoderStream support:
- WritableStream with locked/getWriter, supporting both JS sink callbacks
and internal TransformStream routing
- WritableStreamDefaultWriter with write/close/releaseLock/closed/ready
- WritableStreamDefaultController with error()
- TransformStream with readable/writable accessors, JS transformer
callbacks (start/transform/flush), and Zig-level transform support
- TransformStreamDefaultController with enqueue/error/terminate
- TextEncoderStream that encodes string chunks to UTF-8 Uint8Array
via a Zig-level transform function
2026-03-02 11:49:01 +01:00
Karl Seguin
d9794d72c7 fix bad rebase 2026-03-02 18:39:02 +08:00
Karl Seguin
524b5be937 on iframe re-navigation, keep pending_loads in sync 2026-03-02 18:14:36 +08:00
Karl Seguin
ac2e276a6a try to make test more stable 2026-03-02 18:14:36 +08:00
Karl Seguin
4f4dbc0c22 Allow iframe.src to renavigate the page
Unlike a script, an iframe can be re-navigated simply by setting the src.
2026-03-02 18:14:34 +08:00
Karl Seguin
8c37cac957 Merge pull request #1694 from lightpanda-io/client_abort_frame
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Allow frame-specific HTTP abort
2026-03-02 18:11:33 +08:00
Karl Seguin
eceab76b6f Merge pull request #1693 from lightpanda-io/nikneym/arena-pool-test
`ArenaPool`: make init configurable + add tests
2026-03-02 18:11:13 +08:00
Karl Seguin
1f81b6ddc4 Allow frame-specific HTTP abort
Needed for frame navigation. Implemented using some ugly comptime to avoid
duplication and avoid an runtime frame check when doing a full abort.
2026-03-02 18:00:55 +08:00
Halil Durak
52c3aadd24 ArenaPool: add tests 2026-03-02 12:56:10 +03:00
Halil Durak
ad87573d09 ArenaPool: make init configurable 2026-03-02 12:55:55 +03:00
Karl Seguin
20fbfc8544 Merge pull request #1689 from lightpanda-io/protect_xhr_abort_during_callback
Protect against transfer.abort() being called during callback
2026-03-02 17:42:07 +08:00
Karl Seguin
7695c8403f Merge pull request #1692 from lightpanda-io/rename_page_id_to_frame_id
Rename page.id to page._frame_id
2026-03-02 17:40:43 +08:00
Karl Seguin
421983d06e Merge pull request #1690 from lightpanda-io/event_dispatch_cleanup
Attempt to improve non-DOM EventTarget dispatching
2026-03-02 17:40:29 +08:00
Karl Seguin
328c681a8f Add transfer-specific "performing" flag
In the previous commits, two separte crash resolution conspired to introduce
1 tick delay in request handling.

When we're in a libcurl perform, we can't re-enter libcurl. So we added a
check before processing new requests to make sure we weren't "performing" and,
if we were, we'd queue the request (hence the 1 tick delay).

But for another issue, we set the same "performing" check when manually
triggering callbacks. This extended the situations where the above check fired
thus causing the 1-tick delay to happen under more (and even common) situation.

This commit improves this - instead of relying on the global "performing" check
when processing 1 transfer explicitly, we now have a per-transfer performing
check. This prevents the transfer from being deinitialized during a callback
but does not block requests from being started immediately.
2026-03-02 17:29:47 +08:00
Pierre Tachoire
48d94d0f68 Merge pull request #1688 from lightpanda-io/cdp_shutdown
Remove redundant CDP v8 shutdown
2026-03-02 09:35:40 +01:00
Karl Seguin
10ad5d763e Rename page.id to page._frame_id
This field was recently added and is used to generate correct frameIds in CDP
messages. They remain the same during a navigation event, so calling them
page.id might cause surprises since navigation events create new pages, but
retain the original id. Hence, frame_id is more accurate and hopefully less
surprising.

(This is a small cleanup prior to doing some iframe navigation work).
2026-03-02 16:21:29 +08:00
Pierre Tachoire
2a78c946e4 Merge pull request #1691 from lightpanda-io/wpt-timeout
adjust WPT timeout on CI
2026-03-02 09:14:50 +01:00
Adrià Arrufat
a7872aa054 mcp: improve robustness of server and test harness
- Refactor router and test harness for non-blocking I/O using buffered polling.
- Implement reliable test failure reporting from sub-threads to the main test runner.
- Encapsulate pipe management using idiomatic std.fs.File methods.
- Fix invalid JSON generation in resource streaming due to duplicate fields.
- Improve shutdown sequence for clean test exits.
2026-03-02 17:03:04 +09:00
Pierre Tachoire
5c228ae0a1 adjust WPT timeout on CI 2026-03-02 08:58:03 +01:00
Karl Seguin
ce73f7ac5a Attempt to improve non-DOM EventTarget dispatching
There are two main ways to dispatch events, both via the EventManager: dispatch
and dispatchWithFunction. dispatchWithFunction came about from having to
dispatch to function callbacks in addition to event listeners. Specifically,
firing the window.onload callback.

Since that original design, much has changed. Most significantly, with
https://github.com/lightpanda-io/browser/pull/1524 callbacks defined via
attributes became properly (I hope) integrated with the event dispatching.
Furthermore, the number of non-tree event targets (e.g. AbortSignal) has grown
significantly. Finally, dispatching an event is DOM-based event is pretty
complex, involving multiple phases and capturing the path.

The current design is largely correct, but non-obvious. This commit attempts to
improve the ergonomics of event dispatching.

`dispatchWithFunction` has been renamed to `dispatchDirect`. This function is
meant to be used with non-DOM event targets. It is optimized for having an event
path with a single target andh no bubbling/capture phase. In addition to being
a little more streamlined, `dispatchDirect` will internally turn a
`js.Function.Global` or `js.Function.Temp` into a local. This makes the callsite
simpler, but also provides optimization opportunity - not having to create
a new scope for the common case of having no callback/listener. This lays the
groundwork for having a `hasDirect` guard clause at the callsite to avoid
unnecessary event creation (todo in a follow up commit).

`dispatch` remains unchanged. While `dispatch` is primarily meant to handle the
DOM-based EventTarget, it will forward non-DOM EventTargets to `dispatchDirect`.
This is necessary since JS code can call `signal.dispatchEvent(....)`.

Two notes:
1 - The flow of dispatchDirect is an optimization. The spec makes no distinction
    between DOM and non-DOM based EventTargets.
2 - While the window (as an EventTarget) should probably be thought of as a
    DOM-based EventTarget, we use `dispatchDirect with it. This is because it
    sits at the root and thus can safely go through the faster `dispatchDirect`.
2026-03-02 15:11:02 +08:00
Adrià Arrufat
64107f5957 mcp: refactor for testability and add comprehensive test suite
- Refactor mcp.Server and router to accept injected I/O streams.
- Implement McpHarness for high-fidelity MCP integration testing.
- Add unit tests for protocol, tools, and resources modules.
- Add integration tests covering initialization, tool/resource execution, and error handling.
- Improve error reporting for malformed JSON requests.
2026-03-02 15:52:05 +09:00
Adrià Arrufat
8a1795d56f mcp: fix memory leak in links tool 2026-03-02 13:09:58 +09:00
Karl Seguin
b104c3bfe8 Don't start request during callback
Fixes a separate but similar issue to
https://github.com/lightpanda-io/browser/pull/1689

Specifically, it prevents starting a request from within a libcurl handler, thus
avoiding an illegal recursive call.

(This commit also removes the failed function call debug logging for
DOMExceptions, as these aren't particularly abnormal / log-worthy)
2026-03-02 12:04:02 +08:00
Karl Seguin
82e3f126ff Protect against transfer.abort() being called during callback
This was already handled in most cases, but not for a body-less response. It's
safe to call transfer.abort() during a callback, so long as the performing flag
is set to true. This was set during the normal libcurl callbacks, but for a
body-less response, we manually invoke the header_done_callback and were not
setting the performing flag.
2026-03-02 11:44:42 +08:00
Adrià Arrufat
175488563e mcp: remove browser message loop from processRequests 2026-03-02 12:25:33 +09:00
Adrià Arrufat
da51cdd11d Merge branch 'main' into mcp 2026-03-02 11:55:36 +09:00
Adrià Arrufat
a8a47b138f mcp: change browser from pointer to value 2026-03-02 11:50:56 +09:00
Adrià Arrufat
b63d4cf675 mcp: improve RawJson stringification and schema formatting
- Update `RawJson.jsonStringify` to parse and re-write JSON content, ensuring valid output.
- Reformat tool input schemas in `tools.zig` using multi-line string literals for better readability.
2026-03-02 11:47:02 +09:00
Karl Seguin
03b999c592 Remove redundant CDP v8 shutdown
https://github.com/lightpanda-io/browser/pull/1614 improved our shutdown
behavior so that microtasks associated with a context wouldn't fire after the
context was disposed of. This involved having context-specific microtasks,
pumping the message loop, and prevent re-entry.

The shutdown code in CDP already had much of this behavior built-in, but it has
now become redundant. Most importantly the CDP shutdown logic did not prevent
re-entry.

Removing this code fixes a flaky WPT crash. I didn't seem to be tied to a
specific test, but rather a cross-context/page use-after-free that was saw
prior to 1614. I could reproduce it reliably by running `/wasm/core/`.

I'll be honest, it isn't clear to me why _removing_ the CDP cleanup helps.
Running the message loop and microtask _before_ our normal shutdown might be
unnecessary, but why would it crash? I don't know, but the CDP path is slightly
different in that it also involves Inspector shutdown. So there's still
something about this flow I don't quite understand. And, at least for this case
the current flow seems "correct".
2026-03-02 10:24:07 +08:00
Adrià Arrufat
a91afab038 mcp: improve event loop and response handling
- Use an allocating writer in `sendResponse` to handle large payloads.
- Update the main loop to tick the HTTP client and cap poll timeouts.
- Update protocol version and minify tool input schemas.
2026-03-02 11:12:00 +09:00
Adrià Arrufat
d4747b5386 mcp: own the browser
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-02 10:10:08 +09:00
Adrià Arrufat
41b81c8b05 mcp: use io poll for stdin and integrate message loop
Replaces blocking stdin reads with `std.io.poll` to allow macrotasks to
run. Removes the stdout mutex as I/O is now serialized.
2026-03-02 10:04:23 +09:00
Pierre Tachoire
552831364d Merge pull request #1687 from lightpanda-io/ci-integration-test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: reduce log_level for integration test
2026-03-01 16:09:56 +01:00
Adrià Arrufat
42b5e32473 mcp: modernize I/O processing and reuse message buffer 2026-03-01 22:35:28 +09:00
Adrià Arrufat
e9c36fd6f8 mcp: use declarative static definitions for tools and resources 2026-03-01 21:56:48 +09:00
Adrià Arrufat
952dfbef36 mcp: use acquire/release ordering for server running flag 2026-03-01 21:39:38 +09:00
Adrià Arrufat
254984b600 mcp: use dynamic allocation for error messages in tools 2026-03-01 21:36:21 +09:00
Adrià Arrufat
8cbc58d257 mcp: unify error reporting and use named error codes 2026-03-01 21:29:59 +09:00
Adrià Arrufat
e6cc3e8c34 mcp: refactor tools handling 2026-03-01 21:18:28 +09:00
Karl Seguin
516335e0ed Merge pull request #1686 from lightpanda-io/load_event_iframe_fix
Fix load event for page with no external scripts but with iframes
2026-03-01 20:15:36 +08:00
Adrià Arrufat
01798ed7f8 mcp: use sentinel-terminated strings for tool params 2026-03-01 20:58:00 +09:00
Adrià Arrufat
fcad67a854 mcp: pre-initialize tools and resources on server startup 2026-03-01 20:44:11 +09:00
Adrià Arrufat
e359ffead0 mcp: propagate errors in tool schema parsing 2026-03-01 20:39:40 +09:00
Adrià Arrufat
eb09041859 mcp: resolve absolute URLs for links tool 2026-03-01 20:35:13 +09:00
Adrià Arrufat
b3d52c966d mcp: handle errors during resource and tool streaming 2026-03-01 20:23:23 +09:00
Pierre Tachoire
3fb8a14348 ci: reduce log_level for integration test 2026-03-01 11:22:37 +01:00
Karl Seguin
84a949e7c7 Fix load event for page with no external scripts but with iframes
Previously the "load" event happened when all external scripts were done. In the
case that there was no external script, the "load" event would fire immediately
after parsing.

With iframes, it now waits for external script AND iframes to complete but the
no-external-script code was never updated to consider iframes and would thus
fire load events prematurely.
2026-03-01 18:19:40 +08:00
Pierre Tachoire
eaf1cb26b2 Merge pull request #1685 from ireydiak/chore/remove-gitsubmodules-from-readme
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove outdated git submodule documentation and Makefile target
2026-03-01 11:01:24 +01:00
Pierre Tachoire
f37962d3de Merge pull request #1683 from lightpanda-io/dump_mode_wpt
Add a "wpt" dump mode
2026-03-01 11:00:29 +01:00
Pierre Tachoire
511e957d4b Merge pull request #1682 from lightpanda-io/iframe_document_open_fix
Noop when document.open is called during iframe parsing
2026-03-01 10:57:42 +01:00
Pierre Tachoire
71df03b729 Merge pull request #1681 from lightpanda-io/xhr_url_escape_mime_parse_lax
Escape XHR URL, Lax MIME parameter parsing
2026-03-01 10:55:50 +01:00
Pierre Tachoire
839052f4b8 Merge pull request #1680 from lightpanda-io/cdp_json_url
Correctly JSON encode URL
2026-03-01 10:55:05 +01:00
ireydiak
7c18d857f0 chore: removed gitsubmodules from README and Makefile 2026-02-28 12:36:40 -05:00
Adrià Arrufat
947e672d18 mcp: stream resource and tool content to JSON output 2026-02-28 23:04:22 +09:00
Adrià Arrufat
96942960a9 mcp: reuse arena allocator for message processing 2026-02-28 22:38:16 +09:00
Adrià Arrufat
8b0118e2c8 mcp: update logging scope to use mcp instead of app 2026-02-28 22:30:02 +09:00
Adrià Arrufat
5f9a7a5381 mcp: ignore unknown json fields and improve error reporting 2026-02-28 22:18:37 +09:00
Adrià Arrufat
6897d72c3e mcp: simplify request processing to single-threaded 2026-02-28 21:26:51 +09:00
Adrià Arrufat
aae9a505e0 mcp: promot Server.zig to file struct 2026-02-28 21:02:49 +09:00
Karl Seguin
45196e022b Add a "wpt" dump mode
Adds a not-documented "wpt" mode to --dump which outputs a formatted
report.cases.

This is meant to make working on a single WPT test case easier, particularly
with some coding tool. Claude recommended this output for its own use.

Instead of telling claude to start the browser in serve mode, then run the
wptrunner, and merge the two outputs (and then stop the server), you can do:

zig build run -- fetch --dump wpt "http://localhost:8000/dom/nodes/CharacterData-appendChild.html"

(you still need the wpt server up)
2026-02-28 19:08:58 +08:00
Karl Seguin
b9e4c44d63 Noop when document.open is called during iframe parsing
I'm not sure what the correct behavior is, but this fixes a WPT crash:
/html/browsers/sandboxing/sandbox-inherited-from-required-csp.html

The issue is iframe-specific as, with an iframe, you document.write can be
called during parsing when there's no document._current_script (because it's
being executed from the parent).
2026-02-28 18:05:03 +08:00
Karl Seguin
0a9e5b66ee Merge pull request #1679 from lightpanda-io/escape_data_uri
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Escape DataURIs
2026-02-28 14:45:12 +08:00
Karl Seguin
8b99e82743 Merge pull request #1678 from lightpanda-io/improve_atob
Re-implement forgiving base64 decode without intermediate allocation
2026-02-28 14:45:01 +08:00
Karl Seguin
059fb85e22 Escape XHR URL, Lax MIME parameter parsing
Follow up to https://github.com/lightpanda-io/browser/pull/1646 applies the
same change to XHR URLs.

Following specs, ignores unknown/invalid parameters of the Content-Type when
parsing the MIME (rather than rejecting the entire header).
2026-02-28 14:42:43 +08:00
Karl Seguin
8997df861a Merge pull request #1677 from lightpanda-io/mime_charset_default
Initialize charset to safe default
2026-02-28 14:30:14 +08:00
Karl Seguin
e65667963f Correctly JSON encode URL
I think this code comes from some serialization tweak from when everything was
an std.Uri and by switch to [:0]const u8 everywhere not only was the tweak
unecessary, it was also wrong - possibly resulting in the generation of
invalid JSON.
2026-02-28 12:48:45 +08:00
Karl Seguin
3d51667fc8 Escape DataURIs
Support forgiving base64 decoder

Support non-encoded DataURIs
2026-02-28 12:24:26 +08:00
Karl Seguin
7fc6e97cd8 Re-implement forgiving base64 decode without intermediate allocation
Was looking at, what I thought was a related issue, and started to extract this
code to re-use it (in DataURIs). Realized it could be written without the
intermediate allocation. Then I realized the dataURI issue is something else,
but wanted to keep this improvement.
2026-02-28 11:22:31 +08:00
Karl Seguin
1473e58a41 Initialize charset to safe default
Fixes a WPT crash (not sure which, but in `/fetch/content-type/`)
2026-02-28 10:42:53 +08:00
Karl Seguin
2394b2f44f Merge pull request #1676 from lightpanda-io/add-scroll-by
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
add window.scrollBy
2026-02-28 07:03:39 +08:00
Karl Seguin
516bd98198 Merge pull request #1675 from lightpanda-io/reset-attribute-listener
Set a null attribute listener must remove existing value
2026-02-28 07:01:11 +08:00
Karl Seguin
7d8688a130 Merge pull request #1674 from lightpanda-io/atob-unpadded-base64
accept must accept unpadded data in atob
2026-02-28 07:00:00 +08:00
Pierre Tachoire
631ec70058 add window.scrollBy 2026-02-27 16:19:27 +01:00
Pierre Tachoire
6fd51cfdc0 Set a null attribute listener must remove existing value 2026-02-27 14:47:43 +01:00
Pierre Tachoire
6857b74623 accept must accept unpadded data in atob
according with https://infra.spec.whatwg.org/#forgiving-base64-decode
2026-02-27 14:31:03 +01:00
Adrià Arrufat
5ec4305a9f mcp: add optional url parameter to tools 2026-02-27 22:17:15 +09:00
Pierre Tachoire
88baff96d0 Merge pull request #1671 from lightpanda-io/custom_element_name
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix our custom element name validation
2026-02-27 14:08:13 +01:00
Pierre Tachoire
e871f0002b Merge pull request #1633 from lightpanda-io/wptrunner
remove WPT specific code
2026-02-27 13:02:47 +01:00
Karl Seguin
7358d48e35 Fix our custom element name validation
Passes all WPT tests:
/custom-elements/registries/valid-custom-element-names.html

Also, apply validation to whenDefined, which we were not doing.
2026-02-27 18:46:07 +08:00
Pierre Tachoire
178fbf0fca wpt: reduce concurrency 2026-02-27 11:37:44 +01:00
Karl Seguin
a50597ff27 Merge pull request #1669 from lightpanda-io/more_interned_strings
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Expand the strings we intern
2026-02-27 17:30:21 +08:00
Karl Seguin
e4cb78abee Merge pull request #1670 from lightpanda-io/cdata_sso
Change CData._data from []const to String (SSO)
2026-02-27 17:30:03 +08:00
Karl Seguin
732884a3b2 Merge pull request #1668 from lightpanda-io/selector_list_arena
Add RC support to NodeList
2026-02-27 17:29:06 +08:00
Karl Seguin
80f2c42c69 Merge pull request #1660 from lightpanda-io/fix_css_parse_overflow
Fix possible overflow when parsing floats without an integer
2026-02-27 17:27:56 +08:00
Pierre Tachoire
49a5a39659 Merge pull request #1666 from lightpanda-io/curl_github_repo
Use github repo for curl source
2026-02-27 10:09:37 +01:00
Pierre Tachoire
a4a7040b98 wpt: configure hosts manually for self host runner 2026-02-27 10:09:24 +01:00
Pierre Tachoire
de5a7d5b99 wpt: use auo-restart browser feature of wpt runner 2026-02-27 10:09:23 +01:00
Pierre Tachoire
3f92e388be allow insecure TLS when running WPT tests 2026-02-27 10:09:23 +01:00
Pierre Tachoire
25c941b847 use wptrunner and wpt HTTP server to run wpt tests 2026-02-27 10:09:23 +01:00
Pierre Tachoire
24b6934d3b remove WPT specific code
Using both lightpanda-io/wpt and lightpanda-io/demo/wptrunner remove the
need for code specific to run WPT from browser.
2026-02-27 10:09:07 +01:00
Karl Seguin
d286ab406c Merge pull request #1664 from lightpanda-io/storage-quota-limit
implement storage size limit per origin
2026-02-27 16:45:51 +08:00
Pierre Tachoire
ef6a7a6904 storage: maintain Lookup size correctly 2026-02-27 08:57:29 +01:00
Pierre Tachoire
c61eda0d24 crypto: use dom exception to return QuotaExceededError 2026-02-27 08:57:28 +01:00
Pierre Tachoire
ad226b6fb1 implement storage size limit per origin 2026-02-27 08:57:28 +01:00
Karl Seguin
24491f0dfe fix String copy/reference 2026-02-27 14:34:20 +08:00
Karl Seguin
870fd1654d Change CData._data from []const to String (SSO)
After looking at a handful of websites, the # of Text and Commend nodes
that are small (<= 12 bytes) is _really_ high. Ranging from 85% to 98%. I
thought that was high, but a lot of it is indentation or a sentence that's
broken down into multiple nodes, eg:

<div><b>sale!</b> <span class=price>$1.99</span> buy now<div>

So what looks like 1 sentence to us, is actually 3 text nodes.

On a typical website, we should see thousands of fewer allocations in the
page arena for the text in text nodes.
2026-02-27 12:53:54 +08:00
Karl Seguin
38bc912e4e Expand the strings we intern
Based on analysis of a handful of websites (amazon product, github, DDG, reddit)
2026-02-27 11:17:06 +08:00
Karl Seguin
315c9a2d92 Add RC support to NodeList
Most importantly, this allows the Selector.List to be self-contained with
an arena from the ArenaPool. Selector.List can be both relatively large
and relatively common, so moving it off the page.arena is a nice win.

Also applied this to ChildNodes, which is much smaller but could also be
called often.

I was initially going to hook into the v8::Object's internal fields to store
the referencing v8::Object. So the v8::Object representing the Iterator
would store the v8::Object representing the NodeList inside of its internal
field - which the GC would trace/detect/respect. And that is probably the
fastest and most v8-ish solution, but I couldn't come up with an elegant
solution. The best I had was having a "afterCreate" callback which passed
the v8 object (this is similar to the old postAttach callback we had, but
used for a different purpose). However, since "acquireRef" was recently
added to events, re-using that was much simpler and worked well.
2026-02-27 10:29:46 +08:00
Karl Seguin
a14ad6f700 Merge pull request #1659 from lightpanda-io/nodelist_enumerable
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Make NodeList enumerable
2026-02-27 08:26:59 +08:00
Karl Seguin
d56e63a91b On Client.stop, terminate the isolate
Shutdown on MacOS doesn't work properly. The process appears to shutdown, but
will continue to run in the background. It can run infinitely if it's stuck in
a JS loop. To help it along, Client.stop now force-terminates the isolate.

I also don't think shutdown is working as intended on Linux, but the problem
seems less serious there. On Linux, it appears to properly kill the process
(which is the important thing), but I don't think it necessarily does a clean
shutdown.
2026-02-27 08:20:31 +08:00
Karl Seguin
76dcdfb98c Use github repo for curl source
Since we already rely on github for builds, this removes a point of failure.
Also curl.se consistently fails from a VPS machine for me - not sure if
they're blocking IP ranges, but it works fine on github.
2026-02-27 07:02:06 +08:00
Karl Seguin
99c09ba8a1 Merge pull request #1657 from lightpanda-io/FileReader
Add FileReader
2026-02-27 06:58:49 +08:00
Karl Seguin
0f18b76813 Merge pull request #1665 from lightpanda-io/cookies-limit
add limit for cookie and jar size
2026-02-27 06:54:39 +08:00
Pierre Tachoire
8504e4cd22 add limit for cookie and jar size 2026-02-26 18:33:18 +01:00
Karl Seguin
ebe793e0e7 Merge pull request #1663 from lightpanda-io/more_header_callback_debugging
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add more properties to ScriptManager.Header recall
2026-02-26 19:15:59 +08:00
Pierre Tachoire
965c6cf4d9 Merge pull request #1662 from lightpanda-io/non_keyboard_keydown_event
Don't assume that a 'keydown' event is a KeyboardEvent
2026-02-26 11:55:54 +01:00
Pierre Tachoire
2b1ab3184e Merge pull request #1640 from lightpanda-io/nikneym/load-events-after-doc-complete
Dispatch `load` events that're attached after `documentIsComplete`
2026-02-26 11:01:46 +01:00
Karl Seguin
e7d21c2dbe Add more properties to ScriptManager.Header recall
This bug continues elusive. The latest crash logs show us that, somehow, 1
script is being resolved from 2 transfer objects. This doesn't seem possible,
so I'm adding more properties to log the state of both transfers to try and
figure out how this is happening.
2026-02-26 17:52:33 +08:00
Nikolay Govorov
11906d9d71 Merge pull request #1650 from lightpanda-io/wp/mrdimidium/update-deps
Updates dependencies and their build
2026-02-26 09:19:34 +00:00
Nikolay Govorov
ac5a64d77a Fix typo in build.zig
Co-authored-by: Halil Durak <halildrk@gmail.com>
2026-02-26 08:41:01 +00:00
Halil Durak
c86c851c60 move *addedCallbacks to respective types 2026-02-26 10:27:35 +03:00
Halil Durak
721cf98486 update Image and Style tests 2026-02-26 10:27:35 +03:00
Halil Durak
84bbb6efd4 replacement w/ imageAddedCallback 2026-02-26 10:27:35 +03:00
Halil Durak
f897cda6cd dispatch Style element's load event from nodeIsReady 2026-02-26 10:27:34 +03:00
Halil Durak
2da8b25b09 add LinkLoadError to CloneError 2026-02-26 10:27:34 +03:00
Halil Durak
3f94fd90dd dispatch a load event when href set for Link element
Also add `lazy-href-set` test.
2026-02-26 10:27:34 +03:00
Halil Durak
bc6be22cb4 update test 2026-02-26 10:27:34 +03:00
Halil Durak
e23604e08d introduce dispatchLoad and move load dispatching to Session._wait 2026-02-26 10:27:33 +03:00
Halil Durak
be858ac9ce add load event related tests to link.html 2026-02-26 10:27:33 +03:00
Halil Durak
137ab4a557 dispatch load events that're attached after documentIsComplete 2026-02-26 10:27:33 +03:00
Karl Seguin
bad0fc386d Don't assume that a 'keydown' event is a KeyboardEvent 2026-02-26 15:26:34 +08:00
Karl Seguin
641c7b2c89 Merge pull request #1661 from lightpanda-io/escape_navigate_url
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Callers to page.navigate ensure URL is properly encoded.
2026-02-26 15:19:11 +08:00
Karl Seguin
21be3db51f Callers to page.navigate ensure URL is properly encoded.
Follow up to https://github.com/lightpanda-io/browser/pull/1646

The encodeURL (renamed to ensureEncoded and exposed in this commit) already
handled already-encoded URLs, so this was largely a matter of exposing the
functionality.

The reason this isn't baked directly into Page.navigate is that, in some places
e.g. internal navigation, the URL is already know to be encoded. So it's up
to every caller to make sure they are passing a valid URL to navigate.
2026-02-26 12:22:06 +08:00
Karl Seguin
e978857820 Fix possible overflow when parsing floats without an integer
Fixes a WPT test, but I'm not exactly sure which one.
2026-02-26 11:52:29 +08:00
Karl Seguin
3bf596c54c Merge pull request #1651 from lightpanda-io/more_pump_message_loop
Run the MessageLoop [a lot] more.
2026-02-26 11:35:11 +08:00
Karl Seguin
aedb823b4d update v8 dep 2026-02-26 10:55:02 +08:00
Karl Seguin
7a417435cc Update src/browser/Session.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-26 10:53:16 +08:00
Karl Seguin
497d6e80f7 Merge pull request #1658 from lightpanda-io/wp/mrdimidium/typesafe-libcurl
Move curl C API to type-safe wrapper
2026-02-26 10:28:37 +08:00
Karl Seguin
ae6ab34e72 Make NodeList enumerable
This probably needs to be done for more types. Foundation is now in bridge, so
it should be easy to add.
2026-02-26 08:57:42 +08:00
Nikolay Govorov
4c26161728 Move curl C API to type-safe wrapper 2026-02-25 23:29:54 +00:00
Karl Seguin
1731dca5dd Merge pull request #1648 from lightpanda-io/remove_unused_page_wait
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Remove unused Page.wait and Page._wait
2026-02-26 07:19:40 +08:00
Karl Seguin
ee2caff46e Merge pull request #1577 from lightpanda-io/selection-modify
Add `modify` to Selection
2026-02-26 07:03:37 +08:00
Karl Seguin
db8fb8b05d Merge pull request #1646 from lightpanda-io/url_encoding
Add url encoding option to URL.resolve
2026-02-26 07:02:49 +08:00
Karl Seguin
bec7e141dc Remove unused Page.wait and Page._wait
These exist in Session since iframes. The code isn't currently being used, I
must have pulled it back in during a rebase.
2026-02-26 06:58:56 +08:00
Karl Seguin
ab85b4b129 Merge pull request #1653 from lightpanda-io/dummy-performance-timing
add dummy PerformanceTiming
2026-02-26 06:50:19 +08:00
Karl Seguin
b030049b40 Merge pull request #1652 from lightpanda-io/dump_with_frames
Add a --with_frames argument to fetch
2026-02-26 06:49:53 +08:00
Karl Seguin
1338a3d89d Merge pull request #1647 from lightpanda-io/fix_event_leak_on_disaptch
fix event leak on dispatchEvent
2026-02-26 06:49:29 +08:00
Pierre Tachoire
181178296f set empty_with_no_proto for performance timing and navigation 2026-02-25 21:14:09 +01:00
Pierre Tachoire
df7888d6fb use bridge.property for performance timing and navigation 2026-02-25 21:10:27 +01:00
Muki Kiboigo
dd15f5e052 fix selection modify on nextTextNodeAfter 2026-02-25 07:21:30 -08:00
Muki Kiboigo
f348d85b11 add tests for walking past element on selection modify 2026-02-25 07:21:16 -08:00
Adrià Arrufat
8c8a05b8c1 mcp: consolidate tests and cleanup imports 2026-02-26 00:02:49 +09:00
Adrià Arrufat
34d2fc1503 mcp: support notifications and improve error handling
Make Request id optional for JSON-RPC notifications and handle the
initialized event. Improve thread safety, logging, and error paths.
2026-02-25 23:14:06 +09:00
Adrià Arrufat
9b3fa809bf mcp: add search, markdown, links, and over tools 2026-02-25 20:27:49 +09:00
Karl Seguin
59535c112e Add FileReader 2026-02-25 19:03:24 +08:00
Karl Seguin
04e5a6425a Merge pull request #1655 from lightpanda-io/crash_report_args
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Include assertion args in crash report
2026-02-25 18:36:24 +08:00
Karl Seguin
424dddf67b Merge pull request #1656 from lightpanda-io/dump_attribute
Dump Attribute to empty string
2026-02-25 18:31:19 +08:00
Pierre Tachoire
f0d6ae2a00 Merge pull request #1654 from lightpanda-io/dynamic_import_undefined_resource
Handle dynamicModuleCallback being called with undefined/null resourc…
2026-02-25 11:22:53 +01:00
Karl Seguin
25298a32fa Dump Attribute to empty string
This is normally not called in "normal" dump-usage, but with
XMLSerializer.serializeToString an Attr node _can_ be provided. The spec says,
and FF agrees, this should return an empty string.
2026-02-25 18:16:13 +08:00
Karl Seguin
ba28bf01b7 Include assertion args in crash report
Add more parameters to mysterious/persistent/infrequent
ScriptManager.Header recall failed assertion.
2026-02-25 18:05:33 +08:00
Pierre Tachoire
d15c29b1a3 add dummy Performance.Navigation 2026-02-25 10:52:17 +01:00
Karl Seguin
b083910a51 Handle dynamicModuleCallback being called with undefined/null resource_name
Fixes WPT test:
/semantics/scripting-1/the-script-element/module/dynamic-import/string-compilation-of-promise-result.html
2026-02-25 17:05:40 +08:00
Pierre Tachoire
235aad32a6 add dummy PerformanceTiming 2026-02-25 08:35:53 +01:00
Karl Seguin
a818560344 Add a --with_frames argument to fetch
When set (defaults to not set/false), --dump will include iframe contents.

I was hoping I could add a mode to strip_mode to this, but since dump is used
extensively (e.g. innerHTML), this is something that has to be off by default
(for correctness).
2026-02-25 15:29:27 +08:00
Nikolay Govorov
8f179becf7 Merge pull request #1639 from lightpanda-io/wp/mrdimidium/cleanup-io-layer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Separates network and the browser specific t
2026-02-25 06:12:56 +00:00
Nikolay Govorov
e1695a0874 Strict visibility for Net functions 2026-02-25 05:58:08 +00:00
Karl Seguin
af7498d283 Run the MessageLoop [a lot] more.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/152

We previously ran the message loop every 250ms. This commit changes it to run on
every tick (much more frequently). It also runs microtasks after draining the
message loop (since it can generate microtasks).

Also, we use to run microtasks after each script execution. Now we drain the
message Loop + microtasks.

We still only drain the microtasks when executing v8 callbacks.

As part of this change, we also adjust our wait time based on whether or not
there are pending background tasks in v8 in order to try to execute them (in
general) and in a timely manner.

The goal is to ensure that tasks v8 enqueued on the foreground thread are
executed promptly.

This change is particularly useful for calls to webassembly as compilation
happens in the background and eventually requires the message loop to be drained
to continue.

Previously, if a script did `await WebAssembly.instantiate(....)`, there was
a good chance we'd never finish the code - we'd wait too long to run the
message loop AND, after running it, we wouldn't necessarily resolve the promise.
2026-02-25 13:55:35 +08:00
Nikolay Govorov
3e2a4d8053 Move curl_multi to Net layer 2026-02-25 05:31:28 +00:00
Nikolay Govorov
29982e2caf Move all curl_easy ops to Connection 2026-02-25 05:31:24 +00:00
Nikolay Govorov
5fea1df42b Move Net staff to clean network module 2026-02-25 05:31:19 +00:00
Nikolay Govorov
a041162b32 Merge pull request #1649 from ireydiak/fix/cdp-json-version-trailing-slash
fix: handle trailing slash on /json/version CDP endpoint
2026-02-25 05:20:33 +00:00
Nikolay Govorov
32cd3981d8 Update libcurl version, use build based on config file 2026-02-25 05:05:00 +00:00
Nikolay Govorov
ca5af87196 Build C libs in isolated modules 2026-02-25 05:04:58 +00:00
Nikolay Govorov
a8164f612f Use zig package manager instead of submodules 2026-02-25 05:04:57 +00:00
ireydiak
d3bb0b6ff0 fix: handle trailing slash on /json/version CDP endpoint
Some CDP clients (e.g. playwright-go) request /json/version/ with a
trailing
slash. Added handling for this variant to match the exact same behavior
as /json/version
2026-02-24 22:23:11 -05:00
Karl Seguin
0ef10c1e13 fix event leak on dispatchEvent 2026-02-25 09:36:13 +08:00
Karl Seguin
4017911373 Merge pull request #1643 from lightpanda-io/nikneym/invalid-timer-test
Add a test for invalid timer/timer-like remove
2026-02-25 09:00:53 +08:00
Karl Seguin
048034d4b1 Merge pull request #1645 from lightpanda-io/fix-empty-struct
fix: add _pad to IdleDeadline to avoid identity_map pointer aliasing
2026-02-25 08:58:58 +08:00
Karl Seguin
fcb3f08bcb Add url encoding option to URL.resolve
Given:

a.href = "over 9000!"

Then:

a.href === BASE_URL + '/over%209000!';

This commits adds an escape: bool option to URL.resolve which will escape the
path, query and fragment when true.

Also changes the Anchor, Image, Link and IFrame getSrc to escape. Escaping is
also used when navigating a frame.
2026-02-25 08:17:05 +08:00
Karl Seguin
d2a05bb622 Merge pull request #1642 from lightpanda-io/html-noscript-no-escape
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
don't escape noscript content on html dump
2026-02-25 07:31:26 +08:00
Karl Seguin
f7254ee169 Merge pull request #1641 from lightpanda-io/contexts_no_realloc
Store Env.Contexts in an [fixed-length] array.
2026-02-25 07:30:42 +08:00
Pierre Tachoire
a0e5c9d570 add padding field for some other webapi 2026-02-24 21:36:38 +01:00
Pierre Tachoire
8291e4ba73 fix: add _pad to IdleDeadline to avoid identity_map pointer aliasing 2026-02-24 21:31:41 +01:00
Pierre Tachoire
b324be3b0b Merge pull request #1638 from lightpanda-io/performance-mark-name
accept more performance mark name and return dummy 0
2026-02-24 19:15:04 +01:00
Halil Durak
6ba0ba7126 add a test for invalid timer removal
We cover such cases; yet its better to have a test.
2026-02-24 18:25:26 +03:00
Pierre Tachoire
1d8e0629af don't escape noscript content on html dump 2026-02-24 15:22:43 +01:00
Pierre Tachoire
42df54869f performance: use a comptime StaticStringMap for string comparison 2026-02-24 15:18:25 +01:00
Karl Seguin
7b758b85ec fix test 2026-02-24 19:41:15 +08:00
Karl Seguin
82987ec401 Store Env.Contexts in an [fixed-length] array.
We currently store all of the env's contexts in an ArrayList. When performing
micro/macro tasks, we iterate through this list and perform the micro/macro
tasks. This can result in the ArrayList being invalidated (e.g. a microtask can
result in a context being created, a promise resolving and creating an iframe).
Invalidating the arrylist while we iterate through it is a use-after-free.

This commit stores contexts in a fixed array (64) so that it doesn't move.
Iteration is slower, unfortunately, as the new `env.context_count` has to be
checked.

Fixes WPT crash on
/html/cross-origin-embedder-policy/cross-origin-isolated-permission-iframe.https.window.html url=http://web-platform.test:8000/html/cross-origin-embedder-policy/cross-origin-isolated-permission-iframe.https.window.html

This commit also prevents microtasks execution from causing microtask execution.
On the above test, I saw runMicrotasks which I don't think we're supposed to do.
2026-02-24 19:26:43 +08:00
Pierre Tachoire
71707b5aa7 accept more performance mark name and return dummy 0 2026-02-24 09:12:18 +01:00
Karl Seguin
ca2df83928 Merge pull request #1637 from lightpanda-io/page_url_log
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Include url in page logs
2026-02-24 15:55:26 +08:00
Karl Seguin
085771c2f0 Merge pull request #1636 from lightpanda-io/null_GetOwnPropertyNames
Handle v8:Object::GetOwnPropertyNames returning null
2026-02-24 15:55:10 +08:00
Karl Seguin
607a638858 Merge pull request #1635 from lightpanda-io/frame_shared_memory
All frames must share the same Arena/Factory
2026-02-24 15:54:29 +08:00
Karl Seguin
5f6d06d05d Merge pull request #1634 from lightpanda-io/event_rc
Add [basic] reference counting to events
2026-02-24 15:53:52 +08:00
Karl Seguin
19ecb87b07 Include url in page logs
Wasn't really needed before frames and multithreading,but it's pretty essential
now to figure out what's going on. Probably needs to be applied more
broadly.
2026-02-24 15:11:20 +08:00
Karl Seguin
2a332c0883 Handle v8:Object::GetOwnPropertyNames returning null
This seems to only happen in error cases, most notably someone changes the
object to return an invalid ownKeys, as we see in WPT
/fetch/api/headers/headers-record.any.html
2026-02-24 13:31:34 +08:00
Karl Seguin
bb773c6c13 All frames must share the same Arena/Factory
This is a bit sad, but at least for now, all frames must share the same
page.arena and page.factory (they still get their own v8::Context and
call_arena).

Consider this case (from WPT /css/cssom-view/scrollingElement.html):

```
    let nonQuirksFrame = document.createElement("iframe");
    nonQuirksFrame.onload = this.step_func_done(function() {
      var nonQuirksDoc = nonQuirksFrame.contentDocument;
      nonQuirksDoc.removeChild(nonQuirksDoc.documentElement);
    });
    nonQuirksFrame.src = URL.createObjectURL(new Blob([`<!doctype html>`], { type:
"text/html" }));
    document.body.append(nonQuirksFrame);
```

We have the root page (p0) and the frame page (p1). When the frame (p1) is
created, it's [currently] given its own arena/factory and parses the page. Those
nodes are created with p1's factory.

The onload callback executes on p0, so when we call removeChild that's executing
with p0's arena/factory and tries to release memory using that factory - which
is NOT the factory that created them.

A better approach might be that _memory_ operations aren't tied to the current
calling context, but rather the owning document's page. But:
1 - That would mean we have 2 execution contexts: the v8 context where the code
    is running, and the memory context that owns the code
2 - Nodes can be disconnected, what page should we use?
3 - Some operations can behave across frames, p0 could adoptNode on p1, so we'd
    have to carefully use p1's factory when cleaning up and re-create the node
    in p0's factory.

So much hassle.

Using a shared factory/arena solves these problems at the cost of bloat - when
a frame goes away or navigates, we can't free its old memory. At some point, we
should fix that. But I don't have a quick and easy solution, and sharing the
arena/factory is _really_ quick and easy.
2026-02-24 12:57:05 +08:00
Karl Seguin
238de489c1 Add [basic] reference counting to events
Previously, we used a boolean, `_v8_handoff` to detect whether or not an event
was handed off to v8. When it _was_ handed off, then we relied on the Global
finalizer (or context shutdown) to cleanup the instance. When it wasn't handed
off, we could immediately free the instance.

The issue is that, under pressure, v8 might finalize the event _before_ we've
checked the handoff flag. This was the old code:

```zig
    const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
    defer if (!event._v8_handoff) event.deinit(false);
    try self._event_manager.dispatch(
        self.document.asEventTarget(),
        event,
    );
```

But what happens if, during the call to dispatch, v8 finalizes the event? The
defer statement will access event after its been freed.

Rather than a boolean, we now track a basic reference count. deinit decreases
the reference count, and only frees the object when it reaches 0. Any handoff
to v8 automatically increases the reference count by 1. The above code becomes
a simpler:

```zig
    const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
    defer event.deinit(false);
    try self._event_manager.dispatch(
        self.document.asEventTarget(),
        event,
    );
```

The deinit is un-conditional. The dispatch function itself increases the RC by 1,
and then the v8 handoff  increases it to 2. On v8 finalization the RC is
decreased to 1. The defer deinit decreases it to 0, at which point it is freed.

Fixes WPT /css/css-transitions/properties-value-003.html
2026-02-24 12:31:20 +08:00
Karl Seguin
6b4db330d8 Merge pull request #1629 from lightpanda-io/script_event_dispatch
Use EventManager.dispatch for Script events
2026-02-24 09:46:38 +08:00
Karl Seguin
ea5d7c0dee Use EventManager.dispatch for Script events
Should be updated and merged after:
https://github.com/lightpanda-io/browser/pull/1623 else we'll have a double-free.

The ScriptManager used to directly call the "onload" and "onerror" attributes.
The implementation predates EventManager.dispatch support attribute-based
callbacks. But now the EventManager is attribute-aware and correctly times
the attribute dispatch AND details such as cancellation. So this commit moves
the old attribute-only ScriptManager-specific callback to the EventManager.

With one little wrinkle: 'load' listeners added during a script's execution
should NOT receive a 'load' event when the script finishes. This makes no
sense to me. The EventManager now maintains an ignore_list for "load" events
which is reset after each script execution. A comptime flag is passed to
dispatch to indicate whether the ignore list should be checked. This is only
ever set when the ScriptManager dispatches the 'load' event, so there's no
overhead to dispatch for most events.
2026-02-24 08:50:04 +08:00
Karl Seguin
0f189f1af3 Merge pull request #1628 from lightpanda-io/optimize_resource_load_event
Optimize Resource "load" event
2026-02-24 08:43:00 +08:00
Karl Seguin
0f1b8dd51a Optimize Resource "load" event
An amazon product page has 345 resources and not a single DOM "load" listener.
This, I believe, is pretty common (reddit also has no "load" listener). So this
is a simple optimization to skip dispatching the resource "load" event when
there's no listener for it.

The check could be more granular, i.e. checking the specific parents of the
element. But I believe the global no "load" listener is common enough that this
simpler approach is the best.

The worst case is that we dispatch unnecessary "load" events, which is exactly
what the code was doing before.
2026-02-24 07:30:15 +08:00
Karl Seguin
d7e6946a78 Merge pull request #1632 from lightpanda-io/Response_arrayBuffer
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add Response.arrayBuffer()
2026-02-24 07:27:20 +08:00
Karl Seguin
255b7b1a54 Merge pull request #1631 from lightpanda-io/improve_tests_outside_of_runner
Improve tests when running outside of our test runner
2026-02-24 07:27:08 +08:00
Karl Seguin
79e1c751a1 Merge pull request #1630 from lightpanda-io/getpeername
Log actual client address
2026-02-24 07:26:52 +08:00
Karl Seguin
fc745b9614 Merge pull request #1627 from lightpanda-io/dom_load_no_window
Don't bubble "load" event to Window
2026-02-24 07:24:58 +08:00
Karl Seguin
95b1baebd2 Merge pull request #1626 from lightpanda-io/optimize_property_and_noop_functions
Optimizes properties and noop functions
2026-02-24 07:24:45 +08:00
Karl Seguin
56fe1ceb97 Merge pull request #1623 from lightpanda-io/finalizer_tweaks
Tweak Finalizer callbacks
2026-02-24 07:24:29 +08:00
Karl Seguin
863a51e556 Merge pull request #1614 from lightpanda-io/fix_v8_context_queue_lifetime
Improve Context shutdown
2026-02-24 07:24:18 +08:00
Karl Seguin
69b3064b45 Add Response.arrayBuffer() 2026-02-23 19:53:57 +08:00
Karl Seguin
fb3eab1aa8 Improve tests when running outside of our test runner
We often verify the correctness of tests by loading them in an external browser,
but some tests just don't run the same/correctly. For example, we used to hard-
code the http://127.0.0.1:9582/ origin, but that would cause tests to fail if
running from a different origin.

This commit _begins_ the work of improving this. It introduces a
testing.ORIGIN, testing.BASE_URL and testing.HOST which will work correctly in
both our runner and an external browser.

It also introduces `testing.IS_TEST_RUNNER` boolean flag so that tests which
have no chance of working in an external browser (e.g. screen.width) can be
skipped.

The goal is to reduce/remove tests which fail in external browsers so that such
failures aren't quickly written off as "just how it is".
2026-02-23 18:20:15 +08:00
Karl Seguin
32c7399f26 Log actual client address
We're currently logging the server address. It should clearly be the client
address.
2026-02-23 17:42:25 +08:00
Karl Seguin
955351b5bd Don't bubble "load" event to Window
As per spec:

For legacy reasons, load events for resources inside the document (e.g., images)
do not include the Window in the propagation path in HTML implementations.
2026-02-23 14:04:26 +08:00
Karl Seguin
75f6c67b6e Optimizes properties and noop functions
Define properties directly on the PrototypeTemplate, removing the need for 1
FunctionTemplate per instance-property. Also, properties were previously all
readonly. There is now a readonly flag...thus, properties which we don't care
about, but have a default value, can be defined this way.

Added a 'noop' flag to functions which skips the zig callback and does nothing.

I was initially hoping these would noticeably reduce the size of our snapshot
due to requiring fewer FunctionTemplates. While it shaves a few ~3KB, it's far
less than I was hoping. The issue is that noop function templates still need
to be unique so that `type_a.noopFunc !== type_b.noopFunc` remains true.

Still, as we add more dummy implementation, these little tweaks might add up.
2026-02-23 12:41:02 +08:00
Karl Seguin
700a3e6ed9 Merge pull request #1622 from egrs/wpt-fixes-batch-3
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
fix WPT: document.implementation identity, file input value
2026-02-23 07:01:58 +08:00
Karl Seguin
00702448c7 Merge branch 'main' into wpt-fixes-batch-3 2026-02-23 07:01:35 +08:00
Karl Seguin
5074827d51 Merge pull request #1625 from lightpanda-io/option-set-text
add Option.text setter
2026-02-23 06:56:34 +08:00
Karl Seguin
ceb0711e42 Merge pull request #1620 from lightpanda-io/offscreen_canvas
Add dummy implementation of OffscreenCanvas
2026-02-23 06:55:18 +08:00
Karl Seguin
ddb5824b58 Merge pull request #1624 from lightpanda-io/fontface
add dummy implementation of FontFaceSet
2026-02-23 06:55:03 +08:00
egrs
39f9209374 fix file input value getter/setter per spec
- getValue() returns "" for file inputs regardless of value attribute
- setValue("") is a no-op for file inputs (was throwing)
- setValue(non-empty) still throws InvalidStateError
- add _setValue bridge wrapper for [LegacyNullToEmptyString]: null → ""

Flips html/semantics/forms/the-input-element/valueMode.html (38/40 → 40/40).
2026-02-22 19:13:57 +01:00
Adrià Arrufat
5fea4cf760 mcp: add protocol and router unit tests 2026-02-22 23:15:45 +09:00
Pierre Tachoire
0e5ec86ca9 add Option.text setter 2026-02-22 15:03:12 +01:00
Pierre Tachoire
8b95211055 add dummy implementation of font face set 2026-02-22 14:59:41 +01:00
Adrià Arrufat
a27339b954 mcp: add Model Context Protocol server support
Adds a new `mcp` run mode to start an MCP server over stdio.
Implements tools for navigation and JS evaluation, along with
resources for HTML and Markdown page content.
2026-02-22 22:32:14 +09:00
Karl Seguin
028b728760 Tweak Finalizer callbacks
1 - Finalizer callbacks are now give a *Page parameter. Various types no longer
    need to maintain a reference to *Page just to finalize

2 - EventManager now handles v8_handoff == false cleanup. This is largely
    because of the above change, which would require every:

```
defer if (!event._v8_handoff) event.deinit(false);
```

to be turned into:

```
defer if (!event._v8_handoff) event.deinit(false, page);
```

But the caller might not have a page. Besides this, it makes most uses of Event
simpler. But, in some cases, it could leave a window where the event doesn't
reach the EventManager to be properly managed (though, we have no such cases
as of now).
2026-02-22 20:51:21 +08:00
Karl Seguin
18e63df01e Merge pull request #1621 from egrs/wpt-fixes-batch-2
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix WPT failures: nodeName, PI validation, willValidate, maxLength
2026-02-22 07:45:17 +08:00
egrs
5f459c0901 cache document.implementation for object identity
getImplementation() now returns a cached *DOMImplementation pointer
per Document, matching the getStyleSheets() pattern. This ensures
document.implementation === document.implementation holds true.

Flips dom/nodes/Document-implementation.html (1/2 → 2/2).
2026-02-21 14:45:59 +01:00
egrs
a90bcde38c fix WPT failures: nodeName prefix case, PI validation, willValidate, maxLength
- uppercase entire qualified name in tagName (including prefix)
- validate PI data for "?>" and use proper XML Name production with Unicode
- implement willValidate on HTMLInputElement
- throw IndexSizeError DOMException for negative maxLength assignment

flips: Node-nodeName, Document-createProcessingInstruction, button,
maxlength, input-willvalidate (+6 subtests)
2026-02-21 13:11:06 +01:00
Karl Seguin
603e7d922e Improve Context shutdown
Under some conditions, a microtask would be executed for a context that was
already deinit'd, resulting in various use-after-free.

The culprit appears to be WASM compilation being placed in the microtask queue
(by a user-script) and then resolved at some point in the future. We guard the
microtask queue by a context.shutting_down boolean, but v8 doesn't know anything
about this flag. The fact is that, microtasks are tied to an isolate, not a
context.

This commit introduces a number of changes:

1 - It follows 309f254c2c and stores the zig Context inside of an embedder field. This
    ensures v8 doesn't consider this when GC'ing, which _could_ extend the
    lifetime of the v8::Context beyond what we expect

2 - Most significantly, it introduces per-context microtasks queues. Each
    context gets its own queue. This makes cleanup much simpler and reduces the
    chance of microtasks outliving the context

3 - pumpMessageLoop is called on context.deinit, this helps to ensure that any
    tasks v8 has for our context are processed (e.g. wasm compilation) before
    shtudown

4 - The order of context shutdown is important, we notify the isolate of the
    context destruction first, then pump the message loop and finally destroy
    the context's message loop.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/151
2026-02-21 13:02:43 +08:00
Karl Seguin
861126f810 Add dummy implementation of OffscreenCanvas 2026-02-21 12:58:35 +08:00
Karl Seguin
eb9b706ebc Merge branch 'select-event'
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
2026-02-21 07:21:37 +08:00
Karl Seguin
de9cbae0b2 Merge pull request #1565 from lightpanda-io/frames
Initial support for frames
2026-02-21 07:17:51 +08:00
Karl Seguin
25e890986f Merge pull request #1619 from egrs/wpt-small-fixes
fix DocumentType.remove, MutationRecord.attributeNamespace, createElementNS casing
2026-02-21 07:17:29 +08:00
Karl Seguin
f66627dd04 Merge pull request #1618 from arrufat/markdown-simplifications
markdown: simplify rendering logic and state management
2026-02-21 07:10:56 +08:00
Karl Seguin
924eb33b3f Update src/browser/js/Env.zig
Co-authored-by: Pierre Tachoire <pierre@lightpanda.io>
2026-02-21 07:02:21 +08:00
Karl Seguin
1b288c541a Merge pull request #1616 from lightpanda-io/URL_createObjectURL
Add URL.createObjectURL and URL.revokeObjectURL
2026-02-21 07:02:01 +08:00
Karl Seguin
2612b8c86f Merge pull request #1617 from lightpanda-io/cookie_fixes
Add more cookie tests
2026-02-21 07:01:47 +08:00
Karl Seguin
3e2796d456 Merge pull request #1611 from lightpanda-io/utf_range_offsets
Get both start and end bytes in a single pass
2026-02-21 07:01:30 +08:00
Pierre Tachoire
7092913863 Merge pull request #1615 from lightpanda-io/css_escape_null
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
prorper escaping of null character
2026-02-20 15:38:54 +01:00
Pierre Tachoire
67625fc347 Merge pull request #1600 from lightpanda-io/formdata_disabled_fieldset
FormData recognizes (and skips over) disabled fieldsets
2026-02-20 15:35:03 +01:00
Pierre Tachoire
eb55030b06 Merge pull request #1584 from egrs/fix-textarea-selection-insert
fix textarea text insertion to respect selection range
2026-02-20 15:24:39 +01:00
egrs
6e1b2d50f2 fix DocumentType.remove, MutationRecord.attributeNamespace, createElementNS casing
- add ChildNode.remove() to DocumentType (flips DocumentType-remove.html)
- return null for MutationRecord.attributeNamespace on non-namespaced
  attribute mutations (flips MutationObserver-takeRecords.html)
- stop lowercasing in createElementNS per spec — only createElement
  should ASCII-lowercase for HTML namespace (flips
  Element/Document-getElementsByTagNameNS.html)
- fix getElementsByTagName to use case-insensitive matching for HTML
  namespace elements
2026-02-20 14:46:58 +01:00
Adrià Arrufat
c6f72c44b8 markdown: simplify rendering logic and state management 2026-02-20 22:04:36 +09:00
Karl Seguin
d38ded0f26 Merge pull request #1613 from egrs/lookup-namespace-uri
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
implement Node.lookupNamespaceURI() and isDefaultNamespace()
2026-02-20 20:48:05 +08:00
egrs
ec20b7bd3a implement Node.lookupNamespaceURI() and Node.isDefaultNamespace()
Implements the DOM spec algorithms for namespace lookup on all node
types. Stores custom namespace URIs in a page lookup for elements
created via createElementNS with unknown namespaces. Fixes
setAttributeNS to preserve qualified names for xmlns namespace
declarations.

Flips dom/nodes/Node-lookupNamespaceURI.html: 0/75 → 75/75.
2026-02-20 13:25:09 +01:00
Karl Seguin
0766cf464a Merge pull request #1612 from egrs/fix-childnode-sibling-ordering
fix ChildNode after() and replaceWith() sibling ordering
2026-02-20 20:24:29 +08:00
egrs
867f00e091 fix ChildNode after() and replaceWith() sibling ordering
after() captured node.nextSibling() once, which went stale when that
sibling was one of the nodes being inserted. Use viableNextSibling() to
find the first following sibling not in the nodes list per the DOM spec.

replaceWith() in CData had the same stale-reference problem and also
removed self before inserting, unlike Element.replaceWith() which keeps
self as the insertion anchor. Adopt the same anchor pattern: insert
before self, then remove self at the end.

Flips ChildNode-after.html from 33/45 to 45/45 and
ChildNode-replaceWith.html from 27/33 to 33/33.
2026-02-20 13:12:34 +01:00
Karl Seguin
c823b8d7ae Add more cookie tests
Fix trimming and incorrect early loop termination when  expiring cookies.
2026-02-20 20:03:18 +08:00
Karl Seguin
393d4d336c Add URL.createObjectURL and URL.revokeObjectURL 2026-02-20 19:34:57 +08:00
Karl Seguin
2cb3f2d03d prorper escaping of null character 2026-02-20 18:44:54 +08:00
Karl Seguin
279f2dd633 Merge pull request #1599 from lightpanda-io/input_sanitize_ownership
Improve and fix sanitized value ownership.
2026-02-20 18:40:51 +08:00
Karl Seguin
dec051a6e0 Merge pull request #1603 from egrs/wpt-spec-guards
spec compliance: missing validation guards
2026-02-20 15:33:06 +08:00
Karl Seguin
790fdd320c Merge pull request #1610 from lightpanda-io/add_js_nullablestring
Add js.NullableString
2026-02-20 15:30:15 +08:00
Karl Seguin
feb4a364a7 Merge pull request #1608 from egrs/null-domstring-constants
add DOMException legacy error code constants
2026-02-20 15:30:01 +08:00
egrs
1140149e1e add dom_exception flag to Element.replaceChildren 2026-02-20 08:22:29 +01:00
egrs
2ee9599b6e add DOMException legacy error code constants
Add all 25 legacy constants (INDEX_SIZE_ERR through DATA_CLONE_ERR)
to DOMException on both constructor and prototype, enabling WPT
assert_throws_dom checks that reference e.code.
2026-02-20 08:13:23 +01:00
Karl Seguin
188d45e002 Get both start and end bytes in a single pass
Follow up to https://github.com/lightpanda-io/browser/pull/1605 to calculate
both start and end bytes in a single pass.
2026-02-20 10:14:47 +08:00
Karl Seguin
7c4c2f7860 Merge pull request #1605 from egrs/wpt-chardata-utf16
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix CharacterData methods to use UTF-16 code unit offsets
2026-02-20 09:35:26 +08:00
Karl Seguin
90b7f2ff3b Improve and fix sanitized value ownership.
1 - Fix an issue where build would persist a value in the call_arena
2 - Remove double allocation (call_arena -> page_arena)
3 - Improve ergonomics of sanitizeValue with a comptime value indicating whether
    or not to always dupe the value.
2026-02-20 09:30:44 +08:00
Karl Seguin
d3f0041e93 Merge pull request #1607 from arrufat/markdown-anchors
markdown: handle block-level and standalone anchors
2026-02-20 08:59:41 +08:00
Karl Seguin
9d60142828 Add js.NullableString
When a WebAPI takes `[]const u8`, we coerce values to strings. But when it
takes a `?[]const u8` how should we handle `null`?  Some APIs might want to know
that it was null, others might just want `"null``.

Currently when `null` is passed to `?[]const u8`, we'll get null.

This adds a discriminator type, js.NullableString. When `null` is passed to it
it'll be converted to `"null"`.
2026-02-20 07:24:43 +08:00
Adrià Arrufat
68d5edca60 markdown: use node.is() for type checking and casting 2026-02-20 08:14:15 +09:00
Karl Seguin
1b369489df Merge pull request #1602 from lightpanda-io/css-delcaration
parse style attribute on CSSStyleDeclaration init
2026-02-20 06:57:58 +08:00
Karl Seguin
600ddfbf2d Merge pull request #1587 from lightpanda-io/label_control
Add HTMLLabelElement.control getter
2026-02-20 06:56:37 +08:00
Karl Seguin
415d4dde2a Merge pull request #1606 from lightpanda-io/form_selectors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
More pseudo-seletors
2026-02-19 23:49:35 +08:00
Pierre Tachoire
1867245ed3 Merge pull request #1598 from egrs/input-value-sanitization
input value sanitization per WHATWG spec
2026-02-19 16:48:56 +01:00
Karl Seguin
71d34592d9 add frame created cdp messages 2026-02-19 23:47:33 +08:00
Karl Seguin
db2927eea7 cleanup a not-so-great rebase 2026-02-19 23:47:33 +08:00
Karl Seguin
bb01a5cb31 Make CDP frame-aware 2026-02-19 23:47:33 +08:00
Karl Seguin
815319140f cleanupany incomplete scheduled_navigation on renavigate or page.deinit 2026-02-19 23:47:33 +08:00
Karl Seguin
6e6082119f Remove session.transfer_arena
This no longer works with frames. Multiple frames could have a scheduled
navigation, so a single arena no longer has a clear lifecycle. Instead an arena
from the pool is used per navigation event, thus the queued_navigation is self-
contained.

This required having libcurl copy the body. Unfortunate. Currently we free the
arena as soon as the navigation begins. This is clean. But it means the body is
immediately freed (thus we need libcurl to copy it). As an alternative, each
page could maintain an optional transfer_arena, which it could free on
httpDone/Error.
2026-02-19 23:47:33 +08:00
Karl Seguin
da48ffe05c Move page.wait to session.wait
page.wait is the only significant difference between the "root" page and a page
for an iframe. I think it's more explicit to move this out of the page and
into the session, which was already the sole entry-point for page.wait.
2026-02-19 23:47:33 +08:00
Karl Seguin
081979be3b Initial support for frames
Missing:

- [ ] Navigation support within frames (in fact, as-is, any navigation done
      inside a frame, will almost certainly break things
- [ ] Correct CDP support. I don't know how frames are supposed to be exposed
      to CDP. Normal navigate events? Distinct CDP frame_ids?
- [ ] Cross-origin restrictions. The interaction between frames is supposed to
      change depending on whether or not they're on the same origin
- [ ] Potentially handling src-less frames incorrectly. Might not really matter

Adds basic frame support. Initially explored adding a BrowsingContext and
embedding it in Page, with the goal of also having it embedded in a to-be
created Frame. But it turns out that 98% of Page _was_ BrowsingContext and
introducing a BrowsingContext as the primary interaction unit broke pretty much
_every_ single WebAPI. So Page was expanded:

- Added `_parent: ?*Page`, which is `null` for "root" page.
- Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the
  HTMLIFrameElement for frame-pages.
- Added a _type: enum{root, frame}, which is currently only used to improve
  the logs
- Added a frames: std.ArrayList(*Page). This is a list of frames for the page.
  Note that a "frame-page" can itself haven nested frames.

Besides the above, there were 3 "big" changes.

1 - Adding frames (dynamically, parsed) has to create a new page, start
    navigation, track it (in the frames list). Part of this was just
    piggybacking off of code that handles <script>

2 - The page "load" event blocks on the frame "load" event. This cascades.
    when a page triggers it's load, it can do:
```zig
      if (self._parent) |p| {
        p.iframeLoaded(self);
      }
```
   Pages need to keep track of how many iframes they're waiting to load. When
   all iframes (and all scripts) are loaded, it can then triggers its own load
   event.

3 - Our JS execution expects 1 primary entered context (the pages). But we now
    have multiple page contexts, and we need to be in the correct one based
    on where javascript is being executed. There is no more an default entered
    context. Creating a Local.Scope enters the context, and ls.deinit() exits
    the context.
2026-02-19 23:47:33 +08:00
egrs
3673956c1c add pure zig tests for utf16Len and utf16OffsetToUtf8 2026-02-19 16:29:49 +01:00
egrs
bdd3c274ed address review: arena param + pure zig tests
- sanitizeDatetimeLocal takes arena: Allocator instead of *Page
- add unit tests for isValidFloatingPoint, isValidDate, isValidMonth,
  isValidWeek, isValidTime, sanitizeDatetimeLocal, parseAllDigits,
  daysInMonth, maxWeeksInYear
2026-02-19 16:22:13 +01:00
Adrià Arrufat
423034d5c4 markdown: handle block-level and standalone anchors in
Adds logic to detect if an anchor contains block descendants or is a
standalone element within a layout block. These are now rendered with
appropriate spacing and link formatting. Also adds `.main` to the list
of block elements.
2026-02-20 00:11:38 +09:00
Pierre Tachoire
19fd2b12c0 Update src/browser/webapi/css/CSSStyleDeclaration.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 15:29:36 +01:00
Karl Seguin
21cd17873f More pseudo-seletors
:invalid, :valid and :indeterminate implementation

Fix Element.closest with :scope pseudo-selector.
2026-02-19 22:28:14 +08:00
egrs
9870fa9e34 fix CharacterData methods to use UTF-16 code unit offsets
The DOM spec requires CharacterData offset/length to count UTF-16 code
units, not UTF-8 bytes or Unicode codepoints. Multi-byte characters
(CJK = 3 bytes UTF-8 / 1 code unit, emoji = 4 bytes / 2 code units)
were getting mangled.

- Add utf16Len/utf16OffsetToUtf8 helpers for UTF-8 ↔ UTF-16 conversion
- Fix .length to count UTF-16 code units instead of codepoints
- Fix substringData, deleteData, insertData, replaceData, splitText
- Fix data setter: null → "" (LegacyNullToEmptyString), undefined → "undefined"

Flips 6 WPT files (+134 subtests), 0 regressions.
2026-02-19 15:18:28 +01:00
Karl Seguin
938cd5e136 Merge pull request #1582 from lightpanda-io/cdp_per_page_frame_id
Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
2026-02-19 22:16:52 +08:00
Karl Seguin
e8025ad4b3 Merge pull request #1592 from lightpanda-io/element_render_property_optimization
Reduce cost of various Element render-related properties.
2026-02-19 22:16:17 +08:00
Karl Seguin
07fa141aaa Merge pull request #1593 from lightpanda-io/focus_noop_disconnected
make element.focus()  noop when element is disconnected
2026-02-19 22:16:04 +08:00
Pierre Tachoire
18bdf1e8b3 Merge pull request #1594 from lightpanda-io/fix_flaky_scroll_test
Fix flaky window.scrollTo test
2026-02-19 14:58:28 +01:00
Pierre Tachoire
5be977005e avoid useless priority parsing in CSSStyleDeclaration 2026-02-19 14:49:49 +01:00
Karl Seguin
282b64278e Merge pull request #1601 from lightpanda-io/animation-cancel
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
implement Animation.cancel()
2026-02-19 21:37:08 +08:00
Pierre Tachoire
7263d484de Update src/browser/webapi/css/CSSStyleDeclaration.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 14:36:02 +01:00
egrs
bdb059b6c9 spec compliance: missing validation guards
- Event.preventDefault() and returnValue respect cancelable=false
- MutationObserver.observe() validates options per spec
- detached checkbox/radio click suppresses input/change events
- doctype insertion into non-document throws HierarchyRequestError
- error.TypeError maps to JS TypeError (not generic Error)
- enable dom_exception on Element/DocumentFragment mutation methods
2026-02-19 14:23:11 +01:00
Pierre Tachoire
de3f5011bc parse style attribute on CSSStyleDeclaration init
To reflect the current style attribute, CSSStyleDeclaration now parses
it on init.

Moreover, this PR synchronizes the element's style attribute with the
dynamic changes.
2026-02-19 12:26:04 +01:00
Pierre Tachoire
de9faffa33 implement Animation.cancel() 2026-02-19 12:12:59 +01:00
Karl Seguin
f67ca69e05 FormData recognizes (and skips over) disabled fieldsets 2026-02-19 18:35:23 +08:00
egrs
dd19e880c5 merge main, resolve comment conflicts in Input.zig 2026-02-19 10:16:34 +00:00
egrs
b5e8fa007c input value sanitization per WHATWG spec
- number: validate against spec grammar (reject "+1", "1.", "Infinity",
  "NaN", overflow values like "2e308")
- date: validate YYYY-MM-DD with day-of-month and leap year checks
- time: validate HH:MM[:SS[.sss]]
- month: validate YYYY-MM
- week: validate YYYY-Www with ISO 8601 week count
- datetime-local: validate and normalize (T separator, shortest time)
- color: normalize hex to lowercase per spec
- checkbox/radio: return "on" as default value
- sanitize initial value from HTML attributes in created()
2026-02-19 11:02:03 +01:00
Karl Seguin
c3555bfcab Merge pull request #1596 from lightpanda-io/animation-improve
Improve Animation support: async update from idle => running => finished
2026-02-19 18:01:17 +08:00
Karl Seguin
0383db8788 Merge pull request #1595 from egrs/wpt-value-fixes
fix input value defaults, color normalization, and event propagation resets
2026-02-19 17:56:18 +08:00
Karl Seguin
d7af122c18 Merge pull request #1564 from lightpanda-io/nikneym/create-image-data
Add `createImageData` and `putImageData` to `CanvasRenderingContext2D`
2026-02-19 17:56:02 +08:00
Pierre Tachoire
e15b8145b1 create Animation in the pool arena 2026-02-19 10:50:12 +01:00
Pierre Tachoire
d75f5f9231 don't play animation when startTime is set to null 2026-02-19 10:41:07 +01:00
Pierre Tachoire
9939797792 fix comment 2026-02-19 10:36:29 +01:00
Pierre Tachoire
5248b9fc6f Update src/browser/webapi/animation/Animation.zig
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-02-19 10:35:46 +01:00
Halil Durak
96cfdebced ImageData#constructor: check bounds of dimensions + don't overflow size
Also adds a related `too-large` test.
2026-02-19 12:09:24 +03:00
Halil Durak
944f34b833 createImageData: remove unnecessary unreachable 2026-02-19 12:09:24 +03:00
Halil Durak
1023b2ca9c test blocks need at least a single assertion 2026-02-19 12:09:24 +03:00
Halil Durak
16318bb9f6 add tests 2026-02-19 12:09:24 +03:00
Halil Durak
350586335d add createImageData and putImageData to CanvasReneringContext2D 2026-02-19 12:09:23 +03:00
egrs
9d809499a5 fix input value defaults, color normalization, and event propagation resets
- checkbox/radio getValue() returns "on" when no value attribute set
- color input sanitization normalizes hex to lowercase per spec
- initial input value is sanitized per input type during element creation
- initEvent resets both stop_propagation and stop_immediate_propagation
- dispatchNode resets propagation flags after dispatch per DOM spec step 12
2026-02-19 09:53:13 +01:00
Pierre Tachoire
1461d029db Improve Animation support: async update from idle => running => finished 2026-02-19 09:50:16 +01:00
Karl Seguin
e37d4a6756 Fix flaky window.scrollTo test
The test is sensitive to any delay in running the schedule task exactly when
it's scheduled. Testing this feature isn't worth making the build flaky.
2026-02-19 14:15:19 +08:00
Karl Seguin
e2a1ce623c Rework CDP frameIds (and loaderIds and requestIds and interceptorIds)
Our BrowsingContext currently supports 1 target. So we have a per-BC target_id.
Previously, our target had 1 "frame" - our page. So we often treated the
targetId as the frameId. But to work with frames, we need page-specific
frameIds and loaderIds.

This tries to clean up our ids (a little). frameIds are now ids derived from
a new incrementing page.id. This page.id has to be passed around (via http
Requests and through notifications) in order to properly generate messages with
a frameId.
2026-02-19 13:01:41 +08:00
Karl Seguin
0ff243266c make element.focus() noop when element is disconnected 2026-02-19 10:35:11 +08:00
Karl Seguin
645da2e307 Reduce cost of various Element render-related properties.
Added a get-only `getStyle` which doesn't lazily create a new style if none
exists. This can be used in the (frequently used) `checkVisibility` to avoid
an allocation. Added a specialized getBoundingClientRectForVisible which
skips the checkVisibility check, since a few callers have already done their
own visibility check.

DOMRect is now off the heap. This avoids _a lot_ of allocation when a DOMRect
is only needed for internal calculation, e.g. in Document.elementFromPoint.
2026-02-19 09:45:56 +08:00
egrs
84ffffb3f3 dispatch select event from input.select() and textarea.select() 2026-02-18 15:31:52 +01:00
Muki Kiboigo
90138ed574 use applyModify generally 2026-02-18 06:07:06 -08:00
Karl Seguin
338580087e Add HTMLLabelElement.control getter 2026-02-18 21:59:15 +08:00
egrs
37ecd5cdef use textarea.innerInsert for selection-aware text insertion
Both insertText() and handleKeydown() were manually concatenating text
to the textarea value, ignoring the current selection range. This uses
the existing innerInsert() method which correctly handles full, partial,
and no-selection states — matching the Input element behavior.
2026-02-18 13:47:09 +01:00
Muki Kiboigo
c4391ff058 refactor modifyBy implementations 2026-02-17 12:11:29 -08:00
Muki Kiboigo
3822e3f8d9 pass selection/modify-extend-word-trailing-inline-block.tentative.html 2026-02-17 12:02:34 -08:00
Muki Kiboigo
f8f99f3878 pass selection/modify.tentative.html 2026-02-17 11:58:13 -08:00
Muki Kiboigo
e77e4acea9 add tests for Selection.modify 2026-02-17 11:50:38 -08:00
Muki Kiboigo
c6de444d0b add support for Selection.modify 2026-02-17 11:50:30 -08:00
339 changed files with 25564 additions and 7694 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.2.9'
default: 'v0.3.4'
v8:
description: 'v8 version to install'
required: false
@@ -46,7 +46,7 @@ runs:
- name: Cache v8
id: cache-v8
uses: actions/cache@v4
uses: actions/cache@v5
env:
cache-name: cache-v8
with:

View File

@@ -20,11 +20,9 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
@@ -32,7 +30,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
@@ -47,7 +45,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -55,7 +53,7 @@ jobs:
- run: npm install
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -63,6 +61,6 @@ jobs:
- name: run end to end integration tests
run: |
./lightpanda serve & echo $! > LPD.pid
./lightpanda serve --log_level error & echo $! > LPD.pid
go run integration/main.go
kill `cat LPD.pid`

View File

@@ -9,15 +9,13 @@ env:
on:
push:
branches:
- main
branches: [main]
paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
- "src/**"
- "build.zig"
- "build.zig.zon"
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
@@ -29,12 +27,10 @@ on:
paths:
- ".github/**"
- "src/**"
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
- "build.zig.zon"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -52,8 +48,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
@@ -61,7 +55,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
@@ -76,7 +70,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -84,7 +78,7 @@ jobs:
- run: npm install
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -117,6 +111,107 @@ jobs:
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id`
# e2e tests w/ web-bot-auth configuration on.
wba-demo-scripts:
name: wba-demo-scripts
needs: zig-build-release
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: npm install
- name: download artifact
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
- name: run end to end tests
run: |
./lightpanda serve \
--web_bot_auth_key_file private_key.pem \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \
& echo $! > LPD.pid
go run runner/main.go
kill `cat LPD.pid`
- name: build proxy
run: |
cd proxy
go build
- name: run end to end tests through proxy
run: |
./proxy/proxy & echo $! > PROXY.id
./lightpanda serve \
--web_bot_auth_key_file private_key.pem \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \
--http_proxy 'http://127.0.0.1:3000' \
& echo $! > LPD.pid
go run runner/main.go
kill `cat LPD.pid` `cat PROXY.id`
- name: run request interception through proxy
run: |
export PROXY_USERNAME=username PROXY_PASSWORD=password
./proxy/proxy & echo $! > PROXY.id
./lightpanda serve & echo $! > LPD.pid
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
kill `cat LPD.pid` `cat PROXY.id`
wba-test:
name: wba-test
needs: zig-build-release
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- name: download artifact
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
# force a wakup of the auth server before requesting it w/ the test itself
- run: curl https://${{ vars.WBA_DOMAIN }}
- name: run wba test
shell: bash
run: |
node webbotauth/validator.js &
VALIDATOR_PID=$!
sleep 5
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
./lightpanda fetch --dump http://127.0.0.1:8989/ \
--web_bot_auth_key_file /proc/self/fd/3 \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
wait $VALIDATOR_PID
exec 3>&-
cdp-and-hyperfine-bench:
name: cdp-and-hyperfine-bench
needs: zig-build-release
@@ -125,7 +220,6 @@ jobs:
MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools
@@ -140,7 +234,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -148,7 +242,7 @@ jobs:
- run: npm install
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -234,7 +328,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: bench-results
path: |
@@ -257,12 +351,12 @@ jobs:
container:
image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: bench-results
@@ -280,7 +374,7 @@ jobs:
steps:
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release

View File

@@ -5,7 +5,9 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
on:
push:
@@ -33,8 +35,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -72,11 +72,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
@@ -87,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -116,11 +114,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
@@ -131,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -158,11 +154,9 @@ jobs:
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
@@ -173,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

@@ -5,43 +5,126 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on:
schedule:
- cron: "23 2 * * *"
- cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
wpt:
name: web platform tests json output
wpt-build-release:
name: zig build release
runs-on: ubuntu-latest
timeout-minutes: 90
env:
ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
- name: build wpt
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build release
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
zig-out/bin/lightpanda
retention-days: 1
wpt-build-runner:
name: build wpt runner
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptrunner
CGO_ENABLED=0 go build
- name: upload artifact
uses: actions/upload-artifact@v7
with:
name: wptrunner
path: |
wptrunner/wptrunner
retention-days: 1
run-wpt:
name: web platform tests json output
needs:
- wpt-build-release
- wpt-build-runner
# use a self host runner.
runs-on: lpd-wpt-aws
timeout-minutes: 600
steps:
- uses: actions/checkout@v6
with:
ref: fork
repository: 'lightpanda-io/wpt'
fetch-depth: 0
# The hosts are configured manually on the self host runner.
# - name: create custom hosts
# run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
- name: generate manifest
run: ./wpt manifest
- name: download lightpanda release
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
uses: actions/download-artifact@v8
with:
name: wptrunner
- run: chmod a+x ./wptrunner
- name: run test with json output
run: zig-out/bin/lightpanda-wpt --json > wpt.json
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 20s
./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid`
- name: write commit
run: |
echo "${{github.sha}}" > commit.txt
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: wpt-results
path: |
@@ -51,7 +134,7 @@ jobs:
perf-fmt:
name: perf-fmt
needs: wpt
needs: run-wpt
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -64,7 +147,7 @@ jobs:
steps:
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: wpt-results

View File

@@ -1,60 +0,0 @@
name: zig-fmt
on:
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# Since we skip the job when the PR is in draft state, we want to force CI
# running when the PR is marked ready_for_review w/o other change.
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
types: [opened, synchronize, reopened, ready_for_review]
paths:
- ".github/**"
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
zig-fmt:
name: zig fmt
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# Zig version used from the `minimum_zig_version` field in build.zig.zon
- uses: mlugg/setup-zig@v2
- name: Run zig fmt
id: fmt
run: |
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
delimiter="$(openssl rand -hex 8)"
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
if [ -s zig-fmt.err ]; then
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
fi
if [ -s zig-fmt.err2 ]; then
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
fi
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
- name: Fail the job
if: steps.fmt.outputs.zig_fmt_errs != ''
run: exit 1

View File

@@ -5,19 +5,18 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on:
push:
branches:
- main
branches: [main]
paths:
- "build.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**"
- "vendor/**"
pull_request:
- "src/**"
- "build.zig"
- "build.zig.zon"
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# Since we skip the job when the PR is in draft state, we want to force CI
@@ -27,28 +26,63 @@ on:
paths:
- ".github/**"
- "src/**"
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
- "build.zig.zon"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
zig-test-debug:
name: zig test using v8 in debug mode
timeout-minutes: 15
zig-fmt:
name: zig fmt
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# Zig version used from the `minimum_zig_version` field in build.zig.zon
- uses: mlugg/setup-zig@v2
- name: Run zig fmt
id: fmt
run: |
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
delimiter="$(openssl rand -hex 8)"
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
if [ -s zig-fmt.err ]; then
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
fi
if [ -s zig-fmt.err2 ]; then
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
fi
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
- name: Fail the job
if: steps.fmt.outputs.zig_fmt_errs != ''
run: exit 1
zig-test-debug:
name: zig test using v8 in debug mode
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
with:
@@ -57,21 +91,18 @@ jobs:
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test:
zig-test-release:
name: zig test
timeout-minutes: 15
# Don't run the CI with draft PR.
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install
@@ -83,7 +114,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: bench-results
path: |
@@ -93,23 +124,22 @@ jobs:
bench-fmt:
name: perf-fmt
needs: zig-test
# Don't execute on PR
if: github.event_name != 'pull_request'
needs: zig-test-release
runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event_name != 'pull_request'
container:
image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: bench-results

5
.gitignore vendored
View File

@@ -1,11 +1,6 @@
zig-cache
/.zig-cache/
/.lp-cache/
zig-out
/vendor/netsurf/out
/vendor/libiconv/
lightpanda.id
/v8/
/build/
/src/html5ever/target/
src/snapshot.bin

15
.gitmodules vendored
View File

@@ -1,15 +0,0 @@
[submodule "tests/wpt"]
path = tests/wpt
url = https://github.com/lightpanda-io/wpt
[submodule "vendor/nghttp2"]
path = vendor/nghttp2
url = https://github.com/nghttp2/nghttp2.git
[submodule "vendor/zlib"]
path = vendor/zlib
url = https://github.com/madler/zlib.git
[submodule "vendor/curl"]
path = vendor/curl
url = https://github.com/curl/curl.git
[submodule "vendor/brotli"]
path = vendor/brotli
url = https://github.com/google/brotli

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.2.9
ARG ZIG_V8=v0.3.4
ARG TARGETPLATFORM
RUN apt-get update -yq && \
@@ -36,10 +36,6 @@ RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
# install deps
RUN git submodule init && \
git submodule update --recursive
# download and install v8
RUN case $TARGETPLATFORM in \
"linux/arm64") ARCH="aarch64" ;; \

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands
# ------------
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
## Build v8 snapshot
build-v8-snapshot:
@@ -77,20 +77,6 @@ run-debug: build-dev
@printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
## Run a JS shell in debug mode
shell:
@printf "\033[36mBuilding shell...\033[0m\n"
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Run WPT tests
wpt:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
wpt-summary:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos)
test:
@@ -111,13 +97,7 @@ end2end:
# ------------
.PHONY: install
## Install and build dependencies for release
install: install-submodule
install: build
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
## Init and update git submodule
install-submodule:
@git submodule init && \
git submodule update

383
README.md
View File

@@ -1,30 +1,22 @@
<p align="center">
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
</p>
<h1 align="center">Lightpanda Browser</h1>
<p align="center">
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
</p>
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p>
</div>
<div align="center">
[![License](https://img.shields.io/github/license/lightpanda-io/browser)](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
[![Twitter Follow](https://img.shields.io/twitter/follow/lightpanda_io)](https://twitter.com/lightpanda_io)
[![GitHub stars](https://img.shields.io/github/stars/lightpanda-io/browser)](https://github.com/lightpanda-io/browser)
[![Discord](https://img.shields.io/discord/1391984864894521354?style=flat-square&label=discord)](https://discord.gg/K63XeymfB5)
</div>
Lightpanda is the open-source browser made for headless usage:
- Javascript execution
- Support of Web APIs (partial, WIP)
- Compatible with Playwright[^1], Puppeteer, chromedp through [CDP](https://chromedevtools.github.io/devtools-protocol/)
Fast web automation for AI agents, LLM training, scraping and testing:
- Ultra-low memory footprint (9x less than Chrome)
- Exceptionally fast execution (11x faster than Chrome)
- Instant startup
<div align="center">
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
](https://github.com/lightpanda-io/demo)
@@ -33,11 +25,37 @@ Fast web automation for AI agents, LLM training, scraping and testing:
](https://github.com/lightpanda-io/demo)
</div>
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
See [benchmark details](https://github.com/lightpanda-io/demo)._
## Table of Contents
[^1]: **Playwright support disclaimer:**
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
- [Benchmarks](#benchmarks)
- [Quick Start](#quick-start)
- [Install](#install)
- [Dump a URL](#dump-a-url)
- [Start a CDP Server](#start-a-cdp-server)
- [Telemetry](#telemetry)
- [Lightpanda vs Headless Chrome](#lightpanda-vs-headless-chrome)
- [What Lightpanda supports today](#what-lightpanda-supports-today)
- [Use Cases](#use-cases)
- [Architecture](#architecture)
- [Why Lightpanda?](#why-lightpanda)
- [Build from Source](#build-from-source)
- [Test](#test)
- [Contributing](#contributing)
- [Compatibility Note](#compatibility-note)
- [FAQ](#faq)
---
## Benchmarks
_Puppeteer requesting 100 pages from a local website on an AWS EC2 m5.large instance. See [benchmark details](https://github.com/lightpanda-io/demo)._
| Metric | Lightpanda | Headless Chrome | Difference |
| :---- | :---- | :---- | :---- |
| Memory (peak, 100 pages) | 24 MB | 207 MB | ~9x less |
| Execution time (100 pages) | 2.3s | 25.2s | ~11x faster |
---
## Quick start
@@ -80,6 +98,10 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
```console
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
```
<details>
<summary>Example output</summary>
```console
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
@@ -110,11 +132,17 @@ INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
<!DOCTYPE html>
```
</details>
### Start a CDP server
```console
./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222
```
<details>
<summary>Example output</summary>
```console
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
disabled = false
@@ -123,9 +151,14 @@ INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
address = 127.0.0.1:9222
```
</details>
Once the CDP server started, you can run a Puppeteer script by configuring the
`browserWSEndpoint`.
<details>
<summary>Example Puppeteer script</summary>
```js
'use strict'
@@ -156,52 +189,114 @@ await context.close();
await browser.disconnect();
```
</details>
### Telemetry
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable `LIGHTPANDA_DISABLE_TELEMETRY=true`. You can read Lightpanda's privacy policy at: [https://lightpanda.io/privacy-policy](https://lightpanda.io/privacy-policy).
## Status
## Lightpanda vs Headless Chrome
Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
You may still encounter errors or crashes. Please open an issue with specifics if so.
Lightpanda is not a general-purpose browser. It is built specifically for headless workloads.
Here are the key features we have implemented:
**Use Lightpanda when you need:**
- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
- [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
- [x] DOM tree
- [x] Javascript support ([v8](https://v8.dev/))
- [x] DOM APIs
- [x] Ajax
- [x] XHR API
- [x] Fetch API
- [x] DOM dump
- [x] CDP/websockets server
- [x] Click
- [x] Input form
- [x] Cookies
- [x] Custom HTTP headers
- [x] Proxy support
- [x] Network interception
- [x] Respect `robots.txt` with option `--obey_robots`
- Low-memory scraping or data extraction at scale
- AI agent browsing (via MCP or CDP)
- Fast CI test runs against a headless browser
- Markdown/text extraction from JS-rendered pages
- Minimal footprint: single binary, no Chromium install
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
**Use Headless Chrome when you need:**
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
- Full visual rendering, screenshots, or PDFs
- WebGL or advanced CSS layout
- Complete Web API coverage (Canvas, WebRTC, etc.)
- Pixel-perfect visual testing
## Build from sources
### What Lightpanda supports today
- HTTP loader ([Libcurl](https://curl.se/libcurl/))
- HTML parser ([html5ever](https://github.com/servo/html5ever))
- DOM tree + DOM APIs
- Javascript ([v8](https://v8.dev/))
- Ajax (XHR + Fetch)
- CDP/WebSocket server
- Click, input/form, cookies
- Custom HTTP headers
- Proxy support
- Network interception
- robots.txt compliance (`--obey_robots`)
**Note:** There are hundreds of Web APIs. Coverage increases with each release. If you hit a gap, [open an issue](https://github.com/lightpanda-io/browser/issues).
## Use Cases
### AI Agents and LLM Tools
Give your AI agent a real browser that is fast and cheap to run. Lightpanda Cloud exposes an MCP endpoint at `cloud.lightpanda.io/mcp/sse` with tools for search, goto, markdown, and links. Works with Claude, Cursor, Windsurf, or any CDP-based agent framework.
- [agent-skill repo](https://github.com/lightpanda-io/agent-skill)
### Web Scraping and Data Extraction
Lightpanda uses 9x less memory than Chrome. It works with Crawlee, Puppeteer, and Playwright.
```console
lightpanda fetch --dump markdown --obey_robots https://example.com
```
### Automated Testing
Drop-in replacement for headless Chrome in CI pipelines. If your tests use Puppeteer or Playwright, change the connection URL to `ws://127.0.0.1:9222` and run them.
### LLM Training Data Collection
Use `--dump markdown` to extract clean text from JS-rendered pages at volume.
---
## Architecture
![Architecture Diagram](architecture-diagram-highres.png)
The client connects over CDP via WebSocket. The server parses HTML into a DOM tree, applies CSS, and executes JavaScript through V8. Page content is returned to the client as HTML, markdown, or structured data depending on the request.
---
## Why Lightpanda?
### Javascript execution is mandatory for the modern web
Simple HTTP requests used to be enough for scraping. That's no longer the case. Javascript now drives most of the web:
- Ajax, Single Page Apps, infinite loading, instant search
- JS frameworks: React, Vue, Angular, and others
### Chrome is not the right tool
Running a full desktop browser on a server works, but it does not scale well. Chrome at hundreds or thousands of instances is expensive:
- Heavy on RAM and CPU
- Hard to package, deploy, and maintain at scale
- Many features are irrelevant in headless usage
### Lightpanda is built for performance
Supporting Javascript with real performance meant building from scratch rather than forking Chromium:
- Not based on Chromium, Blink, or WebKit
- Written in Zig, a low-level language with explicit memory control
- No graphical rendering engine
---
## Build from Source
### Prerequisites
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
install it with the right version in order to build the project.
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2` and depends on: [v8](https://chromium.googlesource.com/v8/v8.git), [Libcurl](https://curl.se/libcurl/), [html5ever](https://github.com/servo/html5ever).
Lightpanda also depends on
[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
For **Debian/Ubuntu based Linux**:
**Debian/Ubuntu:**
```
sudo apt install xz-utils ca-certificates \
@@ -210,67 +305,60 @@ sudo apt install xz-utils ca-certificates \
```
You also need to [install Rust](https://rust-lang.org/tools/install/).
For systems with [**Nix**](https://nixos.org/download/), you can use the devShell:
**Nix:**
```
nix develop
```
For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
**macOS:**
```
brew install cmake
```
### Install Git submodules
The project uses git submodules for dependencies.
To init or update the submodules in the `vendor/` directory:
```
make install-submodule
```
This is an alias for `git submodule init && git submodule update`.
You also need [Rust](https://rust-lang.org/tools/install/).
### Build and run
You an build the entire browser with `make build` or `make build-dev` for debug
env.
Build the browser:
But you can directly use the zig command: `zig build run`.
```
make build # release
make build-dev # debug
```
Or directly: `zig build run`.
#### Embed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot:
Generate the snapshot.
```
zig build snapshot_creator -- src/snapshot.bin
```
Build using the snapshot binary.
Build using the snapshot:
```
zig build -Dsnapshot_path=../../snapshot.bin
```
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details.
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for details.
---
## Test
### Unit Tests
You can test Lightpanda by running `make test`.
```
make test
```
### End to end tests
### End to End Tests
To run end to end tests, you need to clone the [demo
repository](https://github.com/lightpanda-io/demo) into `../demo` dir.
You have to install the [demo's node
requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1)
You also need to install [Go](https://go.dev) > v1.24.
Clone the [demo repository](https://github.com/lightpanda-io/demo) into `../demo`. Install the [demo's node requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1) and [Go](https://go.dev) >= v1.24.
```
make end2end
@@ -278,67 +366,124 @@ make end2end
### Web Platform Tests
Lightpanda is tested against the standardized [Web Platform
Tests](https://web-platform-tests.org/).
Lightpanda is tested against the standardized [Web Platform Tests](https://web-platform-tests.org/) using [a fork](https://github.com/lightpanda-io/wpt/tree/fork) with a custom [`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command.
You can also run individual WPT test cases in your browser via [wpt.live](https://wpt.live).
All the tests cases executed are located in the `tests/wpt` sub-directory.
For reference, you can easily execute a WPT test case with your browser via
[wpt.live](https://wpt.live).
#### Run WPT test suite
To run all the tests:
**Setup WPT HTTP server:**
```
make wpt
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
cd wpt
./wpt make-hosts-file | sudo tee -a /etc/hosts
./wpt manifest
```
Or one specific test:
See the [WPT setup guide](https://web-platform-tests.org/running-tests/from-local-system.html) for details.
**Run WPT tests:**
Start the WPT HTTP server:
```
make wpt Node-childNodes.html
./wpt serve
```
#### Add a new WPT test case
Run Lightpanda:
We add new relevant tests cases files when we implemented changes in Lightpanda.
```
zig build run -- --insecure_disable_tls_host_verification
```
To add a new test, copy the file you want from the [WPT
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory.
Run the test suite (from [demo](https://github.com/lightpanda-io/demo/) clone):
:warning: Please keep the original directory tree structure of `tests/wpt`.
```
cd wptrunner && go run .
```
Run a specific test:
```
cd wptrunner && go run . Node-childNodes.html
```
Options: `--summary`, `--json`, `--concurrency`.
**Note:** The full suite takes a long time. Build with `zig build -Doptimize=ReleaseFast run` for faster test execution.
---
## Contributing
Lightpanda accepts pull requests through GitHub.
See [CONTRIBUTING.md](https://github.com/lightpanda-io/browser/blob/main/CONTRIBUTING.md) for guidelines.
You have to sign our [CLA](CLA.md) during the pull request process otherwise
we're not able to accept your contributions.
You must sign our [CLA](CLA.md) during the pull request process.
## Why?
- [Good first issues](https://github.com/lightpanda-io/browser/labels/good%20first%20issue)
- [Discord](https://discord.gg/K63XeymfB5)
### Javascript execution is mandatory for the modern web
---
In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. Its not possible anymore, because Javascript is everywhere, like it or not:
## Compatibility Note
- Ajax, Single Page App, infinite loading, “click to display”, instant search, etc.
- JS web frameworks: React, Vue, Angular & others
**Playwright compatibility note:** A Playwright script that works today may break after a Lightpanda update. Playwright selects its execution strategy based on which browser APIs are available. When Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may switch to a code path that uses features not yet implemented. We test for compatibility, but cannot cover every scenario. If you hit a regression, [open a GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last version of the script that worked.
### Chrome is not the right tool
---
If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure its such a good idea?
## FAQ
- Heavy on RAM and CPU, expensive to run
- Hard to package, deploy and maintain at scale
- Bloated, lots of features are not useful in headless usage
<details>
<summary><strong>Q: What is Lightpanda?</strong></summary>
### Lightpanda is built for performance
Lightpanda is an open-source headless browser written in Zig. It targets AI agents, web scraping, and automated testing. It uses 9x less memory and runs 11x faster than headless Chrome.
If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But thats what we did:
</details>
- Not based on Chromium, Blink or WebKit
- Low-level system programming language (Zig) with optimisations in mind
- Opinionated: without graphical rendering
<details>
<summary><strong>Q: How does Lightpanda compare to Headless Chrome?</strong></summary>
About 24 MB peak memory vs 207 MB for Chrome when loading 100 pages via Puppeteer. Task completion: 2.3s vs 25.2s. It supports the same CDP protocol, so most Puppeteer and Playwright scripts work without code changes. See the [Lightpanda vs Headless Chrome](#lightpanda-vs-headless-chrome) section for what Lightpanda can and cannot do.
</details>
<details>
<summary><strong>Q: Is Lightpanda a Chromium fork?</strong></summary>
No. It is written in Zig and implements web standards independently (W3C DOM, CSS, JavaScript via V8).
</details>
<details>
<summary><strong>Q: Does Lightpanda work with Playwright?</strong></summary>
Yes. Connect with `chromium.connectOverCDP("ws://127.0.0.1:9222")` locally, or use a cloud endpoint for managed infrastructure. See the [compatibility note](#compatibility-note) for caveats.
</details>
<details>
<summary><strong>Q: Is there a cloud/hosted version?</strong></summary>
Yes. [console.lightpanda.io](https://console.lightpanda.io) provides managed browser infrastructure with regional endpoints (EU West, US West), MCP integration, and both Lightpanda and Chromium browser options.
</details>
<details>
<summary><strong>Q: Why is Lightpanda written in Zig?</strong></summary>
Zig provides precise memory control and deterministic performance without a garbage collector. It compiles to a single static binary with no runtime dependencies. Learn more: [Why We Built Lightpanda in Zig](https://lightpanda.io/blog/posts/why-we-built-lightpanda-in-zig).
</details>
<details>
<summary><strong>Q: What operating systems does Lightpanda support?</strong></summary>
Linux (Debian 12, Ubuntu 22.04/24.04), macOS 13+, and Windows 10+ via WSL2.
</details>
<details>
<summary><strong>Q: Does Lightpanda respect robots.txt?</strong></summary>
Yes, when the `--obey_robots` flag is enabled.
</details>

29
SUMMARY.md Normal file
View File

@@ -0,0 +1,29 @@
# Lightpanda Browser: Document Summary
**What it is:** A headless browser built in Zig from scratch. Not a Chromium fork. Targets AI agents, scraping, and automated testing.
**Performance:** 9x less memory (24 MB vs 207 MB) and 11x faster (2.3s vs 25.2s) than headless Chrome, measured over 100 pages via Puppeteer.
---
## Section Summaries
**Quick Start:** Install via nightly binary (Linux/macOS/Windows WSL2) or Docker. Run `fetch` to dump a URL or `serve` to start a CDP server. Connect Puppeteer/Playwright via `ws://127.0.0.1:9222`.
**Lightpanda vs Headless Chrome:** Choose Lightpanda for low-memory scraping, AI agent browsing, CI testing, and markdown extraction. Use Chrome for screenshots, PDFs, WebGL, or full Web API coverage. Supported: HTTP, HTML5, DOM, JS (V8), Ajax, CDP, cookies, proxy, network interception, robots.txt.
**Use Cases:** AI agents via MCP or CDP, web scraping at scale, headless Chrome replacement in CI, LLM training data extraction with `--dump markdown`.
**Architecture:** CDP/WebSocket client → HTML parsed to DOM → CSS applied → JS via V8 → response as HTML, markdown, or structured data.
**Why Lightpanda?:** Modern web requires JS execution; Chrome is too heavy to run at scale; Lightpanda is built in Zig with no graphical renderer for minimal footprint.
**Build from Source:** Requires Zig 0.15.2, v8, Libcurl, html5ever, and Rust. `make build` or `zig build run`. Optional v8 snapshot for faster startup.
**Test:** `make test` for unit tests; `make end2end` for end-to-end; WPT suite runs via a Go runner in the demo repo.
**Contributing:** PRs via GitHub; CLA required. [Good first issues](https://github.com/lightpanda-io/browser/labels/good%20first%20issue) labeled.
**Compatibility Note:** Playwright scripts may break after Lightpanda updates when new Web APIs shift Playwright's execution path. File an issue with the last working version.
**FAQ:** What Lightpanda is, Chrome comparison, Chromium fork question, Playwright/cloud usage, Zig rationale, OS support, robots.txt.

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

1073
build.zig

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,35 @@
.{
.name = .browser,
.paths = .{""},
.version = "0.0.0",
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.2.9.tar.gz",
.hash = "v8-0.0.0-xddH689vBACgpqFVEhT2wxRin-qQQSOcKJoM37MVo0rU",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
// v1.2.0
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",
.hash = "N-V-__8AAJudKgCQCuIiH6MJjAiIJHfg_tT_Ew-0vZwVkCo_",
},
.zlib = .{
.url = "https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz",
.hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ",
},
.nghttp2 = .{
.url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
.hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3",
},
//.v8 = .{ .path = "../zig-v8-fork" },
.@"boringssl-zig" = .{
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
},
.curl = .{
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
},
},
.paths = .{""},
}

View File

@@ -25,35 +25,38 @@ const Config = @import("Config.zig");
const Snapshot = @import("browser/js/Snapshot.zig");
const Platform = @import("browser/js/Platform.zig");
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
const RobotStore = @import("browser/Robots.zig").RobotStore;
pub const Http = @import("http/Http.zig");
const Network = @import("network/Runtime.zig");
pub const ArenaPool = @import("ArenaPool.zig");
const App = @This();
http: Http,
network: Network,
config: *const Config,
platform: Platform,
snapshot: Snapshot,
telemetry: Telemetry,
allocator: Allocator,
arena_pool: ArenaPool,
robots: RobotStore,
app_dir_path: ?[]const u8,
shutdown: bool = false,
pub fn init(allocator: Allocator, config: *const Config) !*App {
const app = try allocator.create(App);
errdefer allocator.destroy(app);
app.config = config;
app.allocator = allocator;
app.* = .{
.config = config,
.allocator = allocator,
.network = undefined,
.platform = undefined,
.snapshot = undefined,
.app_dir_path = undefined,
.telemetry = undefined,
.arena_pool = undefined,
};
app.robots = RobotStore.init(allocator);
app.http = try Http.init(allocator, &app.robots, config);
errdefer app.http.deinit();
app.network = try Network.init(allocator, config);
errdefer app.network.deinit();
app.platform = try Platform.init();
errdefer app.platform.deinit();
@@ -64,27 +67,26 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.mode);
errdefer app.telemetry.deinit();
errdefer app.telemetry.deinit(allocator);
app.arena_pool = ArenaPool.init(allocator);
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
errdefer app.arena_pool.deinit();
return app;
}
pub fn deinit(self: *App) void {
if (@atomicRmw(bool, &self.shutdown, .Xchg, true, .monotonic)) {
return;
}
pub fn shutdown(self: *const App) bool {
return self.network.shutdown.load(.acquire);
}
pub fn deinit(self: *App) void {
const allocator = self.allocator;
if (self.app_dir_path) |app_dir_path| {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
self.telemetry.deinit();
self.robots.deinit();
self.http.deinit();
self.telemetry.deinit(allocator);
self.network.deinit();
self.snapshot.deinit();
self.platform.deinit();
self.arena_pool.deinit();

View File

@@ -36,12 +36,12 @@ const Entry = struct {
arena: ArenaAllocator,
};
pub fn init(allocator: Allocator) ArenaPool {
pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool {
return .{
.allocator = allocator,
.free_list_max = 512, // TODO make configurable
.retain_bytes = 1024 * 16, // TODO make configurable
.entry_pool = std.heap.MemoryPool(Entry).init(allocator),
.free_list_max = free_list_max,
.retain_bytes = retain_bytes,
.entry_pool = .init(allocator),
};
}
@@ -99,3 +99,114 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
_ = arena.reset(.{ .retain_with_limit = retain });
}
const testing = std.testing;
test "arena pool - basic acquire and use" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 64);
@memset(buf, 0xAB);
try testing.expectEqual(@as(u8, 0xAB), buf[0]);
pool.release(alloc);
}
test "arena pool - reuse entry after release" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc1 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
pool.release(alloc1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The same entry should be returned from the free list.
const alloc2 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
try testing.expectEqual(alloc1.ptr, alloc2.ptr);
pool.release(alloc2);
}
test "arena pool - multiple concurrent arenas" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire();
const a2 = try pool.acquire();
const a3 = try pool.acquire();
// All three must be distinct arenas.
try testing.expect(a1.ptr != a2.ptr);
try testing.expect(a2.ptr != a3.ptr);
try testing.expect(a1.ptr != a3.ptr);
_ = try a1.alloc(u8, 16);
_ = try a2.alloc(u8, 32);
_ = try a3.alloc(u8, 48);
pool.release(a1);
pool.release(a2);
pool.release(a3);
try testing.expectEqual(@as(u16, 3), pool.free_list_len);
}
test "arena pool - free list respects max limit" {
// Cap the free list at 1 so the second release discards its arena.
var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire();
const a2 = try pool.acquire();
pool.release(a1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The free list is full; a2's arena should be destroyed, not queued.
pool.release(a2);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
}
test "arena pool - reset clears memory without releasing" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 128);
@memset(buf, 0xFF);
// reset() frees arena memory but keeps the allocator in-flight.
pool.reset(alloc, 0);
// The free list must stay empty; the allocator was not released.
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
// Allocating again through the same arena must still work.
const buf2 = try alloc.alloc(u8, 64);
@memset(buf2, 0x00);
try testing.expectEqual(@as(u8, 0x00), buf2[0]);
pool.release(alloc);
}
test "arena pool - deinit with entries in free list" {
// Verifies that deinit properly cleans up free-listed arenas (no leaks
// detected by the test allocator).
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
const a1 = try pool.acquire();
const a2 = try pool.acquire();
_ = try a1.alloc(u8, 256);
_ = try a2.alloc(u8, 512);
pool.release(a1);
pool.release(a2);
try testing.expectEqual(@as(u16, 2), pool.free_list_len);
pool.deinit();
}

View File

@@ -23,13 +23,17 @@ const Allocator = std.mem.Allocator;
const log = @import("log.zig");
const dump = @import("browser/dump.zig");
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
pub const RunMode = enum {
help,
fetch,
serve,
version,
mcp,
};
pub const MAX_LISTENERS = 16;
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
@@ -59,56 +63,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void {
pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.tls_verify_host,
inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
else => unreachable,
};
}
pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.obey_robots,
inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
else => unreachable,
};
}
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_proxy,
inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
else => unreachable,
};
}
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_bearer_token,
inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
.help, .version => null,
};
}
pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_concurrent orelse 10,
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable,
};
}
pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_host_open orelse 4,
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable,
};
}
pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_connect_timeout orelse 0,
inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable,
};
}
pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_timeout orelse 5000,
inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
else => unreachable,
};
}
@@ -119,35 +123,53 @@ pub fn httpMaxRedirects(_: *const Config) u8 {
pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_response_size,
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
else => unreachable,
};
}
pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_level,
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
else => unreachable,
};
}
pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_format,
inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
else => unreachable,
};
}
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_filter_scopes,
inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
else => unreachable,
};
}
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.user_agent_suffix,
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
.help, .version => null,
};
}
pub fn cdpTimeout(self: *const Config) usize {
return switch (self.mode) {
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
else => unreachable,
};
}
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
.key_file = opts.common.web_bot_auth_key_file orelse return null,
.keyid = opts.common.web_bot_auth_keyid orelse return null,
.domain = opts.common.web_bot_auth_domain orelse return null,
},
.help, .version => null,
};
}
@@ -171,6 +193,7 @@ pub const Mode = union(RunMode) {
fetch: Fetch,
serve: Serve,
version: void,
mcp: Mcp,
};
pub const Serve = struct {
@@ -182,16 +205,24 @@ pub const Serve = struct {
common: Common = .{},
};
pub const Mcp = struct {
common: Common = .{},
};
pub const DumpFormat = enum {
html,
markdown,
wpt,
semantic_tree,
semantic_tree_text,
};
pub const Fetch = struct {
url: [:0]const u8,
dump_mode: ?DumpFormat = null,
common: Common = .{},
withbase: bool = false,
with_base: bool = false,
with_frames: bool = false,
strip: dump.Opts.Strip = .{},
};
@@ -209,6 +240,10 @@ pub const Common = struct {
log_format: ?log.Format = null,
log_filter_scopes: ?[]log.Scope = null,
user_agent_suffix: ?[]const u8 = null,
web_bot_auth_key_file: ?[]const u8 = null,
web_bot_auth_keyid: ?[]const u8 = null,
web_bot_auth_domain: ?[]const u8 = null,
};
/// Pre-formatted HTTP headers for reuse across Http and Client.
@@ -316,13 +351,21 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--user_agent_suffix
\\ Suffix to append to the Lightpanda/X.Y User-Agent
\\
\\--web_bot_auth_key_file
\\ Path to the Ed25519 private key PEM file.
\\
\\--web_bot_auth_keyid
\\ The JWK thumbprint of your public key.
\\
\\--web_bot_auth_domain
\\ Your domain e.g. yourdomain.com
;
// MAX_HELP_LEN|
const usage =
\\usage: {s} command [options] [URL]
\\
\\Command can be either 'fetch', 'serve' or 'help'
\\Command can be either 'fetch', 'serve', 'mcp' or 'help'
\\
\\fetch command
\\Fetches the specified URL
@@ -330,7 +373,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\Options:
\\--dump Dumps document to stdout.
\\ Argument must be 'html' or 'markdown'.
\\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'.
\\ Defaults to no dump.
\\
\\--strip_mode Comma separated list of tag groups to remove from dump
@@ -342,6 +385,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\
\\--with_base Add a <base> tag in dump. Defaults to false.
\\
\\--with_frames Includes the contents of iframes. Defaults to false.
\\
++ common_options ++
\\
\\serve command
@@ -366,6 +411,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Maximum pending connections in the accept queue.
\\ Defaults to 128.
\\
++ common_options ++
\\
\\mcp command
\\Starts an MCP (Model Context Protocol) server over stdio
\\Example: {s} mcp
\\
++ common_options ++
\\
\\version command
@@ -375,7 +426,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Displays this message
\\
;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name });
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) {
return std.process.cleanExit();
}
@@ -410,6 +461,8 @@ pub fn parseArgs(allocator: Allocator) !Config {
return init(allocator, exec_name, .{ .help = false }) },
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} },
};
return init(allocator, exec_name, mode);
@@ -440,6 +493,10 @@ fn inferMode(opt: []const u8) ?RunMode {
return .fetch;
}
if (std.mem.eql(u8, opt, "--with_frames")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--host")) {
return .serve;
}
@@ -534,12 +591,31 @@ fn parseServeArgs(
return serve;
}
fn parseMcpArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Mcp {
var mcp: Mcp = .{};
while (args.next()) |opt| {
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
continue;
}
log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt });
return error.UnkownOption;
}
return mcp;
}
fn parseFetchArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Fetch {
var dump_mode: ?DumpFormat = null;
var withbase: bool = false;
var with_base: bool = false;
var with_frames: bool = false;
var url: ?[:0]const u8 = null;
var common: Common = .{};
var strip: dump.Opts.Strip = .{};
@@ -570,7 +646,12 @@ fn parseFetchArgs(
}
if (std.mem.eql(u8, "--with_base", opt)) {
withbase = true;
with_base = true;
continue;
}
if (std.mem.eql(u8, "--with_frames", opt)) {
with_frames = true;
continue;
}
@@ -626,7 +707,8 @@ fn parseFetchArgs(
.dump_mode = dump_mode,
.strip = strip,
.common = common,
.withbase = withbase,
.with_base = with_base,
.with_frames = with_frames,
};
}
@@ -798,5 +880,32 @@ fn parseCommonArg(
return true;
}
if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" });
return error.InvalidArgument;
};
common.web_bot_auth_key_file = try allocator.dupe(u8, str);
return true;
}
if (std.mem.eql(u8, "--web_bot_auth_keyid", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" });
return error.InvalidArgument;
};
common.web_bot_auth_keyid = try allocator.dupe(u8, str);
return true;
}
if (std.mem.eql(u8, "--web_bot_auth_domain", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" });
return error.InvalidArgument;
};
common.web_bot_auth_domain = try allocator.dupe(u8, str);
return true;
}
return false;
}

View File

@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
const log = @import("log.zig");
const Page = @import("browser/Page.zig");
const Transfer = @import("http/Client.zig").Transfer;
const Transfer = @import("browser/HttpClient.zig").Transfer;
const Allocator = std.mem.Allocator;
@@ -73,6 +73,7 @@ const EventListeners = struct {
page_navigated: List = .{},
page_network_idle: List = .{},
page_network_almost_idle: List = .{},
page_frame_created: List = .{},
http_request_fail: List = .{},
http_request_start: List = .{},
http_request_intercept: List = .{},
@@ -89,6 +90,7 @@ const Events = union(enum) {
page_navigated: *const PageNavigated,
page_network_idle: *const PageNetworkIdle,
page_network_almost_idle: *const PageNetworkAlmostIdle,
page_frame_created: *const PageFrameCreated,
http_request_fail: *const RequestFail,
http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept,
@@ -102,24 +104,36 @@ const EventType = std.meta.FieldEnum(Events);
pub const PageRemove = struct {};
pub const PageNavigate = struct {
req_id: usize,
req_id: u32,
frame_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Page.NavigateOpts,
};
pub const PageNavigated = struct {
req_id: usize,
req_id: u32,
frame_id: u32,
timestamp: u64,
url: [:0]const u8,
opts: Page.NavigatedOpts,
};
pub const PageNetworkIdle = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageNetworkAlmostIdle = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageFrameCreated = struct {
frame_id: u32,
parent_id: u32,
timestamp: u64,
};
@@ -305,6 +319,7 @@ test "Notification" {
// noop
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
@@ -315,6 +330,7 @@ test "Notification" {
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 4,
.url = undefined,
@@ -324,6 +340,7 @@ test "Notification" {
notifier.unregisterAll(&tc);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
@@ -334,23 +351,25 @@ test "Notification" {
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 10,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} });
try testing.expectEqual(14, tc.page_navigate);
try testing.expectEqual(6, tc.page_navigated);
notifier.unregisterAll(&tc);
notifier.dispatch(.page_navigate, &.{
.frame_id = 0,
.req_id = 1,
.timestamp = 100,
.url = undefined,
.opts = .{},
});
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
try testing.expectEqual(14, tc.page_navigate);
try testing.expectEqual(6, tc.page_navigated);
@@ -358,27 +377,27 @@ test "Notification" {
// unregister
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.page_navigate);
try testing.expectEqual(1006, tc.page_navigated);
notifier.unregister(.page_navigate, &tc);
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.page_navigate);
try testing.expectEqual(2006, tc.page_navigated);
notifier.unregister(.page_navigated, &tc);
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.page_navigate);
try testing.expectEqual(2006, tc.page_navigated);
// already unregistered, try anyways
notifier.unregister(.page_navigated, &tc);
notifier.dispatch(.page_navigate, &.{ .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
try testing.expectEqual(114, tc.page_navigate);
try testing.expectEqual(2006, tc.page_navigated);
}

532
src/SemanticTree.zig Normal file
View File

@@ -0,0 +1,532 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. See <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const log = @import("log.zig");
const isAllWhitespace = @import("string.zig").isAllWhitespace;
const Page = lp.Page;
const interactive = @import("browser/interactive.zig");
const CData = @import("browser/webapi/CData.zig");
const Element = @import("browser/webapi/Element.zig");
const Node = @import("browser/webapi/Node.zig");
const AXNode = @import("cdp/AXNode.zig");
const CDPNode = @import("cdp/Node.zig");
const Self = @This();
dom_node: *Node,
registry: *CDPNode.Registry,
page: *Page,
arena: std.mem.Allocator,
prune: bool = true,
interactive_only: bool = false,
max_depth: u32 = std.math.maxInt(u32) - 1,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
var visitor = JsonVisitor{ .jw = jw, .tree = self };
var xpath_buffer: std.ArrayList(u8) = .{};
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed;
};
}
pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void {
var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 };
var xpath_buffer: std.ArrayList(u8) = .empty;
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed;
};
}
const OptionData = struct {
value: []const u8,
text: []const u8,
selected: bool,
};
const NodeData = struct {
id: CDPNode.Id,
axn: AXNode,
role: []const u8,
name: ?[]const u8,
value: ?[]const u8,
options: ?[]OptionData = null,
xpath: []const u8,
is_interactive: bool,
node_name: []const u8,
};
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void {
if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes
if (node.is(Element)) |el| {
const tag = el.getTag();
if (tag.isMetadata() or tag == .svg) return;
// We handle options/optgroups natively inside their parents, skip them in the general walk
if (tag == .datalist or tag == .option or tag == .optgroup) return;
// Check visibility using the engine's checkVisibility which handles CSS display: none
if (!el.checkVisibility(self.page)) {
return;
}
if (el.is(Element.Html)) |html_el| {
if (html_el.getHidden()) return;
}
} else if (node.is(CData.Text)) |text_node| {
const text = text_node.getWholeText();
if (isAllWhitespace(text)) {
return;
}
} else if (node._type != .document and node._type != .document_fragment) {
return;
}
const cdp_node = try self.registry.register(node);
const axn = AXNode.fromNode(node);
const role = try axn.getRole();
var is_interactive = false;
var value: ?[]const u8 = null;
var options: ?[]OptionData = null;
var node_name: []const u8 = "text";
if (node.is(Element)) |el| {
node_name = el.getTagNameLower();
if (el.is(Element.Html.Input)) |input| {
value = input.getValue();
if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| {
options = try extractDataListOptions(list_id, self.page, self.arena);
}
} else if (el.is(Element.Html.TextArea)) |textarea| {
value = textarea.getValue();
} else if (el.is(Element.Html.Select)) |select| {
value = select.getValue(self.page);
options = try extractSelectOptions(el.asNode(), self.page, self.arena);
}
if (el.is(Element.Html)) |html_el| {
if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) {
is_interactive = true;
}
}
} else if (node._type == .document or node._type == .document_fragment) {
node_name = "root";
}
const initial_xpath_len = xpath_buffer.items.len;
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index);
const xpath = xpath_buffer.items;
var name = try axn.getName(self.page, self.arena);
const has_explicit_label = if (node.is(Element)) |el|
el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null
else
false;
const structural = isStructuralRole(role);
// Filter out computed concatenated names for generic containers without explicit labels.
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
if (name != null and structural and !has_explicit_label) {
name = null;
}
var data = NodeData{
.id = cdp_node.id,
.axn = axn,
.role = role,
.name = name,
.value = value,
.options = options,
.xpath = xpath,
.is_interactive = is_interactive,
.node_name = node_name,
};
var should_visit = true;
if (self.interactive_only) {
var keep = false;
if (interactive.isInteractiveRole(role)) {
keep = true;
} else if (interactive.isContentRole(role)) {
if (name != null and name.?.len > 0) {
keep = true;
}
} else if (std.mem.eql(u8, role, "RootWebArea")) {
keep = true;
} else if (is_interactive) {
keep = true;
}
if (!keep) {
should_visit = false;
}
} else if (self.prune) {
if (structural and !is_interactive and !has_explicit_label) {
should_visit = false;
}
if (std.mem.eql(u8, role, "StaticText") and node._parent != null) {
if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) {
should_visit = false;
}
}
}
var did_visit = false;
var should_walk_children = true;
if (should_visit) {
should_walk_children = try visitor.visit(node, &data);
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
} else {
// If we skip the node, we must NOT tell the visitor to close it later
did_visit = false;
}
if (should_walk_children) {
// If we are printing this node normally OR skipping it and unrolling its children,
// we walk the children iterator.
var it = node.childrenIterator();
var tag_counts = std.StringArrayHashMap(usize).init(self.arena);
while (it.next()) |child| {
var tag: []const u8 = "text()";
if (child.is(Element)) |el| {
tag = el.getTagNameLower();
}
const gop = try tag_counts.getOrPut(tag);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
}
}
if (did_visit) {
try visitor.leave();
}
xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
}
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
var options = std.ArrayListUnmanaged(OptionData){};
var it = node.childrenIterator();
while (it.next()) |child| {
if (child.is(Element)) |el| {
if (el.getTag() == .option) {
if (el.is(Element.Html.Option)) |opt| {
const text = opt.getText(page);
const value = opt.getValue(page);
const selected = opt.getSelected();
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
}
} else if (el.getTag() == .optgroup) {
var group_it = child.childrenIterator();
while (group_it.next()) |group_child| {
if (group_child.is(Element.Html.Option)) |opt| {
const text = opt.getText(page);
const value = opt.getValue(page);
const selected = opt.getSelected();
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
}
}
}
}
}
return options.toOwnedSlice(arena);
}
fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData {
if (page.document.getElementById(list_id, page)) |referenced_el| {
if (referenced_el.getTag() == .datalist) {
return try extractSelectOptions(referenced_el.asNode(), page, arena);
}
}
return null;
}
fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void {
if (node.is(Element)) |el| {
const tag = el.getTagNameLower();
try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index });
} else if (node.is(CData.Text)) |_| {
try std.fmt.format(writer, "/text()[{d}]", .{index});
}
}
const JsonVisitor = struct {
jw: *std.json.Stringify,
tree: Self,
pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool {
try self.jw.beginObject();
try self.jw.objectField("nodeId");
try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id}));
try self.jw.objectField("backendDOMNodeId");
try self.jw.write(data.id);
try self.jw.objectField("nodeName");
try self.jw.write(data.node_name);
try self.jw.objectField("xpath");
try self.jw.write(data.xpath);
if (node.is(Element)) |el| {
try self.jw.objectField("nodeType");
try self.jw.write(1);
try self.jw.objectField("isInteractive");
try self.jw.write(data.is_interactive);
try self.jw.objectField("role");
try self.jw.write(data.role);
if (data.name) |name| {
if (name.len > 0) {
try self.jw.objectField("name");
try self.jw.write(name);
}
}
if (data.value) |value| {
try self.jw.objectField("value");
try self.jw.write(value);
}
if (el._attributes) |attrs| {
try self.jw.objectField("attributes");
try self.jw.beginObject();
var iter = attrs.iterator();
while (iter.next()) |attr| {
try self.jw.objectField(attr._name.str());
try self.jw.write(attr._value.str());
}
try self.jw.endObject();
}
if (data.options) |options| {
try self.jw.objectField("options");
try self.jw.beginArray();
for (options) |opt| {
try self.jw.beginObject();
try self.jw.objectField("value");
try self.jw.write(opt.value);
try self.jw.objectField("text");
try self.jw.write(opt.text);
try self.jw.objectField("selected");
try self.jw.write(opt.selected);
try self.jw.endObject();
}
try self.jw.endArray();
}
} else if (node.is(CData.Text)) |text_node| {
try self.jw.objectField("nodeType");
try self.jw.write(3);
try self.jw.objectField("nodeValue");
try self.jw.write(text_node.getWholeText());
} else {
try self.jw.objectField("nodeType");
try self.jw.write(9);
}
try self.jw.objectField("children");
try self.jw.beginArray();
if (data.options != null) {
// Signal to not walk children, as we handled them natively
return false;
}
return true;
}
pub fn leave(self: *JsonVisitor) !void {
try self.jw.endArray();
try self.jw.endObject();
}
};
fn isStructuralRole(role: []const u8) bool {
const structural_roles = std.StaticStringMap(void).initComptime(.{
.{ "none", {} },
.{ "generic", {} },
.{ "InlineTextBox", {} },
.{ "banner", {} },
.{ "navigation", {} },
.{ "main", {} },
.{ "list", {} },
.{ "listitem", {} },
.{ "table", {} },
.{ "rowgroup", {} },
.{ "row", {} },
.{ "cell", {} },
.{ "region", {} },
});
return structural_roles.has(role);
}
const TextVisitor = struct {
writer: *std.Io.Writer,
tree: Self,
depth: usize,
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
for (0..self.depth) |_| {
try self.writer.writeByte(' ');
}
var name_to_print: ?[]const u8 = null;
if (data.name) |n| {
if (n.len > 0) {
name_to_print = n;
}
} else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
if (trimmed.len > 0) {
name_to_print = trimmed;
}
}
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
try self.writer.print("{d}", .{data.id});
if (!is_text_only) {
try self.writer.print(" {s}", .{data.role});
}
if (name_to_print) |n| {
try self.writer.print(" '{s}'", .{n});
}
if (data.value) |v| {
if (v.len > 0) {
try self.writer.print(" value='{s}'", .{v});
}
}
if (data.options) |options| {
try self.writer.writeAll(" options=[");
for (options, 0..) |opt, i| {
if (i > 0) try self.writer.writeAll(",");
try self.writer.print("'{s}'", .{opt.value});
if (opt.selected) {
try self.writer.writeAll("*");
}
}
try self.writer.writeAll("]\n");
self.depth += 1;
return false; // Native handling complete, do not walk children
}
try self.writer.writeByte('\n');
self.depth += 1;
// If this is a leaf-like semantic node and we already have a name,
// skip children to avoid redundant StaticText or noise.
const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or
std.mem.eql(u8, data.role, "button") or
std.mem.eql(u8, data.role, "heading") or
std.mem.eql(u8, data.role, "code");
if (is_leaf_semantic and data.name != null and data.name.?.len > 0) {
return false;
}
return true;
}
pub fn leave(self: *TextVisitor) !void {
if (self.depth > 0) {
self.depth -= 1;
}
}
};
const testing = @import("testing.zig");
test "SemanticTree backendDOMNodeId" {
var registry: CDPNode.Registry = .init(testing.allocator);
defer registry.deinit();
var page = try testing.pageTest("cdp/registry1.html");
defer testing.reset();
defer page._session.removePage();
const st: Self = .{
.dom_node = page.window._document.asNode(),
.registry = &registry,
.page = page,
.arena = testing.arena_allocator,
.prune = false,
.interactive_only = false,
.max_depth = std.math.maxInt(u32) - 1,
};
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
defer testing.allocator.free(json_str);
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
}
test "SemanticTree max_depth" {
var registry: CDPNode.Registry = .init(testing.allocator);
defer registry.deinit();
var page = try testing.pageTest("cdp/registry1.html");
defer testing.reset();
defer page._session.removePage();
const st: Self = .{
.dom_node = page.window._document.asNode(),
.registry = &registry,
.page = page,
.arena = testing.arena_allocator,
.prune = false,
.interactive_only = false,
.max_depth = 1,
};
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try st.textStringify(&aw.writer);
const text_str = aw.written();
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const URL = @import("browser/URL.zig");
const TestHTTPServer = @This();
@@ -97,7 +98,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
}
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
var file = std.fs.cwd().openFile(file_path, .{}) catch |err| switch (err) {
var url_buf: [1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
else => return err,
};

View File

@@ -24,7 +24,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
@@ -87,19 +87,36 @@ pub fn closeSession(self: *Browser) void {
}
}
pub fn runMicrotasks(self: *const Browser) void {
pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks();
}
pub fn runMacrotasks(self: *Browser) !?u64 {
return try self.env.runMacrotasks();
pub fn runMacrotasks(self: *Browser) !void {
const env = &self.env;
try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
}
pub fn runMessageLoop(self: *const Browser) void {
while (self.env.pumpMessageLoop()) {
if (comptime IS_DEBUG) {
log.debug(.browser, "pumpMessageLoop", .{});
}
}
pub fn hasBackgroundTasks(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
pub fn waitForBackgroundTasks(self: *Browser) void {
self.env.waitForBackgroundTasks();
}
pub fn msToNextMacrotask(self: *Browser) ?u64 {
return self.env.msToNextMacrotask();
}
pub fn msTo(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks();
}

View File

@@ -56,7 +56,12 @@ pub const EventManager = @This();
page: *Page,
arena: Allocator,
// Used as an optimization in Page._documentIsComplete. If we know there are no
// 'load' listeners in the document, we can skip dispatching the per-resource
// 'load' event (e.g. amazon product page has no listener and ~350 resources)
has_dom_load_listener: bool,
listener_pool: std.heap.MemoryPool(Listener),
ignore_list: std.ArrayList(*Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.HashMapUnmanaged(
EventKey,
@@ -72,10 +77,12 @@ pub fn init(arena: Allocator, page: *Page) EventManager {
.page = page,
.lookup = .{},
.arena = arena,
.ignore_list = .{},
.list_pool = .init(arena),
.listener_pool = .init(arena),
.dispatch_depth = 0,
.deferred_removals = .{},
.has_dom_load_listener = false,
};
}
@@ -106,6 +113,10 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
// Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{});
if (type_string.eql(comptime .wrap("load")) and target._type == .node) {
self.has_dom_load_listener = true;
}
const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string,
.event_target = @intFromPtr(target),
@@ -146,6 +157,11 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
};
// append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node);
// Track load listeners for script execution ignore list
if (type_string.eql(comptime .wrap("load"))) {
try self.ignore_list.append(self.arena, listener);
}
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
@@ -158,6 +174,10 @@ pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callba
}
}
pub fn clearIgnoreList(self: *EventManager) void {
self.ignore_list.clearRetainingCapacity();
}
// Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and
// inferred error.
@@ -169,42 +189,31 @@ const DispatchError = error{
ExecutionError,
JsException,
};
pub const DispatchOpts = struct {
// A "load" event triggered by a script (in ScriptManager) should not trigger
// a "load" listener added within that script. Therefore, any "load" listener
// that we add go into an ignore list until after the script finishes executing.
// The ignore list is only checked when apply_ignore == true, which is only
// set by the ScriptManager when raising the script's "load" event.
apply_ignore: bool = false,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
return self.dispatchOpts(target, event, .{});
}
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page._session);
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}
event._target = target;
event._dispatch_target = target; // Store original target for composedPath()
var was_handled = false;
defer if (was_handled) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
switch (target._type) {
.node => |node| try self.dispatchNode(node, event, &was_handled),
.xhr,
.window,
.abort_signal,
.media_query_list,
.message_port,
.text_track_cue,
.navigation,
.screen,
.screen_orientation,
.visual_viewport,
.generic,
=> {
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_handled);
},
.node => |node| try self.dispatchNode(node, event, opts),
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
}
}
@@ -213,13 +222,28 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
// property is just a shortcut for calling addEventListener, but they are distinct.
// An event set via property cannot be removed by removeEventListener. If you
// set both the property and add a listener, they both execute.
const DispatchWithFunctionOptions = struct {
const DispatchDirectOptions = struct {
context: []const u8,
inject_target: bool = true,
};
pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void {
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
// property handlers. No propagation - just calls the handler and registered listeners.
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const page = self.page;
// Set window.event to the currently dispatching event (WHATWG spec)
const window = page.window;
const prev_event = window._current_event;
window._current_event = event;
defer window._current_event = prev_event;
event.acquireRef();
defer event.deinit(false, page._session);
if (comptime IS_DEBUG) {
log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null });
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
}
if (comptime opts.inject_target) {
@@ -228,14 +252,15 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
}
var was_dispatched = false;
defer if (was_dispatched) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
if (function_) |func| {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer {
ls.local.runMicrotasks();
ls.deinit();
}
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
if (func.callWithThis(void, target, .{event})) {
was_dispatched = true;
@@ -245,23 +270,169 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
}
}
// listeners reigstered via addEventListener
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_dispatched);
// This is a slightly simplified version of what you'll find in dispatchPhase
// It is simpler because, for direct dispatching, we know there's no ancestors
// and only the single target phase.
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
while (node) |n| {
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
self.removeListener(list, listener);
continue;
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
was_dispatched = true;
event._current_target = target;
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
if (event._stop_immediate_propagation) {
return;
}
}
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
const T = @TypeOf(handler);
const ti = @typeInfo(T);
if (ti == .null) {
return null;
}
if (ti == .optional) {
return getFunction(handler orelse return null, local);
}
return switch (T) {
js.Function => handler,
js.Function.Temp => local.toLocal(handler),
js.Function.Global => local.toLocal(handler),
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
};
}
/// Check if there are any listeners for a direct dispatch (non-DOM target).
/// Use this to avoid creating an event when there are no listeners.
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
if (hasHandler(handler)) {
return true;
}
return self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = .wrap(typ),
}) != null;
}
fn hasHandler(handler: anytype) bool {
const ti = @typeInfo(@TypeOf(handler));
if (ti == .null) {
return false;
}
if (ti == .optional) {
return handler != null;
}
return true;
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
{
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
}
const page = self.page;
// Set window.event to the currently dispatching event (WHATWG spec)
const window = page.window;
const prev_event = window._current_event;
window._current_event = event;
defer window._current_event = prev_event;
var was_handled = false;
// Create a single scope for all event handlers in this dispatch.
// This ensures function handles passed to queueMicrotask remain valid
// throughout the entire dispatch, preventing crashes when microtasks run.
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer {
if (was_handled) {
ls.local.runMicrotasks();
}
ls.deinit();
}
const activation_state = ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
state.restore(event, page);
@@ -307,11 +478,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
node = n._parent;
}
// Even though the window isn't part of the DOM, events always propagate
// Even though the window isn't part of the DOM, most events propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
// The only explicit exception is "load"
if (event._type_string.eql(comptime .wrap("load")) == false) {
if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
}
const path = path_buffer[0..path_len];
@@ -322,32 +496,27 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
var i: usize = path_len;
while (i > 1) {
i -= 1;
if (event._stop_propagation) return;
const current_target = path[i];
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, true);
if (event._stop_propagation) {
return;
}
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts));
}
}
// Phase 2: At target
if (event._stop_propagation) return;
event._event_phase = .at_target;
const target_et = target.asEventTarget();
blk: {
// Get inline handler (e.g., onclick property) for this target
if (self.getInlineHandler(target_et, event)) |inline_handler| {
was_handled.* = true;
was_handled = true;
event._current_target = target_et;
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
if (event._stop_propagation) {
@@ -363,7 +532,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
if (event._stop_propagation) {
return;
}
@@ -375,20 +544,30 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (event._stop_propagation) break;
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, false);
if (event._stop_propagation) {
break;
}
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
}
}
}
}
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts {
return .{
.capture_only = capture_only,
.apply_ignore = opts.apply_ignore,
};
}
};
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void {
const page = self.page;
// Track dispatch depth for deferred removal
@@ -414,7 +593,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
while (node) |n| {
node_loop: while (node) |n| {
if (is_done) {
break;
}
@@ -424,7 +603,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
node = n.next;
// Skip non-matching listeners
if (comptime capture_only) |capture| {
if (comptime opts.capture_only) |capture| {
if (listener.capture != capture) {
continue;
}
@@ -443,6 +622,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
}
}
if (comptime opts.apply_ignore) {
for (self.ignore_list.items) |ignored| {
if (ignored == listener) {
continue :node_loop;
}
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
@@ -457,18 +644,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = getAdjustedTarget(original_target, current_target);
}
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, current_target, .{event}),
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
try local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
const obj = local.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
@@ -486,22 +669,20 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
}
}
// Non-Node dispatching (XHR, Window without propagation)
fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void {
return self.dispatchPhase(list, current_target, event, was_handled, null);
}
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
const element = switch (target._type) {
.node => |n| n.is(Element) orelse return null,
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
else => return null,
};
return self.page.getAttrListener(element, handler_type);
return html_element.getAttributeFunction(handler_type, self.page) catch |err| {
log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err });
return null;
};
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
@@ -682,9 +863,10 @@ const ActivationState = struct {
}
// Commit: fire input and change events only if state actually changed
// and the element is connected to a document (detached elements don't fire).
// For checkboxes, state always changes. For radios, only if was unchecked.
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
if (state_changed) {
if (state_changed and input.asElement().asNode().isConnected()) {
fireEvent(page, input, "input") catch |err| {
log.warn(.event, "input event", .{ .err = err });
};
@@ -754,7 +936,6 @@ const ActivationState = struct {
.bubbles = true,
.cancelable = false,
}, page);
defer if (!event._v8_handoff) event.deinit(false);
const target = input.asElement().asEventTarget();
try page._event_manager.dispatch(target, event);

View File

@@ -42,12 +42,94 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert;
// Shared across all frames of a Page.
const Factory = @This();
_page: *Page,
_arena: Allocator,
_slab: SlabAllocator,
pub fn init(arena: Allocator) Factory {
return .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return self.eventTargetWithAllocator(self._slab.allocator(), child);
}
pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn PrototypeChain(comptime types: []const type) type {
return struct {
const Self = @This();
@@ -151,110 +233,29 @@ fn AutoPrototypeChain(comptime types: []const type) type {
};
}
pub fn init(arena: Allocator, page: *Page) Factory {
return .{
._page = page,
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._rc = 0,
._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value),
._type_string = typ,
._time_stamp = time_stamp,
};
}
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
// Special case: Blob has slice and mime fields, so we need manual setup
const chain = try PrototypeChain(
&.{ Blob, @TypeOf(child) },
).allocate(allocator);
).allocate(arena);
const blob_ptr = chain.get(0);
blob_ptr.* = .{
._arena = arena,
._type = unionInit(Blob.Type, chain.get(1)),
._slice = "",
._mime = "",
@@ -264,19 +265,23 @@ pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
pub fn abstractRange(self: *Factory, child: anytype, page: *Page) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(allocator);
pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, page: *Page) !*@TypeOf(child) {
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(arena);
const doc = page.document.asNode();
chain.set(0, AbstractRange{
const abstract_range = chain.get(0);
abstract_range.* = AbstractRange{
._rc = 0,
._arena = arena,
._page_id = page.id,
._type = unionInit(AbstractRange.Type, chain.get(1)),
._end_offset = 0,
._start_offset = 0,
._end_container = doc,
._start_container = doc,
});
};
chain.setLeaf(1, child);
page._live_ranges.append(&abstract_range._range_link);
return chain.get(1);
}
@@ -384,7 +389,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
}
if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
self.destroyChain(value, 0, std.mem.Alignment.@"1");
} else {
self.destroyStandalone(value);
}
@@ -398,7 +403,6 @@ pub fn destroyStandalone(self: *Factory, value: anytype) void {
fn destroyChain(
self: *Factory,
value: anytype,
comptime first: bool,
old_size: usize,
old_align: std.mem.Alignment,
) void {
@@ -410,23 +414,8 @@ fn destroyChain(
const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
// we don't call deinit (because we're in that deinit)
if (!comptime first) {
// But if it isn't the first time
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
}
if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, false, new_size, new_align);
self.destroyChain(value._proto, new_size, new_align);
} else {
// no proto so this is the head of the chain.
// we use this as the ptr to the start of the chain.

File diff suppressed because it is too large Load Diff

View File

@@ -24,10 +24,12 @@ params: []const u8 = "",
// IANA defines max. charset value length as 40.
// We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset,
charset_len: usize = 5,
charset_len: usize = default_charset_len,
is_default_charset: bool = true,
/// String "UTF-8" continued by null characters.
pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
/// Mime with unknown Content-Type, empty params and empty charset.
pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
@@ -127,17 +129,18 @@ pub fn parse(input: []u8) !Mime {
const params = trimLeft(normalized[type_len..]);
var charset: [41]u8 = undefined;
var charset_len: usize = undefined;
var charset: [41]u8 = default_charset;
var charset_len: usize = default_charset_len;
var has_explicit_charset = false;
var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| {
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid;
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
const name = trimLeft(attr[0..i]);
const value = trimRight(attr[i + 1 ..]);
if (value.len == 0) {
return error.Invalid;
continue;
}
const attribute_name = std.meta.stringToEnum(enum {
@@ -150,11 +153,12 @@ pub fn parse(input: []u8) !Mime {
break;
}
const attribute_value = try parseCharset(value);
const attribute_value = parseCharset(value) catch continue;
@memcpy(charset[0..attribute_value.len], attribute_value);
// Null-terminate right after attribute value.
charset[attribute_value.len] = 0;
charset_len = attribute_value.len;
has_explicit_charset = true;
},
}
}
@@ -164,9 +168,137 @@ pub fn parse(input: []u8) !Mime {
.charset = charset,
.charset_len = charset_len,
.content_type = content_type,
.is_default_charset = !has_explicit_charset,
};
}
/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
/// Looks for `<meta charset="X">` and `<meta http-equiv="Content-Type" content="...;charset=X">`.
/// Returns the charset value or null if none found.
/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations
pub fn prescanCharset(html: []const u8) ?[]const u8 {
const limit = @min(html.len, 1024);
const data = html[0..limit];
// Scan for <meta tags
var pos: usize = 0;
while (pos < data.len) {
// Find next '<'
pos = std.mem.indexOfScalarPos(u8, data, pos, '<') orelse return null;
pos += 1;
if (pos >= data.len) return null;
// Check for "meta" (case-insensitive)
if (pos + 4 >= data.len) return null;
var tag_buf: [4]u8 = undefined;
_ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]);
if (!std.mem.eql(u8, &tag_buf, "meta")) {
continue;
}
pos += 4;
// Must be followed by whitespace or end of tag
if (pos >= data.len) return null;
if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and
data[pos] != '\r' and data[pos] != '/')
{
continue;
}
// Scan attributes within this meta tag
const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null;
const attrs = data[pos..tag_end];
// Look for charset= attribute directly
if (findAttrValue(attrs, "charset")) |charset| {
if (charset.len > 0 and charset.len <= 40) return charset;
}
// Look for http-equiv="content-type" with content="...;charset=X"
if (findAttrValue(attrs, "http-equiv")) |he| {
if (std.ascii.eqlIgnoreCase(he, "content-type")) {
if (findAttrValue(attrs, "content")) |content| {
if (extractCharsetFromContentType(content)) |charset| {
return charset;
}
}
}
}
pos = tag_end + 1;
}
return null;
}
fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 {
var pos: usize = 0;
while (pos < attrs.len) {
// Skip whitespace
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or
attrs[pos] == '\n' or attrs[pos] == '\r'))
{
pos += 1;
}
if (pos >= attrs.len) return null;
// Read attribute name
const attr_start = pos;
while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and
attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/')
{
pos += 1;
}
const attr_name = attrs[attr_start..pos];
// Skip whitespace around =
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
if (pos >= attrs.len or attrs[pos] != '=') {
// No '=' found - skip this token. Advance at least one byte to avoid infinite loop.
if (pos == attr_start) pos += 1;
continue;
}
pos += 1; // skip '='
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
if (pos >= attrs.len) return null;
// Read attribute value
const value = blk: {
if (attrs[pos] == '"' or attrs[pos] == '\'') {
const quote = attrs[pos];
pos += 1;
const val_start = pos;
while (pos < attrs.len and attrs[pos] != quote) pos += 1;
const val = attrs[val_start..pos];
if (pos < attrs.len) pos += 1; // skip closing quote
break :blk val;
} else {
const val_start = pos;
while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and
attrs[pos] != '>' and attrs[pos] != '/')
{
pos += 1;
}
break :blk attrs[val_start..pos];
}
};
if (std.ascii.eqlIgnoreCase(attr_name, name)) return value;
}
return null;
}
fn extractCharsetFromContentType(content: []const u8) ?[]const u8 {
var it = std.mem.splitScalar(u8, content, ';');
while (it.next()) |part| {
const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' });
if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) {
const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' });
if (val.len > 0 and val.len <= 40) return val;
}
}
return null;
}
pub fn sniff(body: []const u8) ?Mime {
// 0x0C is form feed
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
@@ -177,15 +309,30 @@ pub fn sniff(body: []const u8) ?Mime {
if (content[0] != '<') {
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
// UTF-8 BOM
return .{ .content_type = .{ .text_plain = {} } };
return .{
.content_type = .{ .text_plain = {} },
.charset = default_charset,
.charset_len = default_charset_len,
.is_default_charset = false,
};
}
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
// UTF-16 big-endian BOM
return .{ .content_type = .{ .text_plain = {} } };
return .{
.content_type = .{ .text_plain = {} },
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33,
.charset_len = 8,
.is_default_charset = false,
};
}
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
// UTF-16 little-endian BOM
return .{ .content_type = .{ .text_plain = {} } };
return .{
.content_type = .{ .text_plain = {} },
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33,
.charset_len = 8,
.is_default_charset = false,
};
}
return null;
}
@@ -334,6 +481,19 @@ test "Mime: invalid" {
"text/ html",
"text / html",
"text/html other",
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
}
}
test "Mime: malformed parameters are ignored" {
defer testing.reset();
// These should all parse successfully as text/html with malformed params ignored
const valid_with_malformed_params = [_][]const u8{
"text/html; x",
"text/html; x=",
"text/html; x= ",
@@ -342,11 +502,13 @@ test "Mime: invalid" {
"text/html; charset=\"\"",
"text/html; charset=\"",
"text/html; charset=\"\\",
"text/html;\"",
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
for (valid_with_malformed_params) |input| {
const mutable_input = try testing.arena_allocator.dupe(u8, input);
const mime = try Mime.parse(mutable_input);
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
}
}
@@ -435,6 +597,12 @@ test "Mime: parse charset" {
.charset = "custom-non-standard-charset-value",
.params = "charset=\"custom-non-standard-charset-value\"",
}, "text/xml;charset=\"custom-non-standard-charset-value\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "UTF-8",
.params = "x=\"",
}, "text/html;x=\"");
}
test "Mime: isHTML" {
@@ -518,6 +686,24 @@ test "Mime: sniff" {
try expectHTML("<!-->");
try expectHTML(" \n\t <!-->");
{
const mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?;
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
try testing.expectEqual("UTF-8", mime.charsetString());
}
{
const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?;
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
try testing.expectEqual("UTF-16BE", mime.charsetString());
}
{
const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?;
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
try testing.expectEqual("UTF-16LE", mime.charsetString());
}
}
const Expectation = struct {
@@ -554,3 +740,35 @@ fn expect(expected: Expectation, input: []const u8) !void {
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
}
}
test "Mime: prescanCharset" {
// <meta charset="X">
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
try testing.expectEqual("iso-8859-1", Mime.prescanCharset("<html><head><meta charset=\"iso-8859-1\">").?);
try testing.expectEqual("shift_jis", Mime.prescanCharset("<meta charset='shift_jis'>").?);
// Case-insensitive tag matching
try testing.expectEqual("utf-8", Mime.prescanCharset("<META charset=\"utf-8\">").?);
try testing.expectEqual("utf-8", Mime.prescanCharset("<Meta charset=\"utf-8\">").?);
// <meta http-equiv="Content-Type" content="text/html; charset=X">
try testing.expectEqual(
"iso-8859-1",
Mime.prescanCharset("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">").?,
);
// No charset found
try testing.expectEqual(null, Mime.prescanCharset("<html><head><title>Test</title>"));
try testing.expectEqual(null, Mime.prescanCharset(""));
try testing.expectEqual(null, Mime.prescanCharset("no html here"));
// Self-closing meta without charset must not loop forever
try testing.expectEqual(null, Mime.prescanCharset("<meta foo=\"bar\"/>"));
// Charset after 1024 bytes should not be found
var long_html: [1100]u8 = undefined;
@memset(&long_html, ' ');
const suffix = "<meta charset=\"windows-1252\">";
@memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
try testing.expectEqual(null, Mime.prescanCharset(&long_html));
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,13 +20,15 @@ const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const js = @import("js/js.zig");
const log = @import("../log.zig");
const HttpClient = @import("HttpClient.zig");
const net_http = @import("../network/http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
const URL = @import("URL.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Http = @import("../http/Http.zig");
const Element = @import("webapi/Element.zig");
@@ -59,11 +61,8 @@ ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *Http.Client,
client: *HttpClient,
allocator: Allocator,
buffer_pool: BufferPool,
script_pool: std.heap.MemoryPool(Script),
// We can download multiple sync modules in parallel, but we want to process
// them in order. We can't use an std.DoublyLinkedList, like the other script types,
@@ -83,7 +82,11 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// importmap contains resolved urls.
importmap: std.StringHashMapUnmanaged([:0]const u8),
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
// have we notified the page that all scripts are loaded (used to fire the "load"
// event).
page_notified_of_completion: bool,
pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptManager {
return .{
.page = page,
.async_scripts = .{},
@@ -95,17 +98,14 @@ pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) Script
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
.buffer_pool = BufferPool.init(allocator, 5),
.script_pool = std.heap.MemoryPool(Script).init(allocator),
.page_notified_of_completion = false,
};
}
pub fn deinit(self: *ScriptManager) void {
// necessary to free any buffers scripts may be referencing
// necessary to free any arenas scripts may be referencing
self.reset();
self.buffer_pool.deinit();
self.script_pool.deinit();
self.imported_modules.deinit(self.allocator);
// we don't deinit self.importmap b/c we use the page's arena for its
// allocations.
@@ -114,7 +114,10 @@ pub fn deinit(self: *ScriptManager) void {
pub fn reset(self: *ScriptManager) void {
var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| {
self.buffer_pool.release(value_ptr.buffer);
switch (value_ptr.state) {
.done => |script| script.deinit(),
else => {},
}
}
self.imported_modules.clearRetainingCapacity();
@@ -131,13 +134,13 @@ pub fn reset(self: *ScriptManager) void {
fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", n);
script.deinit(true);
script.deinit();
}
}
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !Http.Headers {
fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers {
var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers);
try self.page.headersForRequest(arena, url, &headers);
return headers;
}
@@ -152,7 +155,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// <script> has already been processed.
return;
}
script_element._executed = true;
const element = script_element.asElement();
if (element.getAttributeSafe(comptime .wrap("nomodule")) != null) {
@@ -185,30 +187,48 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return;
};
var handover = false;
const page = self.page;
const arena = try page.getArena(.{ .debug = "addFromElement" });
errdefer if (!handover) {
page.releaseArena(arena);
};
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = page.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
if (try parseDataURI(page.arena, src)) |data_uri| {
if (try parseDataURI(arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
remote_url = try URL.resolve(page.arena, base_url, src, .{});
remote_url = try URL.resolve(arena, base_url, src, .{});
source = .{ .remote = .{} };
}
} else {
const inline_source = try element.asNode().getTextContentAlloc(page.arena);
var buf = std.Io.Writer.Allocating.init(arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
page.releaseArena(arena);
return;
}
source = .{ .@"inline" = inline_source };
}
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
// Only set _executed (already-started) when we actually have content to execute
script_element._executed = true;
const is_inline = source == .@"inline";
const script = try arena.create(Script);
script.* = .{
.kind = kind,
.node = .{},
.arena = arena,
.manager = self,
.source = source,
.script_element = script_element,
@@ -252,14 +272,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (is_blocking == false) {
self.scriptList(script).remove(&script.node);
}
script.deinit(true);
// Let the outer errdefer handle releasing the arena if client.request fails
}
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.headers = try self.getHeaders(url),
.frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
@@ -270,6 +291,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
handover = true;
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -299,7 +321,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
}
if (script.status == 0) {
// an error (that we already logged)
script.deinit(true);
script.deinit();
return;
}
@@ -308,7 +330,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
self.is_evaluating = true;
defer {
self.is_evaluating = was_evaluating;
script.deinit(true);
script.deinit();
}
return script.eval(page);
}
@@ -340,11 +362,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
}
errdefer _ = self.imported_modules.remove(url);
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
const page = self.page;
const arena = try page.getArena(.{ .debug = "preloadImport" });
errdefer page.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
@@ -354,13 +379,11 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.mode = .import,
};
gop.value_ptr.* = ImportedModule{
.manager = self,
};
gop.value_ptr.* = ImportedModule{};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
@@ -371,26 +394,30 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
});
}
try self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.headers = try self.getHeaders(url),
.cookie_jar = &self.page._session.cookie_jar,
.resource_type = .script,
.notification = self.page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
// This seems wrong since we're not dealing with an async import (unlike
// getAsyncModule below), but all we're trying to do here is pre-load the
// script for execution at some point in the future (when waitForImport is
// called).
self.async_scripts.append(&script.node);
self.client.request(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url),
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
@@ -411,12 +438,12 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
_ = try client.tick(200);
continue;
},
.done => {
.done => |script| {
var shared = false;
const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters;
if (waiters == 0) {
if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr);
} else {
shared = true;
@@ -425,7 +452,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
return .{
.buffer = buffer,
.shared = shared,
.buffer_pool = &self.buffer_pool,
.script = script,
};
},
.err => return error.Failed,
@@ -434,11 +461,14 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
}
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
const page = self.page;
const arena = try page.getArena(.{ .debug = "getAsyncImport" });
errdefer page.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
@@ -453,7 +483,7 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
page.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
@@ -473,22 +503,25 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
try self.client.request(.{
self.async_scripts.append(&script.node);
self.client.request(.{
.url = url,
.method = .GET,
.headers = try self.getHeaders(url),
.frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url),
.ctx = script,
.resource_type = .script,
.cookie_jar = &self.page._session.cookie_jar,
.notification = self.page._session.notification,
.cookie_jar = &page._session.cookie_jar,
.notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
self.async_scripts.append(&script.node);
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
// Called from the Page to let us know it's done parsing the HTML. Necessary that
@@ -513,18 +546,18 @@ fn evaluate(self: *ScriptManager) void {
var script: *Script = @fieldParentPtr("node", n);
switch (script.mode) {
.async => {
defer script.deinit(true);
defer script.deinit();
script.eval(page);
},
.import_async => |ia| {
defer script.deinit(false);
if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
.buffer_pool = &self.buffer_pool,
});
}
},
@@ -550,7 +583,7 @@ fn evaluate(self: *ScriptManager) void {
}
defer {
_ = self.defer_scripts.popFirst();
script.deinit(true);
script.deinit();
}
script.eval(page);
}
@@ -564,19 +597,12 @@ fn evaluate(self: *ScriptManager) void {
// Page makes this safe to call multiple times.
page.documentIsLoaded();
if (self.async_scripts.first == null) {
// Looks like all async scripts are done too!
// Page makes this safe to call multiple times.
page.documentIsComplete();
if (self.async_scripts.first == null and self.page_notified_of_completion == false) {
self.page_notified_of_completion = true;
page.scriptsCompletedLoading();
}
}
pub fn isDone(self: *const ScriptManager) bool {
return self.static_scripts_done and // page is done processing initial html
self.defer_scripts.first == null and // no deferred scripts
self.async_scripts.first == null; // no async scripts
}
fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
const content = script.source.content();
@@ -608,17 +634,31 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
}
pub const Script = struct {
complete: bool,
kind: Kind,
complete: bool,
status: u16 = 0,
source: Source,
url: []const u8,
arena: Allocator,
mode: ExecutionMode,
node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script,
manager: *ScriptManager,
// for debugging a rare production issue
header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0,
debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0,
const Kind = enum {
module,
javascript,
@@ -650,18 +690,15 @@ pub const Script = struct {
import_async: ImportAsync,
};
fn deinit(self: *Script, comptime release_buffer: bool) void {
if ((comptime release_buffer) and self.source == .remote) {
self.manager.buffer_pool.release(self.source.remote);
}
self.manager.script_pool.destroy(self);
fn deinit(self: *Script) void {
self.manager.page.releaseArena(self.arena);
}
fn startCallback(transfer: *Http.Transfer) !void {
fn startCallback(transfer: *HttpClient.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
fn headerCallback(transfer: *Http.Transfer) !bool {
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
const header = &transfer.response_header.?;
self.status = header.status;
@@ -686,28 +723,57 @@ pub const Script = struct {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{});
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state),
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer = self.manager.buffer_pool.get();
var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(transfer: *Http.Transfer, data: []const u8) !void {
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: *Http.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.manager.allocator, data);
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data);
}
fn doneCallback(ctx: *anyopaque) !void {
@@ -724,9 +790,8 @@ pub const Script = struct {
} else if (self.mode == .import) {
manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .done;
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
self.deinit(false);
}
manager.evaluate();
}
@@ -752,7 +817,7 @@ pub const Script = struct {
const manager = self.manager;
manager.scriptList(self).remove(&self.node);
if (manager.shutdown) {
self.deinit(true);
self.deinit();
return;
}
@@ -764,7 +829,7 @@ pub const Script = struct {
},
else => {},
}
self.deinit(true);
self.deinit();
manager.evaluate();
}
@@ -820,13 +885,15 @@ pub const Script = struct {
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback("error", local.toLocal(script_element._on_error), page);
self.executeCallback(comptime .wrap("error"), page);
return;
};
self.executeCallback("load", local.toLocal(script_element._on_load), page);
self.executeCallback(comptime .wrap("load"), page);
return;
}
defer page._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
@@ -845,19 +912,18 @@ pub const Script = struct {
};
if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success, .on_load = script_element._on_load != null });
log.debug(.browser, "executed script", .{ .src = url, .success = success });
}
defer {
// We should run microtasks even if script execution fails.
local.runMicrotasks();
local.runMacrotasks(); // also runs microtasks
_ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback("load", local.toLocal(script_element._on_load), page);
self.executeCallback(comptime .wrap("load"), page);
return;
}
@@ -868,14 +934,12 @@ pub const Script = struct {
.cacheable = cacheable,
});
self.executeCallback("error", local.toLocal(script_element._on_error), page);
self.executeCallback(comptime .wrap("error"), page);
}
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void {
const cb = cb_ orelse return;
fn executeCallback(self: *const Script, typ: String, page: *Page) void {
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| {
const event = Event.initTrusted(typ, .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
@@ -883,89 +947,16 @@ pub const Script = struct {
});
return;
};
defer if (!event._v8_handoff) event.deinit(false);
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
page._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
log.warn(.js, "script callback", .{
.url = self.url,
.type = typ,
.caught = caught,
.err = err,
});
};
}
};
const BufferPool = struct {
count: usize,
available: List = .{},
allocator: Allocator,
max_concurrent_transfers: u8,
mem_pool: std.heap.MemoryPool(Container),
const List = std.SinglyLinkedList;
const Container = struct {
node: List.Node,
buf: std.ArrayList(u8),
};
fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
return .{
.available = .{},
.count = 0,
.allocator = allocator,
.max_concurrent_transfers = max_concurrent_transfers,
.mem_pool = std.heap.MemoryPool(Container).init(allocator),
};
}
fn deinit(self: *BufferPool) void {
const allocator = self.allocator;
var node = self.available.first;
while (node) |n| {
const container: *Container = @fieldParentPtr("node", n);
container.buf.deinit(allocator);
node = n.next;
}
self.mem_pool.deinit();
}
fn get(self: *BufferPool) std.ArrayList(u8) {
const node = self.available.popFirst() orelse {
// return a new buffer
return .{};
};
self.count -= 1;
const container: *Container = @fieldParentPtr("node", node);
defer self.mem_pool.destroy(container);
return container.buf;
}
fn release(self: *BufferPool, buffer: ArrayList(u8)) void {
// create mutable copy
var b = buffer;
if (self.count == self.max_concurrent_transfers) {
b.deinit(self.allocator);
return;
}
const container = self.mem_pool.create() catch |err| {
b.deinit(self.allocator);
log.err(.http, "SM BufferPool release", .{ .err = err });
return;
};
b.clearRetainingCapacity();
container.* = .{ .buf = b, .node = .{} };
self.count += 1;
self.available.prepend(&container.node);
}
};
const ImportAsync = struct {
data: *anyopaque,
callback: ImportAsync.Callback,
@@ -975,12 +966,12 @@ const ImportAsync = struct {
pub const ModuleSource = struct {
shared: bool,
buffer_pool: *BufferPool,
script: *Script,
buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) {
self.buffer_pool.release(self.buffer);
self.script.deinit();
}
}
@@ -990,15 +981,14 @@ pub const ModuleSource = struct {
};
const ImportedModule = struct {
manager: *ScriptManager,
waiters: u16 = 1,
state: State = .loading,
buffer: std.ArrayList(u8) = .{},
waiters: u16 = 1,
const State = enum {
const State = union(enum) {
err,
done,
loading,
done: *Script,
};
};
@@ -1010,23 +1000,35 @@ fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
const data = uri[data_starts + 1 ..];
var data = uri[data_starts + 1 ..];
const unescaped = try URL.unescape(allocator, data);
// Extract the encoding.
const metadata = uri[0..data_starts];
if (std.mem.endsWith(u8, metadata, ";base64")) {
const decoder = std.base64.standard.Decoder;
const decoded_size = try decoder.calcSizeForSlice(data);
const buffer = try allocator.alloc(u8, decoded_size);
errdefer allocator.free(buffer);
try decoder.decode(buffer, data);
data = buffer;
if (std.mem.endsWith(u8, metadata, ";base64") == false) {
return unescaped;
}
return data;
// Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Step 1: Remove all ASCII whitespace
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
for (unescaped) |c| {
if (!std.ascii.isWhitespace(c)) {
stripped.appendAssumeCapacity(c);
}
}
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
// Length % 4 == 1 is invalid
if (trimmed.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const buffer = try allocator.alloc(u8, decoded_size);
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
return buffer;
}
const testing = @import("../testing.zig");

View File

@@ -18,8 +18,10 @@
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const App = @import("../App.zig");
const js = @import("js/js.zig");
const storage = @import("webapi/storage/storage.zig");
@@ -28,59 +30,89 @@ const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Page.QueuedNavigation;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const ArenaPool = App.ArenaPool;
const IS_DEBUG = builtin.mode == .Debug;
// Session is like a browser's tab.
// It owns the js env and the loader for all the pages of the session.
// You can create successively multiple pages for a session, but you must
// deinit a page before running another one.
// deinit a page before running another one. It manages two distinct lifetimes.
//
// The first is the lifetime of the Session itself, where pages are created and
// removed, but share the same cookie jar and navigation history (etc...)
//
// The second is as a container the data needed by the full page hierarchy, i.e. \
// the root page and all of its frames (and all of their frames.)
const Session = @This();
// These are the fields that remain intact for the duration of the Session
browser: *Browser,
notification: *Notification,
// Used to create our Inspector and in the BrowserContext.
arena: Allocator,
// The page's arena is unsuitable for data that has to existing while
// navigating from one page to another. For example, if we're clicking
// on an HREF, the URL exists in the original page (where the click
// originated) but also has to exist in the new page.
// While we could use the Session's arena, this could accumulate a lot of
// memory if we do many navigation events. The `transfer_arena` is meant to
// bridge the gap: existing long enough to store any data needed to end one
// page and start another.
transfer_arena: Allocator,
cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed,
history: History,
navigation: Navigation,
storage_shed: storage.Shed,
notification: *Notification,
cookie_jar: storage.Cookie.Jar,
// These are the fields that get reset whenever the Session's page (the root) is reset.
factory: Factory,
page_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Shared resources for all pages in this session.
// These live for the duration of the page tree (root + frames).
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void = if (IS_DEBUG) .empty else {},
page: ?Page,
queued_navigation: std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Page),
page_id_gen: u32,
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
const arena_pool = browser.arena_pool;
const transfer_arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(transfer_arena);
const arena = try arena_pool.acquire();
errdefer arena_pool.release(arena);
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
self.* = .{
.page = null,
.arena = arena,
.arena_pool = arena_pool,
.page_arena = page_arena,
.factory = Factory.init(page_arena),
.history = .{},
.page_id_gen = 0,
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.queued_navigation = .{},
.queued_queued_navigation = .{},
.notification = notification,
.transfer_arena = transfer_arena,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
}
@@ -89,12 +121,11 @@ pub fn deinit(self: *Session) void {
if (self.page != null) {
self.removePage();
}
const browser = self.browser;
self.cookie_jar.deinit();
self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.transfer_arena);
browser.arena_pool.release(self.arena);
self.storage_shed.deinit(self.browser.app.allocator);
self.arena_pool.release(self.page_arena);
self.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
@@ -104,7 +135,7 @@ pub fn createPage(self: *Session) !*Page {
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
try Page.init(page, self.nextFrameId(), self, null);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page);
@@ -124,28 +155,137 @@ pub fn removePage(self: *Session) void {
self.notification.dispatch(.page_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit();
self.page.?.deinit(false);
self.page = null;
self.navigation.onRemovePage();
self.resetPageResources();
if (comptime IS_DEBUG) {
log.debug(.browser, "remove page", .{});
}
}
pub const GetArenaOpts = struct {
debug: []const u8,
};
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
// Use session's arena (not page_arena) since page_arena gets reset between pages
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing and gop.value_ptr.count != 0) {
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
@panic("ArenaPool Double Use");
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
}
pub fn releaseArena(self: *Session, allocator: Allocator) void {
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
if (comptime builtin.is_test) {
@panic("ArenaPool Double Free");
}
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
const key = key_ orelse {
var opaque_origin: [36]u8 = undefined;
@import("../id.zig").uuidv4(&opaque_origin);
// Origin.init will dupe opaque_origin. It's fine that this doesn't
// get added to self.origins. In fact, it further isolates it. When the
// context is freed, it'll call session.releaseOrigin which will free it.
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
};
const gop = try self.origins.getOrPut(self.arena, key);
if (gop.found_existing) {
const origin = gop.value_ptr.*;
origin.rc += 1;
return origin;
}
errdefer _ = self.origins.remove(key);
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
gop.key_ptr.* = origin.key;
gop.value_ptr.* = origin;
return origin;
}
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
const rc = origin.rc;
if (rc == 1) {
_ = self.origins.remove(origin.key);
origin.deinit(self.browser.app);
} else {
origin.rc = rc - 1;
}
}
/// Reset page_arena and factory for a clean slate.
/// Called when root page is removed.
fn resetPageResources(self: *Session) void {
// Check for arena leaks before releasing
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track.clearRetainingCapacity();
}
// All origins should have been released when contexts were destroyed
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
// Defensive cleanup in case origins leaked
{
const app = self.browser.app;
var it = self.origins.valueIterator();
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins.clearRetainingCapacity();
}
// Release old page_arena and acquire fresh one
self.frame_id_gen = 0;
self.arena_pool.reset(self.page_arena, 64 * 1024);
self.factory = Factory.init(self.page_arena);
}
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
self.page.?.deinit();
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
var current = self.page.?;
const frame_id = current._frame_id;
current.deinit(true);
self.resetPageResources();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self);
try Page.init(page, frame_id, self, null);
return page;
}
@@ -159,53 +299,376 @@ pub const WaitResult = enum {
cdp_socket,
};
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
while (true) {
if (self.page) |*page| {
switch (page.wait(wait_ms)) {
.done => {
if (page._queued_navigation == null) {
return .done;
}
self.processScheduledNavigation() catch return .done;
},
else => |result| return result,
}
} else {
return .no_page;
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return findPageBy(page, "_frame_id", frame_id);
}
pub fn findPageById(self: *Session, id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return findPageBy(page, "id", id);
}
fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
if (@field(page, field) == id) return page;
for (page.frames.items) |f| {
if (findPageBy(f, field, id)) |found| {
return found;
}
}
return null;
}
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
var page = &(self.page orelse return .no_page);
while (true) {
const wait_result = self._wait(page, wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = page.url,
}),
}
return .done;
};
switch (wait_result) {
.done => {
if (self.queued_navigation.items.len == 0) {
return .done;
}
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
},
else => |result| return result,
}
// if we've successfull navigated, we'll give the new page another
// page.wait(wait_ms)
}
}
fn processScheduledNavigation(self: *Session) !void {
defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024);
const url, const opts = blk: {
const qn = self.page.?._queued_navigation.?;
// qn might not be safe to use after self.removePage is called, hence
// this block;
const url = qn.url;
const opts = qn.opts;
fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
// This was already aborted on the page, but it would be pretty
// bad if old requests went to the new page, so let's make double sure
self.browser.http_client.abort();
self.removePage();
const browser = self.browser;
var http_client = browser.http_client;
break :blk .{ url, opts };
};
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
const page = self.createPage() catch |err| {
log.err(.browser, "queued navigation page error", .{
.err = err,
.url = url,
});
return err;
};
while (true) {
switch (page._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
page.navigate(url, opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = url });
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (self.queued_navigation.items.len != 0) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
var ms = blk: {
// if (wait_ms - ms_remaining < 100) {
// if (comptime builtin.is_test) {
// return .done;
// }
// // Look, we want to exit ASAP, but we don't want
// // to exit so fast that we've run none of the
// // background jobs.
// break :blk 50;
// }
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
break :blk browser.msToNextMacrotask() orelse return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
if (!browser.hasBackgroundTasks()) {
return .done;
}
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
ms = 20;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
}
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
const list = &self.queued_navigation;
// Check if page is already queued
for (list.items) |existing| {
if (existing == page) {
// Already queued
return;
}
}
return list.append(self.arena, page);
}
fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation;
if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the
// root page is navigating, then we don't need to process any other
// navigation. Also, the navigation for the root page and for a frame
// is different enough that have two distinct code blocks is, imo,
// better. Yes, there will be duplication.
navigations.clearRetainingCapacity();
return self.processRootQueuedNavigation();
}
const about_blank_queue = &self.queued_queued_navigation;
defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| {
const qn = page._queued_navigation.?;
if (qn.is_about_blank) {
// Defer about:blank to second pass
try about_blank_queue.append(self.arena, page);
continue;
}
self.processFrameNavigation(page, qn) catch |err| {
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
};
}
// Clear the queue after first pass
navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank)
// These may trigger new navigations which go into queued_navigation
for (about_blank_queue.items) |page| {
const qn = page._queued_navigation.?;
try self.processFrameNavigation(page, qn);
}
// Safety: Remove any about:blank navigations that were queued during the
// second pass to prevent infinite loops
var i: usize = 0;
while (i < navigations.items.len) {
const page = navigations.items[i];
if (page._queued_navigation) |qn| {
if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i);
continue;
}
}
i += 1;
}
}
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
lp.assert(page.parent != null, "root queued navigation", .{});
const iframe = page.iframe.?;
const parent = page.parent.?;
page._queued_navigation = null;
defer self.releaseArena(qn.arena);
errdefer iframe._window = null;
const parent_notified = page._parent_notified;
if (parent_notified) {
// we already notified the parent that we had loaded
parent._pending_loads += 1;
}
const frame_id = page._frame_id;
page.deinit(true);
page.* = undefined;
try Page.init(page, frame_id, self, parent);
errdefer {
for (parent.frames.items, 0..) |frame, i| {
if (frame == page) {
parent.frames_sorted = false;
_ = parent.frames.swapRemove(i);
break;
}
}
if (parent_notified) {
parent._pending_loads -= 1;
}
page.deinit(true);
}
page.iframe = iframe;
iframe._window = page.window;
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued frame navigation error", .{ .err = err });
return err;
};
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_page = &self.page.?;
const frame_id = current_page._frame_id;
// create a copy before the page is cleared
const qn = current_page._queued_navigation.?;
current_page._queued_navigation = null;
defer self.arena_pool.release(qn.arena);
// HACK
// Mark as released in tracking BEFORE removePage clears the map.
// We can't call releaseArena() because that would also return the arena
// to the pool, making the memory invalid before we use qn.url/qn.opts.
if (comptime IS_DEBUG) {
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
found.count = 0;
}
}
self.removePage();
self.page = @as(Page, undefined);
const new_page = &self.page.?;
try Page.init(new_page, frame_id, self, null);
// Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(new_page);
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.notification.dispatch(.page_created, new_page);
new_page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err });
return err;
};
}
pub fn nextFrameId(self: *Session) u32 {
const id = self.frame_id_gen +% 1;
self.frame_id_gen = id;
return id;
}
pub fn nextPageId(self: *Session) u32 {
const id = self.page_id_gen +% 1;
self.page_id_gen = id;
return id;
}

View File

@@ -20,44 +20,61 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const ResolveOpts = struct {
encode: bool = false,
always_dupe: bool = false,
};
// path is anytype, so that it can be used with both []const u8 and [:0]const u8
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(path);
if (base.len == 0 or isCompleteHTTPUrl(path)) {
if (comptime opts.always_dupe or !isNullTerminated(PT)) {
return allocator.dupeZ(u8, path);
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
}
if (path.len == 0) {
if (comptime opts.always_dupe) {
return allocator.dupeZ(u8, base);
const duped = try allocator.dupeZ(u8, base);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, base, opts);
}
return base;
}
if (path[0] == '?') {
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
return processResolved(allocator, result, opts);
}
if (path[0] == '#') {
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
return processResolved(allocator, result, opts);
}
if (std.mem.startsWith(u8, path, "//")) {
// network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse {
if (comptime isNullTerminated(PT)) {
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path;
}
return allocator.dupeZ(u8, path);
const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
};
const protocol = base[0 .. index + 1];
return std.mem.joinZ(allocator, "", &.{ protocol, path });
const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
return processResolved(allocator, result, opts);
}
const scheme_end = std.mem.indexOf(u8, base, "://");
@@ -65,7 +82,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
if (path[0] == '/') {
return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
return processResolved(allocator, result, opts);
}
var normalized_base: []const u8 = base[0..path_start];
@@ -127,7 +145,122 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
// we always have an extra space
out[out_i] = 0;
return out[0..out_i :0];
return processResolved(allocator, out[0..out_i :0], opts);
}
fn processResolved(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 {
if (!comptime opts.encode) {
return url;
}
return ensureEncoded(allocator, url);
}
pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const scheme_end = std.mem.indexOf(u8, url, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;
const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?');
const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#');
const path_end = query_start orelse fragment_start orelse url.len;
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path);
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
const encoded = try percentEncodeSegment(allocator, query_to_encode, .query);
break :blk encoded;
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query);
break :blk encoded;
} else null;
if (encoded_path.ptr == path_to_encode.ptr and
(encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and
(encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr))
{
// nothing has changed
return url;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20);
try buf.appendSlice(allocator, url[0..path_start]);
try buf.appendSlice(allocator, encoded_path);
if (encoded_query) |eq| {
try buf.append(allocator, '?');
try buf.appendSlice(allocator, eq);
}
if (encoded_fragment) |ef| {
try buf.append(allocator, '#');
try buf.appendSlice(allocator, ef);
}
try buf.append(allocator, 0);
return buf.items[0 .. buf.items.len - 1 :0];
}
const EncodeSet = enum { path, query, userinfo };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed
var needs_encoding = false;
for (segment) |c| {
if (shouldPercentEncode(c, encode_set)) {
needs_encoding = true;
break;
}
}
if (!needs_encoding) {
return segment;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, segment.len + 10);
var i: usize = 0;
while (i < segment.len) : (i += 1) {
const c = segment[i];
// Check if this is an already-encoded sequence (%XX)
if (c == '%' and i + 2 < segment.len) {
const end = i + 2;
const h1 = segment[i + 1];
const h2 = segment[end];
if (std.ascii.isHex(h1) and std.ascii.isHex(h2)) {
try buf.appendSlice(allocator, segment[i .. end + 1]);
i = end;
continue;
}
}
if (shouldPercentEncode(c, encode_set)) {
try buf.writer(allocator).print("%{X:0>2}", .{c});
} else {
try buf.append(allocator, c);
}
}
return buf.items;
}
fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in path/query but some must be encoded in userinfo
'!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries but not in paths or userinfo
'?' => encode_set != .query,
// Everything else needs encoding (including space)
else => true,
};
}
fn isNullTerminated(comptime value: type) bool {
@@ -144,6 +277,11 @@ pub fn isCompleteHTTPUrl(url: []const u8) bool {
return false;
}
// blob: and data: URLs are complete but don't follow scheme:// pattern
if (std.mem.startsWith(u8, url, "blob:") or std.mem.startsWith(u8, url, "data:")) {
return true;
}
// Check if there's a scheme (protocol) ending with ://
const colon_pos = std.mem.indexOfScalar(u8, url, ':') orelse return false;
@@ -384,7 +522,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const search = getSearch(current);
const hash = getHash(current);
// Check if the host includes a port
// Check if the new value includes a port
const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
const clean_host = if (colon_pos) |pos| blk: {
const port_str = value[pos + 1 ..];
@@ -396,7 +534,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
break :blk value[0..pos];
}
break :blk value;
} else value;
} else blk: {
// No port in new value - preserve existing port
const current_port = getPort(current);
if (current_port.len > 0) {
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port });
}
break :blk value;
};
return buildUrl(allocator, protocol, clean_host, pathname, search, hash);
}
@@ -414,6 +559,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato
pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
const hostname = getHostname(current);
const protocol = getProtocol(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
// Handle null or default ports
const new_host = if (value) |port_str| blk: {
@@ -430,7 +578,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator)
break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
} else hostname;
return setHost(current, new_host, allocator);
return buildUrl(allocator, protocol, new_host, pathname, search, hash);
}
pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
@@ -478,6 +626,64 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const password = getPassword(current);
const encoded_username = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash);
}
pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 {
const protocol = getProtocol(current);
const host = getHost(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
const username = getUsername(current);
const encoded_password = try percentEncodeSegment(allocator, value, .userinfo);
return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash);
}
fn buildUrlWithUserInfo(
allocator: Allocator,
protocol: []const u8,
username: []const u8,
password: []const u8,
host: []const u8,
pathname: []const u8,
search: []const u8,
hash: []const u8,
) ![:0]const u8 {
if (username.len == 0 and password.len == 0) {
return buildUrl(allocator, protocol, host, pathname, search, hash);
} else if (password.len == 0) {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{
protocol,
username,
host,
pathname,
search,
hash,
}, 0);
} else {
return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{
protocol,
username,
password,
host,
pathname,
search,
hash,
}, 0);
}
}
pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
if (query_string.len == 0) {
return arena.dupeZ(u8, url);
@@ -512,6 +718,33 @@ pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 {
);
}
pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
if (std.mem.indexOfScalar(u8, input, '%') == null) {
return input;
}
var result = try std.ArrayList(u8).initCapacity(arena, input.len);
var i: usize = 0;
while (i < input.len) {
if (input[i] == '%' and i + 2 < input.len) {
const hex = input[i + 1 .. i + 3];
const byte = std.fmt.parseInt(u8, hex, 16) catch {
result.appendAssumeCapacity(input[i]);
i += 1;
continue;
};
result.appendAssumeCapacity(byte);
i += 3;
} else {
result.appendAssumeCapacity(input[i]);
i += 1;
}
}
return result.items;
}
const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -691,6 +924,297 @@ test "URL: resolve" {
}
}
test "URL: ensureEncoded" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
.{
.url = "https://example.com/over 9000!",
.expected = "https://example.com/over%209000!",
},
.{
.url = "http://example.com/hello world.html",
.expected = "http://example.com/hello%20world.html",
},
.{
.url = "https://example.com/file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.url = "https://example.com/page?query=hello world",
.expected = "https://example.com/page?query=hello%20world",
},
.{
.url = "https://example.com/page?a=1&b=value with spaces",
.expected = "https://example.com/page?a=1&b=value%20with%20spaces",
},
.{
.url = "https://example.com/page#section one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/my path?query=my value#my anchor",
.expected = "https://example.com/my%20path?query=my%20value#my%20anchor",
},
.{
.url = "https://example.com/already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.url = "https://example.com/file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/caf%C3%A9",
.expected = "https://example.com/caf%C3%A9",
},
.{
.url = "https://example.com/page?query=already%20encoded",
.expected = "https://example.com/page?query=already%20encoded",
},
.{
.url = "https://example.com/page?a=1&b=value%20here",
.expected = "https://example.com/page?a=1&b=value%20here",
},
.{
.url = "https://example.com/page#section%20one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
.{
.url = "https://example.com/page?a=encoded%20value&b=not encoded",
.expected = "https://example.com/page?a=encoded%20value&b=not%20encoded",
},
.{
.url = "https://example.com/my%20path?query=not encoded#encoded%20anchor",
.expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor",
},
.{
.url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
.expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
},
.{
.url = "https://example.com/path-with_under~tilde",
.expected = "https://example.com/path-with_under~tilde",
},
.{
.url = "https://example.com/sub-delims!$&'()*+,;=",
.expected = "https://example.com/sub-delims!$&'()*+,;=",
},
.{
.url = "https://example.com",
.expected = "https://example.com",
},
.{
.url = "https://example.com?query=value",
.expected = "https://example.com?query=value",
},
.{
.url = "https://example.com/clean/path",
.expected = "https://example.com/clean/path",
},
.{
.url = "https://example.com/path?clean=query#clean-fragment",
.expected = "https://example.com/path?clean=query#clean-fragment",
},
.{
.url = "https://example.com/100% complete",
.expected = "https://example.com/100%25%20complete",
},
.{
.url = "https://example.com/path?value=100% done",
.expected = "https://example.com/path?value=100%25%20done",
},
.{
.url = "about:blank",
.expected = "about:blank",
},
};
for (cases) |case| {
const result = try ensureEncoded(testing.arena_allocator, case.url);
try testing.expectString(case.expected, result);
}
}
test "URL: resolve with encoding" {
defer testing.reset();
const Case = struct {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
// Spaces should be encoded as %20, but ! is allowed
.{
.base = "https://example.com/dir/",
.path = "over 9000!",
.expected = "https://example.com/dir/over%209000!",
},
.{
.base = "https://example.com/",
.path = "hello world.html",
.expected = "https://example.com/hello%20world.html",
},
// Multiple spaces
.{
.base = "https://example.com/",
.path = "path with multiple spaces",
.expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces",
},
// Special characters that need encoding
.{
.base = "https://example.com/",
.path = "file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.base = "https://example.com/",
.path = "file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.base = "https://example.com/",
.path = "file<test>.html",
.expected = "https://example.com/file%3Ctest%3E.html",
},
.{
.base = "https://example.com/",
.path = "file\"quote\".html",
.expected = "https://example.com/file%22quote%22.html",
},
.{
.base = "https://example.com/",
.path = "file|pipe.html",
.expected = "https://example.com/file%7Cpipe.html",
},
.{
.base = "https://example.com/",
.path = "file\\backslash.html",
.expected = "https://example.com/file%5Cbackslash.html",
},
.{
.base = "https://example.com/",
.path = "file^caret.html",
.expected = "https://example.com/file%5Ecaret.html",
},
.{
.base = "https://example.com/",
.path = "file`backtick`.html",
.expected = "https://example.com/file%60backtick%60.html",
},
// Characters that should NOT be encoded
.{
.base = "https://example.com/",
.path = "path-with_under~tilde.html",
.expected = "https://example.com/path-with_under~tilde.html",
},
.{
.base = "https://example.com/",
.path = "path/with/slashes",
.expected = "https://example.com/path/with/slashes",
},
.{
.base = "https://example.com/",
.path = "sub-delims!$&'()*+,;=.html",
.expected = "https://example.com/sub-delims!$&'()*+,;=.html",
},
// Already encoded characters should not be double-encoded
.{
.base = "https://example.com/",
.path = "already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.base = "https://example.com/",
.path = "file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
// Mix of encoded and unencoded
.{
.base = "https://example.com/",
.path = "part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
// Query strings and fragments ARE encoded
.{
.base = "https://example.com/",
.path = "file name.html?query=value with spaces",
.expected = "https://example.com/file%20name.html?query=value%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file name.html#anchor with spaces",
.expected = "https://example.com/file%20name.html#anchor%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file.html?hello=world !",
.expected = "https://example.com/file.html?hello=world%20!",
},
// Query structural characters should NOT be encoded
.{
.base = "https://example.com/",
.path = "file.html?a=1&b=2",
.expected = "https://example.com/file.html?a=1&b=2",
},
// Relative paths with encoding
.{
.base = "https://example.com/dir/page.html",
.path = "../other dir/file.html",
.expected = "https://example.com/other%20dir/file.html",
},
.{
.base = "https://example.com/dir/",
.path = "./sub dir/file.html",
.expected = "https://example.com/dir/sub%20dir/file.html",
},
// Absolute paths with encoding
.{
.base = "https://example.com/some/path",
.path = "/absolute path/file.html",
.expected = "https://example.com/absolute%20path/file.html",
},
// Unicode/high bytes (though ideally these should be UTF-8 encoded first)
.{
.base = "https://example.com/",
.path = "café",
.expected = "https://example.com/caf%C3%A9",
},
// Empty path
.{
.base = "https://example.com/",
.path = "",
.expected = "https://example.com/",
},
// Complete URL as path (should not be encoded)
.{
.base = "https://example.com/",
.path = "https://other.com/path with spaces",
.expected = "https://other.com/path%20with%20spaces",
},
};
for (cases) |case| {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{ .encode = true });
try testing.expectString(case.expected, result);
}
}
test "URL: eqlDocument" {
defer testing.reset();
{
@@ -816,3 +1340,77 @@ test "URL: getRobotsUrl" {
try testing.expectString("https://example.com/robots.txt", url);
}
}
test "URL: unescape" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const result = try unescape(arena, "hello world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "hello%20world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "%48%65%6c%6c%6f");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "%48%65%6C%6C%6F");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "a%3Db");
try testing.expectEqual("a=b", result);
}
{
const result = try unescape(arena, "a%3DB");
try testing.expectEqual("a=B", result);
}
{
const result = try unescape(arena, "ZDIgPSAndHdvJzs%3D");
try testing.expectEqual("ZDIgPSAndHdvJzs=", result);
}
{
const result = try unescape(arena, "%5a%44%4d%67%50%53%41%6e%64%47%68%79%5a%57%55%6e%4f%77%3D%3D");
try testing.expectEqual("ZDMgPSAndGhyZWUnOw==", result);
}
{
const result = try unescape(arena, "hello%2world");
try testing.expectEqual("hello%2world", result);
}
{
const result = try unescape(arena, "hello%ZZworld");
try testing.expectEqual("hello%ZZworld", result);
}
{
const result = try unescape(arena, "hello%");
try testing.expectEqual("hello%", result);
}
{
const result = try unescape(arena, "hello%2");
try testing.expectEqual("hello%2", result);
}
}
test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "example.com", getHost("https://example.com/path"));
try testing.expectEqualSlices(u8, "example.com:443", getHost("https://example.com:443/"));
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
}

104
src/browser/actions.zig Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("../lightpanda.zig");
const DOMNode = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Page = @import("Page.zig");
pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = 0,
.clientY = 0,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
lp.log.err(.app, "click failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
if (el.is(Element.Html.Input)) |input| {
input.setValue(text, page) catch |err| {
lp.log.err(.app, "fill input failed", .{ .err = err });
return error.ActionFailed;
};
} else if (el.is(Element.Html.TextArea)) |textarea| {
textarea.setValue(text, page) catch |err| {
lp.log.err(.app, "fill textarea failed", .{ .err = err });
return error.ActionFailed;
};
} else if (el.is(Element.Html.Select)) |select| {
select.setValue(text, page) catch |err| {
lp.log.err(.app, "fill select failed", .{ .err = err });
return error.ActionFailed;
};
} else {
return error.InvalidNodeType;
}
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
}
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
if (node) |n| {
const el = n.is(Element) orelse return error.InvalidNodeType;
if (x) |val| {
el.setScrollLeft(val, page) catch |err| {
lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
return error.ActionFailed;
};
}
if (y) |val| {
el.setScrollTop(val, page) catch |err| {
lp.log.err(.app, "setScrollTop failed", .{ .err = err });
return error.ActionFailed;
};
}
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
};
} else {
page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| {
lp.log.err(.app, "scroll failed", .{ .err = err });
return error.ActionFailed;
};
}
}

View File

@@ -480,10 +480,11 @@ fn consumeName(self: *Tokenizer) []const u8 {
self.consumeEscape();
},
0x0 => self.advance(1),
'\x80'...'\xBF', '\xC0'...'\xEF', '\xF0'...'\xFF' => {
// This byte *is* part of a multi-byte code point,
// well end up copying the whole code point before this loop does something else.
self.advance(1);
'\x80'...'\xFF' => {
// Non-ASCII: advance over the complete UTF-8 code point in one step.
// Using consumeChar() instead of advance(1) ensures we never land on
// a continuation byte, which advance() asserts against.
self.consumeChar();
},
else => {
if (self.hasNonAsciiAt(0)) {
@@ -583,7 +584,7 @@ fn consumeNumeric(self: *Tokenizer) Token {
};
self.advance(2);
} else if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(2))) {
} else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
self.advance(1);
} else {
break :blk;

View File

@@ -20,16 +20,15 @@ const std = @import("std");
const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Slot = @import("webapi/element/html/Slot.zig");
const IFrame = @import("webapi/element/html/IFrame.zig");
pub const RootOpts = struct {
with_base: bool = false,
strip: Opts.Strip = .{},
shadow: Opts.Shadow = .rendered,
};
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const Opts = struct {
strip: Strip = .{},
shadow: Shadow = .rendered,
with_base: bool = false,
with_frames: bool = false,
strip: Opts.Strip = .{},
shadow: Opts.Shadow = .rendered,
pub const Strip = struct {
js: bool = false,
@@ -49,7 +48,7 @@ pub const Opts = struct {
};
};
pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *Page) !void {
pub fn root(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
blk: {
// Ideally we just render the doctype which is part of the document
@@ -71,7 +70,7 @@ pub fn root(doc: *Node.Document, opts: RootOpts, writer: *std.Io.Writer, page: *
}
}
return deep(doc.asNode(), .{ .strip = opts.strip, .shadow = opts.shadow }, writer, page);
return deep(doc.asNode(), opts, writer, page);
}
pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
@@ -83,19 +82,19 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
.cdata => |cd| {
if (node.is(Node.CData.Comment)) |_| {
try writer.writeAll("<!--");
try writer.writeAll(cd.getData());
try writer.writeAll(cd.getData().str());
try writer.writeAll("-->");
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
try writer.writeAll("<?");
try writer.writeAll(pi._target);
try writer.writeAll(" ");
try writer.writeAll(cd.getData());
try writer.writeAll(cd.getData().str());
try writer.writeAll("?>");
} else {
if (shouldEscapeText(node._parent)) {
try writeEscapedText(cd.getData(), writer);
try writeEscapedText(cd.getData().str(), writer);
} else {
try writer.writeAll(cd.getData());
try writer.writeAll(cd.getData().str());
}
}
},
@@ -140,7 +139,24 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
}
}
try children(node, opts, writer, page);
if (opts.with_frames and el.is(IFrame) != null) {
const frame = el.as(IFrame);
if (frame.getContentDocument()) |doc| {
// A frame's document should always ahave a page, but
// I'm not willing to crash a release build on that assertion.
if (comptime IS_DEBUG) {
std.debug.assert(doc._page != null);
}
if (doc._page) |frame_page| {
try writer.writeByte('\n');
root(doc, opts, writer, frame_page) catch return error.WriteFailed;
try writer.writeByte('\n');
}
}
} else {
try children(node, opts, writer, page);
}
if (!isVoidElement(el)) {
try writer.writeAll("</");
try writer.writeAll(el.getTagNameDump());
@@ -172,7 +188,11 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
try writer.writeAll(">\n");
},
.document_fragment => try children(node, opts, writer, page),
.attribute => unreachable,
.attribute => {
// Not called normally, but can be called via XMLSerializer.serializeToString
// in which case it should return an empty string
try writer.writeAll("");
},
}
}
@@ -294,6 +314,12 @@ fn shouldEscapeText(node_: ?*Node) bool {
if (node.is(Node.Element.Html.Script) != null) {
return false;
}
// When scripting is enabled, <noscript> is a raw text element per the HTML spec
// (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments).
// Its text content must not be HTML-escaped during serialization.
if (node.is(Node.Element.Html.Generic)) |generic| {
if (generic._tag == .noscript) return false;
}
return true;
}
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {

581
src/browser/interactive.zig Normal file
View File

@@ -0,0 +1,581 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Allocator = std.mem.Allocator;
pub const InteractivityType = enum {
native,
aria,
contenteditable,
listener,
focusable,
};
pub const InteractiveElement = struct {
node: *Node,
tag_name: []const u8,
role: ?[]const u8,
name: ?[]const u8,
interactivity_type: InteractivityType,
listener_types: []const []const u8,
disabled: bool,
tab_index: i32,
id: ?[]const u8,
class: ?[]const u8,
href: ?[]const u8,
input_type: ?[]const u8,
value: ?[]const u8,
element_name: ?[]const u8,
placeholder: ?[]const u8,
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("tagName");
try jw.write(self.tag_name);
try jw.objectField("role");
try jw.write(self.role);
try jw.objectField("name");
try jw.write(self.name);
try jw.objectField("type");
try jw.write(@tagName(self.interactivity_type));
if (self.listener_types.len > 0) {
try jw.objectField("listeners");
try jw.beginArray();
for (self.listener_types) |lt| {
try jw.write(lt);
}
try jw.endArray();
}
if (self.disabled) {
try jw.objectField("disabled");
try jw.write(true);
}
try jw.objectField("tabIndex");
try jw.write(self.tab_index);
if (self.id) |v| {
try jw.objectField("id");
try jw.write(v);
}
if (self.class) |v| {
try jw.objectField("class");
try jw.write(v);
}
if (self.href) |v| {
try jw.objectField("href");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.element_name) |v| {
try jw.objectField("elementName");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
try jw.endObject();
}
};
/// Collect all interactive elements under `root`.
pub fn collectInteractiveElements(
root: *Node,
arena: Allocator,
page: *Page,
) ![]InteractiveElement {
// Pre-build a map of event_target pointer → event type names,
// so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena);
var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const html_el = el.is(Element.Html) orelse continue;
// Skip non-visual elements that are never user-interactive.
switch (el.getTag()) {
.script, .style, .link, .meta, .head, .noscript, .template => continue,
else => {},
}
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
const listener_types = getListenerTypes(
el.asEventTarget(),
listener_targets,
);
try results.append(arena, .{
.node = node,
.tag_name = el.getTagNameLower(),
.role = getRole(el),
.name = try getAccessibleName(el, arena),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = isDisabled(el),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
.href = if (el.getAttributeSafe(comptime .wrap("href"))) |href|
URL.resolve(arena, page.base(), href, .{ .encode = true }) catch href
else
null,
.input_type = getInputType(el),
.value = getInputValue(el),
.element_name = el.getAttributeSafe(comptime .wrap("name")),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
});
}
return results.items;
}
pub const ListenerTargetMap = std.AutoHashMapUnmanaged(usize, std.ArrayList([]const u8));
/// Pre-build a map from event_target pointer → list of event type names.
/// This lets both classifyInteractivity (O(1) "has any?") and
/// getListenerTypes (O(1) "which ones?") avoid re-iterating per element.
pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap {
var map = ListenerTargetMap{};
// addEventListener registrations
var it = page._event_manager.lookup.iterator();
while (it.next()) |entry| {
const list = entry.value_ptr.*;
if (list.first != null) {
const gop = try map.getOrPut(arena, entry.key_ptr.event_target);
if (!gop.found_existing) gop.value_ptr.* = .empty;
try gop.value_ptr.append(arena, entry.key_ptr.type_string.str());
}
}
// Inline handlers (onclick, onmousedown, etc.)
var attr_it = page._event_target_attr_listeners.iterator();
while (attr_it.next()) |entry| {
const gop = try map.getOrPut(arena, @intFromPtr(entry.key_ptr.target));
if (!gop.found_existing) gop.value_ptr.* = .empty;
// Strip "on" prefix to get the event type name.
try gop.value_ptr.append(arena, @tagName(entry.key_ptr.handler)[2..]);
}
return map;
}
pub fn classifyInteractivity(
el: *Element,
html_el: *Element.Html,
listener_targets: ListenerTargetMap,
) ?InteractivityType {
// 1. Native interactive by tag
switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native,
.anchor, .area => {
if (el.getAttributeSafe(comptime .wrap("href")) != null) return .native;
},
.input => {
if (el.is(Element.Html.Input)) |input| {
if (input._input_type != .hidden) return .native;
}
},
else => {},
}
// 2. ARIA interactive role
if (el.getAttributeSafe(comptime .wrap("role"))) |role| {
if (isInteractiveRole(role)) return .aria;
}
// 3. contenteditable (15 bytes, exceeds SSO limit for comptime)
if (el.getAttributeSafe(.wrap("contenteditable"))) |ce| {
if (ce.len == 0 or std.ascii.eqlIgnoreCase(ce, "true")) return .contenteditable;
}
// 4. Event listeners (addEventListener or inline handlers)
const et_ptr = @intFromPtr(html_el.asEventTarget());
if (listener_targets.get(et_ptr) != null) return .listener;
// 5. Explicitly focusable via tabindex.
// Only count elements with an EXPLICIT tabindex attribute,
// since getTabIndex() returns 0 for all interactive tags by default
// (including anchors without href and hidden inputs).
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |_| {
if (html_el.getTabIndex() >= 0) return .focusable;
}
return null;
}
pub fn isInteractiveRole(role: []const u8) bool {
const MAX_LEN = "menuitemcheckbox".len;
if (role.len > MAX_LEN) return false;
var buf: [MAX_LEN]u8 = undefined;
const lowered = std.ascii.lowerString(&buf, role);
const interactive_roles = std.StaticStringMap(void).initComptime(.{
.{ "button", {} },
.{ "checkbox", {} },
.{ "combobox", {} },
.{ "iframe", {} },
.{ "link", {} },
.{ "listbox", {} },
.{ "menuitem", {} },
.{ "menuitemcheckbox", {} },
.{ "menuitemradio", {} },
.{ "option", {} },
.{ "radio", {} },
.{ "searchbox", {} },
.{ "slider", {} },
.{ "spinbutton", {} },
.{ "switch", {} },
.{ "tab", {} },
.{ "textbox", {} },
.{ "treeitem", {} },
});
return interactive_roles.has(lowered);
}
pub fn isContentRole(role: []const u8) bool {
const MAX_LEN = "columnheader".len;
if (role.len > MAX_LEN) return false;
var buf: [MAX_LEN]u8 = undefined;
const lowered = std.ascii.lowerString(&buf, role);
const content_roles = std.StaticStringMap(void).initComptime(.{
.{ "article", {} },
.{ "cell", {} },
.{ "columnheader", {} },
.{ "gridcell", {} },
.{ "heading", {} },
.{ "listitem", {} },
.{ "main", {} },
.{ "navigation", {} },
.{ "region", {} },
.{ "rowheader", {} },
});
return content_roles.has(lowered);
}
fn getRole(el: *Element) ?[]const u8 {
// Explicit role attribute takes precedence
if (el.getAttributeSafe(comptime .wrap("role"))) |role| return role;
// Implicit role from tag
return switch (el.getTag()) {
.button, .summary => "button",
.anchor, .area => if (el.getAttributeSafe(comptime .wrap("href")) != null) "link" else null,
.input => blk: {
if (el.is(Element.Html.Input)) |input| {
break :blk switch (input._input_type) {
.text, .tel, .url, .email => "textbox",
.checkbox => "checkbox",
.radio => "radio",
.button, .submit, .reset, .image => "button",
.range => "slider",
.number => "spinbutton",
.search => "searchbox",
else => null,
};
}
break :blk null;
},
.select => "combobox",
.textarea => "textbox",
.details => "group",
else => null,
};
}
fn getAccessibleName(el: *Element, arena: Allocator) !?[]const u8 {
// aria-label
if (el.getAttributeSafe(comptime .wrap("aria-label"))) |v| {
if (v.len > 0) return v;
}
// alt (for img, input[type=image])
if (el.getAttributeSafe(comptime .wrap("alt"))) |v| {
if (v.len > 0) return v;
}
// title
if (el.getAttributeSafe(comptime .wrap("title"))) |v| {
if (v.len > 0) return v;
}
// placeholder
if (el.getAttributeSafe(comptime .wrap("placeholder"))) |v| {
if (v.len > 0) return v;
}
// value (for buttons)
if (el.getTag() == .input) {
if (el.getAttributeSafe(comptime .wrap("value"))) |v| {
if (v.len > 0) return v;
}
}
// Text content (first non-empty text node, trimmed)
return try getTextContent(el.asNode(), arena);
}
fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
var tw: TreeWalker.FullExcludeSelf = .init(node, .{});
var arr: std.ArrayList(u8) = .empty;
var single_chunk: ?[]const u8 = null;
while (tw.next()) |child| {
// Skip text inside script/style elements.
if (child.is(Element)) |el| {
switch (el.getTag()) {
.script, .style => {
tw.skipChildren();
continue;
},
else => {},
}
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
if (content.len > 0) {
if (single_chunk == null and arr.items.len == 0) {
single_chunk = content;
} else {
if (single_chunk) |sc| {
try arr.appendSlice(arena, sc);
try arr.append(arena, ' ');
single_chunk = null;
}
try arr.appendSlice(arena, content);
try arr.append(arena, ' ');
}
}
}
}
}
if (single_chunk) |sc| return sc;
if (arr.items.len == 0) return null;
// strip out trailing space
return arr.items[0 .. arr.items.len - 1];
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input._input_type.toString();
}
return null;
}
fn getInputValue(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
return input.getValue();
}
return null;
}
/// Get all event listener types registered on this target.
fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) []const []const u8 {
if (listener_targets.get(@intFromPtr(target))) |types| return types.items;
return &.{};
}
const testing = @import("../testing.zig");
fn testInteractive(html: []const u8) ![]InteractiveElement {
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectInteractiveElements(div.asNode(), page.call_arena, page);
}
test "browser.interactive: button" {
const elements = try testInteractive("<button>Click me</button>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("button", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual("Click me", elements[0].name.?);
try testing.expectEqual(InteractivityType.native, elements[0].interactivity_type);
}
test "browser.interactive: anchor with href" {
const elements = try testInteractive("<a href=\"/page\">Link</a>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("a", elements[0].tag_name);
try testing.expectEqual("link", elements[0].role.?);
try testing.expectEqual("Link", elements[0].name.?);
}
test "browser.interactive: anchor without href" {
const elements = try testInteractive("<a>Not a link</a>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: input types" {
const elements = try testInteractive(
\\<input type="text" placeholder="Search">
\\<input type="hidden" name="csrf">
);
try testing.expectEqual(1, elements.len);
try testing.expectEqual("input", elements[0].tag_name);
try testing.expectEqual("text", elements[0].input_type.?);
try testing.expectEqual("Search", elements[0].placeholder.?);
}
test "browser.interactive: select and textarea" {
const elements = try testInteractive(
\\<select name="color"><option>Red</option></select>
\\<textarea name="msg"></textarea>
);
try testing.expectEqual(2, elements.len);
try testing.expectEqual("select", elements[0].tag_name);
try testing.expectEqual("textarea", elements[1].tag_name);
}
test "browser.interactive: aria role" {
const elements = try testInteractive("<div role=\"button\">Custom</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual("div", elements[0].tag_name);
try testing.expectEqual("button", elements[0].role.?);
try testing.expectEqual(InteractivityType.aria, elements[0].interactivity_type);
}
test "browser.interactive: contenteditable" {
const elements = try testInteractive("<div contenteditable=\"true\">Edit me</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.contenteditable, elements[0].interactivity_type);
}
test "browser.interactive: tabindex" {
const elements = try testInteractive("<div tabindex=\"0\">Focusable</div>");
try testing.expectEqual(1, elements.len);
try testing.expectEqual(InteractivityType.focusable, elements[0].interactivity_type);
try testing.expectEqual(@as(i32, 0), elements[0].tab_index);
}
test "browser.interactive: disabled" {
const elements = try testInteractive("<button disabled>Off</button>");
try testing.expectEqual(1, elements.len);
try testing.expect(elements[0].disabled);
}
test "browser.interactive: disabled by fieldset" {
const elements = try testInteractive(
\\<fieldset disabled>
\\ <button>Disabled</button>
\\ <legend><button>In legend</button></legend>
\\</fieldset>
);
try testing.expectEqual(2, elements.len);
// Button outside legend is disabled by fieldset
try testing.expect(elements[0].disabled);
// Button inside first legend is NOT disabled
try testing.expect(!elements[1].disabled);
}
test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: details and summary" {
const elements = try testInteractive("<details><summary>More</summary><p>Content</p></details>");
try testing.expectEqual(2, elements.len);
try testing.expectEqual("details", elements[0].tag_name);
try testing.expectEqual("summary", elements[1].tag_name);
}
test "browser.interactive: mixed elements" {
const elements = try testInteractive(
\\<div>
\\ <a href="/home">Home</a>
\\ <p>Some text</p>
\\ <button id="btn1">Submit</button>
\\ <input type="email" placeholder="Email">
\\ <div>Not interactive</div>
\\ <div role="tab">Tab</div>
\\</div>
);
try testing.expectEqual(4, elements.len);
}

View File

@@ -40,8 +40,8 @@ prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
initWithContext(self, Context.fromC(v8_context), v8_context);
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
initWithContext(self, ctx, v8_context);
}
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
@@ -60,6 +60,11 @@ fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context)
ctx.local = &self.local;
}
pub fn initFromHandle(self: *Caller, handle: ?*const v8.FunctionCallbackInfo) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?;
self.init(isolate);
}
pub fn deinit(self: *Caller) void {
const ctx = self.local.ctx;
const call_depth = ctx.call_depth - 1;
@@ -251,7 +256,33 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getEnumerator(T, local, func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
if (@typeInfo(F).@"fn".params.len == 2) {
@field(args, "1") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
@@ -274,7 +305,7 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime getter: bool
else => ret,
};
if (comptime getter) {
if (comptime with_value) {
info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
}
// intercepted
@@ -302,15 +333,20 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = local.isolate;
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .warn)) {
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
if (comptime IS_DEBUG and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .debug)) {
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err) == null) {
// This isn't a DOMException, let's log it
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
}
}
}
const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: {
@@ -333,7 +369,7 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
// this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(local: *const Local, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = serializeFunctionArgs(local, info) catch "failed to serialize args";
log.info(.js, "function call error", .{
log.debug(.js, "function call error", .{
.type = type_name,
.func = func,
.err = err,
@@ -410,6 +446,11 @@ pub const FunctionCallbackInfo = struct {
return .{ .local = local, .handle = v8.v8__FunctionCallbackInfo__INDEX(self.handle, @intCast(index)).? };
}
pub fn getData(self: FunctionCallbackInfo) ?*anyopaque {
const data = v8.v8__FunctionCallbackInfo__Data(self.handle) orelse return null;
return v8.v8__External__Value(@ptrCast(data));
}
pub fn getThis(self: FunctionCallbackInfo) *const v8.Object {
return v8.v8__FunctionCallbackInfo__This(self.handle).?;
}
@@ -462,11 +503,13 @@ const ReturnValue = struct {
pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
cache: ?Caching = null,
embedded_receiver: bool = false,
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
@@ -494,9 +537,7 @@ pub const Function = struct {
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
const ctx = Context.fromC(v8_context);
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
const info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined;
@@ -537,6 +578,9 @@ pub const Function = struct {
var args: ParameterTypes(F) = undefined;
if (comptime opts.static) {
args = try getArgs(F, 0, local, info);
} else if (comptime opts.embedded_receiver) {
args = try getArgs(F, 1, local, info);
@field(args, "0") = @ptrCast(@alignCast(info.getData() orelse unreachable));
} else {
args = try getArgs(F, 1, local, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -688,7 +732,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
if (slice_type == js.Value or (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8)) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};

View File

@@ -23,9 +23,11 @@ const log = @import("../../log.zig");
const js = @import("js.zig");
const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig");
const v8 = js.v8;
@@ -41,15 +43,16 @@ const Context = @This();
id: usize,
env: *Env,
page: *Page,
session: *Session,
isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
microtask_queue: *v8.MicrotaskQueue,
// The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>>
// from this, and we can free it when the context is done.
handle: v8.Global,
// True if the context is auto-entered,
entered: bool,
cpu_profiler: ?*v8.CpuProfiler = null,
heap_profiler: ?*v8.HeapProfiler = null,
@@ -74,39 +77,11 @@ call_depth: usize = 0,
// context.localScope
local: ?*const js.Local = null,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the context.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
origin: *Origin,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
finalizer_callback_pool: std.heap.MemoryPool(FinalizerCallback),
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
global_values: std.ArrayList(v8.Global) = .empty,
global_objects: std.ArrayList(v8.Global) = .empty,
// Unlike other v8 types, like functions or objects, modules are not shared
// across origins.
global_modules: std.ArrayList(v8.Global) = .empty,
global_promises: std.ArrayList(v8.Global) = .empty,
global_functions: std.ArrayList(v8.Global) = .empty,
global_promise_resolvers: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
global_values_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_promises_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
global_functions_temp: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Our module cache: normalized module specifier => module.
module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
@@ -124,10 +99,6 @@ script_manager: ?*ScriptManager,
// Our macrotasks
scheduler: Scheduler,
// Prevents us from enqueuing a microtask for this context while we're shutting
// down.
shutting_down: bool = false,
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
const ModuleEntry = struct {
@@ -148,21 +119,26 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null,
};
pub fn fromC(c_context: *const v8.Context) *Context {
const data = v8.v8__Context__GetEmbedderData(c_context, 1).?;
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
pub fn fromC(c_context: *const v8.Context) ?*Context {
return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
}
pub fn fromIsolate(isolate: js.Isolate) *Context {
/// Returns the Context and v8::Context for the given isolate.
/// If the current context is from a destroyed Context (e.g., navigated-away iframe),
/// falls back to the incumbent context (the calling context).
pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } {
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?;
const data = v8.v8__Context__GetEmbedderData(v8_context, 1).?;
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
if (fromC(v8_context)) |ctx| {
return .{ ctx, v8_context };
}
// The current context's Context struct has been freed (e.g., iframe navigated away).
// Fall back to the incumbent context (the calling context).
const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?;
return .{ fromC(v8_incumbent).?, v8_incumbent };
}
pub fn deinit(self: *Context) void {
if (comptime IS_DEBUG) {
if (comptime IS_DEBUG and @import("builtin").is_test == false) {
var it = self.unknown_properties.iterator();
while (it.next()) |kv| {
log.debug(.unknown_prop, "unknown property", .{
@@ -172,102 +148,83 @@ pub fn deinit(self: *Context) void {
});
}
}
defer self.env.app.arena_pool.release(self.arena);
const env = self.env;
defer env.app.arena_pool.release(self.arena);
var hs: js.HandleScope = undefined;
const entered = self.enter(&hs);
defer entered.exit();
// We might have microtasks in the isolate that refence this context. The
// only option we have is to run them. But a microtask could queue another
// microtask, so we set the shutting_down flag, so that any such microtask
// will be a noop (this isn't automatic, when v8 calls our microtask callback
// the first thing we'll check is if self.shutting_down == true).
self.shutting_down = true;
self.env.runMicrotasks();
// can release objects
// this can release objects
self.scheduler.deinit();
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
self.finalizer_callback_pool.deinit();
}
for (self.global_values.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_objects.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_modules.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_functions.items) |*global| {
v8.v8__Global__Reset(global);
}
self.session.releaseOrigin(self.origin);
for (self.global_promises.items) |*global| {
v8.v8__Global__Reset(global);
}
for (self.global_promise_resolvers.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.global_values_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_promises_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
{
var it = self.global_functions_temp.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
if (self.entered) {
v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)));
}
// Clear the embedder data so that if V8 keeps this context alive
// (because objects created in it are still referenced), we don't
// have a dangling pointer to our freed Context struct.
v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null);
v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
// There can be other tasks associated with this context that we need to
// purge while the context is still alive.
_ = env.pumpMessageLoop();
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
}
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
const env = self.env;
const isolate = env.isolate;
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
try origin.takeover(self.origin);
self.origin = origin;
{
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
// Set the V8::Context SecurityToken, which is a big part of what allows
// one context to access another.
const token_local = v8.v8__Global__Get(&origin.security_token, isolate.handle);
v8.v8__Context__SetSecurityToken(ls.local.handle, token_local);
}
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.origin.trackGlobal(global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.origin.trackTemp(global);
}
pub fn weakRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
}
pub fn safeWeakRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -275,11 +232,12 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
return;
};
v8.v8__Global__ClearWeak(&fc.global);
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
}
pub fn strongRef(self: *Context, obj: anytype) void {
const fc = self.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -289,55 +247,19 @@ pub fn strongRef(self: *Context, obj: anytype) void {
v8.v8__Global__ClearWeak(&fc.global);
}
pub fn release(self: *Context, item: anytype) void {
if (@TypeOf(item) == *anyopaque) {
// Existing *anyopaque path for identity_map. Called internally from
// finalizers
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been fianalized, remove it for the finalizer callback so that
// we don't try to call it again on shutdown.
const fc = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
self.finalizer_callback_pool.destroy(fc.value);
return;
}
var map = switch (@TypeOf(item)) {
js.Value.Temp => &self.global_values_temp,
js.Promise.Temp => &self.global_promises_temp,
js.Function.Temp => &self.global_functions_temp,
else => |T| @compileError("Context.release cannot be called with a " ++ @typeName(T)),
};
if (map.fetchRemove(item.handle.data_ptr)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
}
// Any operation on the context have to be made from a local.
pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate;
js.HandleScope.init(&ls.handle_scope, isolate);
const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
v8.v8__Context__Enter(local_v8_context);
// TODO: add and init ls.hs for the handlescope
ls.local = .{
.ctx = self,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)),
.isolate = isolate,
.handle = local_v8_context,
.call_arena = self.call_arena,
};
}
@@ -347,29 +269,22 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global);
}
// This isn't expected to be called often. It's for converting attributes into
// function calls, e.g. <body onload="doSomething"> will turn that "doSomething"
// string into a js.Function which looks like: function(e) { doSomething(e) }
// There might be more efficient ways to do this, but doing it this way means
// our code only has to worry about js.Funtion, not some union of a js.Function
// or a string.
pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.Global {
pub fn getIncumbent(self: *Context) *Page {
return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
}
pub fn stringToPersistedFunction(
self: *Context,
function_body: []const u8,
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function.Global {
var ls: js.Local.Scope = undefined;
self.localScope(&ls);
defer ls.deinit();
var extra: []const u8 = "";
const normalized = std.mem.trim(u8, str, &std.ascii.whitespace);
if (normalized.len > 0 and normalized[normalized.len - 1] != ')') {
extra = "(e)";
}
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
const js_val = try ls.local.compileAndRun(full, null);
if (!js_val.isFunction()) {
return error.StringFunctionError;
}
return try (js.Function{ .local = &ls.local, .handle = @ptrCast(js_val.handle) }).persist();
const js_function = try ls.local.compileFunction(function_body, parameter_names, extensions);
return js_function.persist();
}
pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local, src: []const u8, url: []const u8, cacheable: bool) !(if (want_result) ModuleEntry else void) {
@@ -409,15 +324,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
}
const owned_url = try arena.dupeZ(u8, url);
if (cacheable and !gop.found_existing) {
gop.key_ptr.* = owned_url;
}
const m = try compileModule(local, src, owned_url);
if (cacheable) {
// compileModule is synchronous - nothing can modify the cache during compilation
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
gop.value_ptr.module = try m.persist();
if (!gop.found_existing) {
gop.key_ptr.* = owned_url;
}
}
break :blk .{ m, owned_url };
@@ -547,6 +462,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
nested_gop.key_ptr.* = owned_specifier;
nested_gop.value_ptr.* = .{};
try script_manager.preloadImport(owned_specifier, url);
} else if (nested_gop.value_ptr.module == null) {
// Entry exists but module failed to compile previously.
// The imported_modules entry may have been consumed, so
// re-preload to ensure waitForImport can find it.
// Key was stored via dupeZ so it has a sentinel in memory.
const key = nested_gop.key_ptr.*;
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
try script_manager.preloadImport(key_z, url);
}
}
}
@@ -571,7 +494,7 @@ fn resolveModuleCallback(
) callconv(.c) ?*const v8.Module {
_ = import_attributes;
const self = fromC(c_context.?);
const self = fromC(c_context.?).?;
const local = js.Local{
.ctx = self,
.handle = c_context.?,
@@ -604,7 +527,7 @@ pub fn dynamicModuleCallback(
_ = host_defined_options;
_ = import_attrs;
const self = fromC(c_context.?);
const self = fromC(c_context.?).?;
const local = js.Local{
.ctx = self,
.handle = c_context.?,
@@ -612,14 +535,23 @@ pub fn dynamicModuleCallback(
.isolate = self.isolate,
};
const resource = js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
const resource = blk: {
const resource_value = js.Value{ .handle = resource_name.?, .local = &local };
if (resource_value.isNullOrUndefined()) {
// will only be null / undefined in extreme cases (e.g. WPT tests)
// where you're
break :blk self.page.base();
}
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
};
};
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
};
const normalized_specifier = self.script_manager.?.resolveSpecifier(
@@ -628,21 +560,21 @@ pub fn dynamicModuleCallback(
specifier,
) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
};
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
log.err(.js, "dynamic module callback", .{
.err = err,
});
break :blk local.rejectPromise("Failed to load module") catch return null;
break :blk local.rejectPromise(.{ .generic_error = "Out of memory" });
};
return @constCast(promise.handle);
}
pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void {
// @HandleScope implement this without a fat context/local..
const self = fromC(c_context.?);
const self = fromC(c_context.?).?;
var local = js.Local{
.ctx = self,
.handle = c_context.?,
@@ -686,7 +618,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return local.toLocal(m).handle;
}
var source = try self.script_manager.?.waitForImport(normalized_specifier);
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
},
else => return err,
};
defer source.deinit();
var try_catch: js.TryCatch = undefined;
@@ -789,9 +729,16 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
entry.module_promise = try module_resolver.promise().persist();
} else {
// the module was loaded, but not evaluated, we _have_ to evaluate it now
if (status == .kUninstantiated) {
if (try mod.instantiate(resolveModuleCallback) == false) {
_ = resolver.reject("module instantiation", local.newString("Module instantiation failed"));
return promise;
}
}
const evaluated = mod.evaluate() catch {
if (comptime IS_DEBUG) {
std.debug.assert(status == .kErrored);
std.debug.assert(mod.getStatus() == .kErrored);
}
_ = resolver.reject("module evaluation", local.newString("Module evaluation failed"));
return promise;
@@ -871,13 +818,12 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
const then_callback = newFunctionWithData(local, struct {
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
var c: Caller = undefined;
c.init(isolate);
c.initFromHandle(callback_handle);
defer c.deinit();
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
if (s.context_id != c.local.ctx.id) {
// The microtask is tied to the isolate, not the context
@@ -896,17 +842,15 @@ fn resolveDynamicModule(self: *Context, state: *DynamicModuleResolveState, modul
const catch_callback = newFunctionWithData(local, struct {
pub fn callback(callback_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(callback_handle).?;
var c: Caller = undefined;
c.init(isolate);
c.initFromHandle(callback_handle);
defer c.deinit();
const info_data = v8.v8__FunctionCallbackInfo__Data(callback_handle).?;
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(info_data))));
const info = Caller.FunctionCallbackInfo{ .handle = callback_handle.? };
const s: *DynamicModuleResolveState = @ptrCast(@alignCast(info.getData() orelse return));
const l = &c.local;
const ctx = l.ctx;
if (s.context_id != ctx.id) {
if (s.context_id != l.ctx.id) {
return;
}
@@ -998,13 +942,10 @@ pub fn queueSlotchangeDelivery(self: *Context) !void {
// But for these Context microtasks, we want to (a) make sure the context isn't
// being shut down and (b) that it's entered.
fn enqueueMicrotask(self: *Context, callback: anytype) void {
self.isolate.enqueueMicrotask(struct {
// Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotask(self.microtask_queue, self.isolate.handle, struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const ctx: *Context = @ptrCast(@alignCast(data.?));
if (ctx.shutting_down) {
return;
}
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
@@ -1013,38 +954,18 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
}.run, self);
}
// There's an assumption here: the js.Function will be alive when microtasks are
// run. If we're Env.runMicrotasks in all the places that we're supposed to, then
// this should be safe (I think). In whatever HandleScope a microtask is enqueued,
// PerformCheckpoint should be run. So the v8::Local<v8::Function> should remain
// valid. If we have problems with this, a simple solution is to provide a Zig
// wrapper for these callbacks which references a js.Function.Temp, on callback
// it executes the function and then releases the global.
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
self.isolate.enqueueMicrotaskFunc(cb);
// Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
}
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create();
fc.* = .{
.ctx = self,
.ptr = ptr,
.global = global,
.finalizerFn = finalizerFn,
};
return fc;
}
// == Misc ==
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in ctx._finalizers and call them on
// context shutdown.
pub const FinalizerCallback = struct {
ctx: *Context,
ptr: *anyopaque,
global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque) void,
pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr);
self.ctx.finalizer_callback_pool.destroy(self);
}
};
// == Profiler ==
pub fn startCpuProfiler(self: *Context) void {
if (comptime !IS_DEBUG) {

View File

@@ -26,6 +26,7 @@ const App = @import("../../App.zig");
const log = @import("../../log.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig");
@@ -57,18 +58,26 @@ const Env = @This();
app: *App,
allocator: Allocator,
platform: *const Platform,
// the global isolate
isolate: js.Isolate,
contexts: std.ArrayList(*js.Context),
contexts: [64]*Context,
context_count: usize,
// just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams,
context_id: usize,
// Maps origin -> shared Origin contains, for v8 values shared across
// same-origin Contexts. There's a mismatch here between our JS model and our
// Browser model. Origins only live as long as the root page of a session exists.
// It would be wrong/dangerous to re-use an Origin across root page navigations.
// Global handles that need to be freed on deinit
eternal_function_templates: []v8.Eternal,
@@ -85,6 +94,8 @@ inspector: ?*Inspector,
// which an be created once per isolaet.
private_symbols: PrivateSymbols,
microtask_queues_are_running: bool,
pub const InitOpts = struct {
with_inspector: bool = false,
};
@@ -175,8 +186,23 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
});
// I don't 100% understand this. We actually set this up in the snapshot,
// but for the global instance, it doesn't work. SetIndexedHandler and
// SetNamedHandler are set on the Instance template, and that's the key
// difference. The context has its own global instance, so we need to set
// these back up directly on it. There might be a better way to do this.
v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{
.getter = Window.JsApi.index.getter,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = 0,
});
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
private_symbols = PrivateSymbols.init(isolate_handle);
}
@@ -188,7 +214,9 @@ pub fn init(app: *App, opts: InitOpts) !Env {
return .{
.app = app,
.context_id = 0,
.contexts = .empty,
.allocator = allocator,
.contexts = undefined,
.context_count = 0,
.isolate = isolate,
.platform = &app.platform,
.templates = templates,
@@ -196,25 +224,26 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.inspector = inspector,
.global_template = global_eternal,
.private_symbols = private_symbols,
.microtask_queues_are_running = false,
.eternal_function_templates = eternal_function_templates,
};
}
pub fn deinit(self: *Env) void {
if (comptime IS_DEBUG) {
std.debug.assert(self.contexts.items.len == 0);
std.debug.assert(self.context_count == 0);
}
for (self.contexts.items) |ctx| {
for (self.contexts[0..self.context_count]) |ctx| {
ctx.deinit();
}
const allocator = self.app.allocator;
const app = self.app;
const allocator = app.allocator;
if (self.inspector) |i| {
i.deinit(allocator);
}
self.contexts.deinit(allocator);
allocator.free(self.templates);
allocator.free(self.eternal_function_templates);
self.private_symbols.deinit();
@@ -225,7 +254,7 @@ pub fn deinit(self: *Env) void {
allocator.destroy(self.isolate_params);
}
pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
pub fn createContext(self: *Env, page: *Page) !*Context {
const context_arena = try self.app.arena_pool.acquire();
errdefer self.app.arena_pool.release(context_arena);
@@ -234,10 +263,19 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
hs.init(isolate);
defer hs.deinit();
// Create a per-context microtask queue for isolation
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
// Get the global template that was created once per isolate
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?;
const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{
.global_template = global_template,
.global_object = null,
.microtask_queue = microtask_queue,
}).?;
// Create the v8::Context and wrap it in a v8::Global
var context_global: v8.Global = undefined;
@@ -245,6 +283,7 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
// get the global object for the context, this maps to our Window
const global_obj = v8.v8__Context__Global(v8_context).?;
{
// Store our TAO inside the internal field of the global object. This
// maps the v8::Object -> Zig instance. Almost all objects have this, and
@@ -260,50 +299,55 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
}
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
if (enter) {
v8.v8__Context__Enter(v8_context);
}
errdefer if (enter) {
v8.v8__Context__Exit(v8_context);
};
const context_id = self.context_id;
self.context_id = context_id + 1;
const origin = try page._session.getOrCreateOrigin(null);
errdefer page._session.releaseOrigin(origin);
const context = try context_arena.create(Context);
context.* = .{
.env = self,
.page = page,
.session = page._session,
.origin = origin,
.id = context_id,
.entered = enter,
.isolate = isolate,
.arena = context_arena,
.handle = context_global,
.templates = self.templates,
.call_arena = page.call_arena,
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager,
.scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
};
try context.identity_map.putNoClobber(context_arena, @intFromPtr(page.window), global_global);
try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global);
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out
const data = isolate.initBigInt(@intFromPtr(context));
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
v8.v8__Context__SetAlignedPointerInEmbedderData(v8_context, 1, @ptrCast(context));
const count = self.context_count;
if (count >= self.contexts.len) {
return error.TooManyContexts;
}
self.contexts[count] = context;
self.context_count = count + 1;
try self.contexts.append(self.app.allocator, context);
return context;
}
pub fn destroyContext(self: *Env, context: *Context) void {
for (self.contexts.items, 0..) |ctx, i| {
for (self.contexts[0..self.context_count], 0..) |ctx, i| {
if (ctx == context) {
_ = self.contexts.swapRemove(i);
// Swap with last element and decrement count
self.context_count -= 1;
self.contexts[i] = self.contexts[self.context_count];
break;
}
} else {
@@ -321,16 +365,25 @@ pub fn destroyContext(self: *Env, context: *Context) void {
}
context.deinit();
isolate.notifyContextDisposed();
}
pub fn runMicrotasks(self: *const Env) void {
self.isolate.performMicrotasksCheckpoint();
pub fn runMicrotasks(self: *Env) void {
if (self.microtask_queues_are_running == false) {
const v8_isolate = self.isolate.handle;
self.microtask_queues_are_running = true;
defer self.microtask_queues_are_running = false;
var i: usize = 0;
while (i < self.context_count) : (i += 1) {
const ctx = self.contexts[i];
v8.v8__MicrotaskQueue__PerformCheckpoint(ctx.microtask_queue, v8_isolate);
}
}
}
pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null;
for (self.contexts.items) |ctx| {
pub fn runMacrotasks(self: *Env) !void {
for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests
// which rely on short execution before shutdown. In real world, it's
@@ -344,21 +397,44 @@ pub fn runMacrotasks(self: *Env) !?u64 {
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
const ms = (try ctx.scheduler.run()) orelse continue;
if (ms_to_next_task == null or ms < ms_to_next_task.?) {
ms_to_next_task = ms;
}
try ctx.scheduler.run();
}
return ms_to_next_task;
}
pub fn pumpMessageLoop(self: *const Env) bool {
pub fn msToNextMacrotask(self: *Env) ?u64 {
var next_task: u64 = std.math.maxInt(u64);
for (self.contexts[0..self.context_count]) |ctx| {
const candidate = ctx.scheduler.msToNextHigh() orelse continue;
next_task = @min(candidate, next_task);
}
return if (next_task == std.math.maxInt(u64)) null else next_task;
}
pub fn pumpMessageLoop(self: *const Env) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false);
const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {}
}
pub fn hasBackgroundTasks(self: *const Env) bool {
return v8.v8__Isolate__HasPendingBackgroundTasks(self.isolate.handle);
}
pub fn waitForBackgroundTasks(self: *Env) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Isolate__HasPendingBackgroundTasks(isolate)) {
_ = v8.v8__Platform__PumpMessageLoop(platform, isolate, true);
self.runMicrotasks();
}
}
pub fn runIdleTasks(self: *const Env) void {
@@ -414,21 +490,30 @@ pub fn dumpMemoryStats(self: *Env) void {
, .{ stats.total_heap_size, stats.total_heap_size_executable, stats.total_physical_size, stats.total_available_size, stats.used_heap_size, stats.heap_size_limit, stats.malloced_memory, stats.external_memory, stats.peak_malloced_memory, stats.number_of_native_contexts, stats.number_of_detached_contexts, stats.total_global_handles_size, stats.used_global_handles_size, stats.does_zap_garbage });
}
pub fn terminate(self: *const Env) void {
v8.v8__Isolate__TerminateExecution(self.isolate.handle);
}
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
const promise_event = v8.v8__PromiseRejectMessage__GetEvent(&message_handle);
if (promise_event != v8.kPromiseRejectWithNoHandler and promise_event != v8.kPromiseHandlerAddedAfterReject) {
return;
}
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const js_isolate = js.Isolate{ .handle = v8_isolate };
const ctx = Context.fromIsolate(js_isolate);
const isolate = js.Isolate{ .handle = v8_isolate };
const ctx, const v8_context = Context.fromIsolate(isolate);
const local = js.Local{
.ctx = ctx,
.isolate = js_isolate,
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
.isolate = isolate,
.handle = v8_context,
.call_arena = ctx.call_arena,
};
const page = ctx.page;
page.window.unhandledPromiseRejection(.{
page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
.local = &local,
.handle = &message_handle,
}, page) catch |err| {

View File

@@ -160,8 +160,8 @@ fn _tryCallWithThis(self: *const Function, comptime T: type, this: anytype, args
try_catch.rethrow();
return error.TryCatchRethrow;
}
caught.* = try_catch.caughtOrError(local.call_arena, error.JSExecCallback);
return error.JSExecCallback;
caught.* = try_catch.caughtOrError(local.call_arena, error.JsException);
return error.JsException;
};
if (@typeInfo(T) == .void) {
@@ -209,11 +209,11 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_functions.append(ctx.arena, global);
} else {
try ctx.global_functions_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
@@ -226,15 +226,18 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
return with_this.persist();
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -252,5 +255,9 @@ fn G(comptime discriminator: u8) type {
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -130,6 +130,12 @@ pub fn contextCreated(
pub fn contextDestroyed(self: *Inspector, context: *const v8.Context) void {
v8.v8_inspector__Inspector__ContextDestroyed(self.handle, context);
if (self.default_context) |*dc| {
if (v8.v8__Global__IsEqual(dc, context)) {
self.default_context = null;
}
}
}
pub fn resetContextGroup(self: *const Inspector) void {
@@ -241,8 +247,6 @@ pub const Session = struct {
msg.ptr,
msg.len,
);
v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate);
}
// Gets a value by object ID regardless of which context it is in.

View File

@@ -41,18 +41,6 @@ pub fn exit(self: Isolate) void {
v8.v8__Isolate__Exit(self.handle);
}
pub fn performMicrotasksCheckpoint(self: Isolate) void {
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle);
}
pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void {
v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data);
}
pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void {
v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle);
}
pub fn lowMemoryNotification(self: Isolate) void {
v8.v8__Isolate__LowMemoryNotification(self.handle);
}
@@ -90,6 +78,21 @@ pub fn createError(self: Isolate, msg: []const u8) *const v8.Value {
return v8.v8__Exception__Error(message).?;
}
pub fn createRangeError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__RangeError(message).?;
}
pub fn createReferenceError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__ReferenceError(message).?;
}
pub fn createSyntaxError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__SyntaxError(message).?;
}
pub fn createTypeError(self: Isolate, msg: []const u8) *const v8.Value {
const message = self.initStringHandle(msg);
return v8.v8__Exception__TypeError(message).?;

View File

@@ -17,6 +17,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const log = @import("../../log.zig");
const string = @import("../../string.zig");
@@ -81,8 +83,28 @@ pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, s
return .init(self, size);
}
pub fn newCallback(
self: *const Local,
callback: anytype,
data: anytype,
) js.Function {
const external = self.isolate.createExternal(data);
const handle = v8.v8__Function__New__DEFAULT2(self.handle, struct {
fn wrap(info_handle: ?*const js.v8.FunctionCallbackInfo) callconv(.c) void {
Caller.Function.call(@TypeOf(data), info_handle.?, callback, .{ .embedded_receiver = true });
}
}.wrap, @ptrCast(external)).?;
return .{ .local = self, .handle = handle };
}
pub fn runMacrotasks(self: *const Local) void {
const env = self.ctx.env;
env.pumpMessageLoop();
env.runMicrotasks(); // macrotasks can cause microtasks to queue
}
pub fn runMicrotasks(self: *const Local) void {
self.isolate.performMicrotasksCheckpoint();
self.ctx.env.runMicrotasks();
}
// == Executors ==
@@ -94,6 +116,49 @@ pub fn exec(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
return self.compileAndRun(src, name);
}
/// Compiles a function body as function.
///
/// https://v8.github.io/api/head/classv8_1_1ScriptCompiler.html#a3a15bb5a7dfc3f998e6ac789e6b4646a
pub fn compileFunction(
self: *const Local,
function_body: []const u8,
/// We tend to know how many params we'll pass; can remove the comptime if necessary.
comptime parameter_names: []const []const u8,
extensions: []const v8.Object,
) !js.Function {
// TODO: Make configurable.
const script_name = self.isolate.initStringHandle("anonymous");
const script_source = self.isolate.initStringHandle(function_body);
var parameter_list: [parameter_names.len]*const v8.String = undefined;
inline for (0..parameter_names.len) |i| {
parameter_list[i] = self.isolate.initStringHandle(parameter_names[i]);
}
// Create `ScriptOrigin`.
var origin: v8.ScriptOrigin = undefined;
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
// Create `ScriptCompilerSource`.
var script_compiler_source: v8.ScriptCompilerSource = undefined;
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &script_compiler_source);
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&script_compiler_source);
// Compile the function.
const result = v8.v8__ScriptCompiler__CompileFunction(
self.handle,
&script_compiler_source,
parameter_list.len,
&parameter_list,
extensions.len,
@ptrCast(&extensions),
v8.kNoCompileOptions,
v8.kNoCacheNoReason,
) orelse return error.CompilationError;
return .{ .local = self, .handle = result };
}
pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js.Value {
const script_name = self.isolate.initStringHandle(name orelse "anonymous");
const script_source = self.isolate.initStringHandle(src);
@@ -116,7 +181,7 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
) orelse return error.CompilationError;
// Run the script
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.ExecutionError;
const result = v8.v8__Script__Run(v8_script, self.handle) orelse return error.JsException;
return .{ .local = self, .handle = result };
}
@@ -137,20 +202,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
// we can just grab it from the identity_map)
pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object {
const ctx = self.ctx;
const arena = ctx.arena;
const origin_arena = ctx.origin.arena;
const T = @TypeOf(value);
switch (@typeInfo(T)) {
.@"struct" => {
// Struct, has to be placed on the heap
const heap = try arena.create(T);
const heap = try origin_arena.create(T);
heap.* = value;
return self.mapZigInstanceToJs(js_obj_handle, heap);
},
.pointer => |ptr| {
const resolved = resolveValue(value);
const gop = try ctx.identity_map.getOrPut(arena, @intFromPtr(resolved.ptr));
const gop = try ctx.origin.addIdentity(@intFromPtr(resolved.ptr));
if (gop.found_existing) {
// we've seen this instance before, return the same object
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
@@ -179,7 +244,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// The TAO contains the pointer to our Zig instance as
// well as any meta data we'll need to use it later.
// See the TaggedOpaque struct for more details.
const tao = try arena.create(TaggedOpaque);
const tao = try origin_arena.create(TaggedOpaque);
tao.* = .{
.value = resolved.ptr,
.prototype_chain = resolved.prototype_chain.ptr,
@@ -204,19 +269,20 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// can't figure out how to make that work, since it depends on
// the [runtime] `value`.
// We need the resolved finalizer, which we have in resolved.
//
// The above if statement would be more clear as:
// if (resolved.finalizer_from_v8) |finalizer| {
// But that's a runtime check.
// Instead, we check if the base has finalizer. The assumption
// here is that if a resolve type has a finalizer, then the base
// should have a finalizer too.
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
{
errdefer fc.deinit();
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc);
}
conditionallyFlagHandoff(value);
conditionallyReference(value);
if (@hasDecl(JsApi.Meta, "weak")) {
if (comptime IS_DEBUG) {
std.debug.assert(JsApi.Meta.weak == true);
@@ -322,6 +388,7 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
},
inline
js.Array,
js.Function,
js.Object,
js.Promise,
@@ -453,6 +520,13 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
return js_val;
}
if (comptime o.child == js.NullableString) {
if (js_val.isUndefined()) {
return null;
}
return .{ .value = try js_val.toStringSlice() };
}
if (comptime o.child == js.Object) {
return js.Object{
.local = self,
@@ -1054,7 +1128,7 @@ const Resolved = struct {
class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque, session: *Session) void = null,
};
pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value));
@@ -1093,14 +1167,14 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved {
};
}
fn conditionallyFlagHandoff(value: anytype) void {
fn conditionallyReference(value: anytype) void {
const T = bridge.Struct(@TypeOf(value));
if (@hasField(T, "_v8_handoff")) {
value._v8_handoff = true;
if (@hasDecl(T, "acquireRef")) {
value.acquireRef();
return;
}
if (@hasField(T, "_proto")) {
conditionallyFlagHandoff(value._proto);
conditionallyReference(value._proto);
}
}
@@ -1132,9 +1206,15 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
}
// == Promise Helpers ==
pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
pub fn rejectPromise(self: *const Local, err: js.PromiseResolver.RejectError) js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.reject("Local.rejectPromise", value);
resolver.rejectError("Local.rejectPromise", err);
return resolver.promise();
}
pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.rejectError("Local.rejectPromise", value);
return resolver.promise();
}
@@ -1201,13 +1281,20 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
gop.value_ptr.* = {};
}
const names_arr = js_obj.getOwnPropertyNames();
const len = names_arr.len();
if (depth > 20) {
return writer.writeAll("...deeply nested object...");
}
const own_len = js_obj.getOwnPropertyNames().len();
const names_arr = js_obj.getOwnPropertyNames() catch {
return writer.writeAll("...invalid object...");
};
const len = names_arr.len();
const own_len = blk: {
const own_names = js_obj.getOwnPropertyNames() catch break :blk 0;
break :blk own_names.len();
};
if (own_len == 0) {
const js_val_str = try js_val.toStringSlice();
if (js_val_str.len > 2000) {
@@ -1326,6 +1413,7 @@ pub const Scope = struct {
handle_scope: js.HandleScope,
pub fn deinit(self: *Scope) void {
v8.v8__Context__Exit(self.local.handle);
self.handle_scope.deinit();
}

View File

@@ -97,7 +97,7 @@ pub fn persist(self: Object) !Global {
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_objects.append(ctx.arena, global);
try ctx.trackGlobal(global);
return .{ .handle = global };
}
@@ -129,8 +129,14 @@ pub fn isNullOrUndefined(self: Object) bool {
return v8.v8__Value__IsNullOrUndefined(@ptrCast(self.handle));
}
pub fn getOwnPropertyNames(self: Object) js.Array {
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle).?;
pub fn getOwnPropertyNames(self: Object) !js.Array {
const handle = v8.v8__Object__GetOwnPropertyNames(self.handle, self.local.handle) orelse {
// This is almost always a fatal error case. Either we're in some exception
// and things are messy, or we're shutting down, or someone has messed up
// the object (like some WPT tests do).
return error.TypeError;
};
return .{
.local = self.local,
.handle = handle,
@@ -145,8 +151,11 @@ pub fn getPropertyNames(self: Object) js.Array {
};
}
pub fn nameIterator(self: Object) NameIterator {
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle).?;
pub fn nameIterator(self: Object) !NameIterator {
const handle = v8.v8__Object__GetPropertyNames(self.handle, self.local.handle) orelse {
// see getOwnPropertyNames above
return error.TypeError;
};
const count = v8.v8__Array__Length(handle);
return .{

262
src/browser/js/Origin.zig Normal file
View File

@@ -0,0 +1,262 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Origin represents the shared Zig<->JS bridge state for all contexts within
// the same origin. Multiple contexts (frames) from the same origin share a
// single Origin, ensuring that JS objects maintain their identity across frames.
const std = @import("std");
const js = @import("js.zig");
const App = @import("../../App.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Origin = @This();
rc: usize = 1,
arena: Allocator,
// The key, e.g. lightpanda.io:443
key: []const u8,
// Security token - all contexts in this realm must use the same v8::Value instance
// as their security token for V8 to allow cross-context access
security_token: v8.Global,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the realm.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
globals: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
taken_over: std.ArrayList(*Origin),
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire();
errdefer app.arena_pool.release(arena);
var hs: js.HandleScope = undefined;
hs.init(isolate);
defer hs.deinit();
const owned_key = try arena.dupe(u8, key);
const token_local = isolate.initStringHandle(owned_key);
var token_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, token_local, &token_global);
const self = try arena.create(Origin);
self.* = .{
.rc = 1,
.arena = arena,
.key = owned_key,
.temps = .empty,
.globals = .empty,
.taken_over = .empty,
.security_token = token_global,
};
return self;
}
pub fn deinit(self: *Origin, app: *App) void {
for (self.taken_over.items) |o| {
o.deinit(app);
}
// Call finalizers before releasing anything
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
}
v8.v8__Global__Reset(&self.security_token);
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
app.arena_pool.release(self.arena);
}
pub fn trackGlobal(self: *Origin, global: v8.Global) !void {
return self.globals.append(self.arena, global);
}
pub const IdentityResult = struct {
value_ptr: *v8.Global,
found_existing: bool,
};
pub fn addIdentity(self: *Origin, ptr: usize) !IdentityResult {
const gop = try self.identity_map.getOrPut(self.arena, ptr);
return .{
.value_ptr = gop.value_ptr,
.found_existing = gop.found_existing,
};
}
pub fn trackTemp(self: *Origin, global: v8.Global) !void {
return self.temps.put(self.arena, global.data_ptr, global);
}
pub fn releaseTemp(self: *Origin, global: v8.Global) void {
if (self.temps.fetchRemove(global.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
/// Release an item from the identity_map (called after finalizer runs from V8)
pub fn release(self: *Origin, item: *anyopaque) void {
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been finalized, remove it from the finalizer callback so that
// we don't try to call it again on shutdown.
const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
const fc = kv.value;
fc.session.releaseArena(fc.arena);
}
pub fn createFinalizerCallback(
self: *Origin,
session: *Session,
global: v8.Global,
ptr: *anyopaque,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
) !*FinalizerCallback {
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
errdefer session.releaseArena(arena);
const fc = try arena.create(FinalizerCallback);
fc.* = .{
.arena = arena,
.origin = self,
.session = session,
.ptr = ptr,
.global = global,
.zig_finalizer = zig_finalizer,
};
return fc;
}
pub fn takeover(self: *Origin, original: *Origin) !void {
const arena = self.arena;
try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
for (original.globals.items) |obj| {
self.globals.appendAssumeCapacity(obj);
}
original.globals.clearRetainingCapacity();
{
try self.temps.ensureUnusedCapacity(arena, original.temps.count());
var it = original.temps.iterator();
while (it.next()) |kv| {
try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.temps.clearRetainingCapacity();
}
{
try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
var it = original.finalizer_callbacks.iterator();
while (it.next()) |kv| {
kv.value_ptr.*.origin = self;
try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.finalizer_callbacks.clearRetainingCapacity();
}
{
try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
var it = original.identity_map.iterator();
while (it.next()) |kv| {
try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.identity_map.clearRetainingCapacity();
}
try self.taken_over.append(self.arena, original);
}
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
// origin shutdown.
pub const FinalizerCallback = struct {
arena: Allocator,
origin: *Origin,
session: *Session,
ptr: *anyopaque,
global: v8.Global,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
pub fn deinit(self: *FinalizerCallback) void {
self.zig_finalizer(self.ptr, self.session);
self.session.releaseArena(self.arena);
}
};

View File

@@ -62,22 +62,25 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_promises.append(ctx.arena, global);
} else {
try ctx.global_promises_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -91,5 +94,9 @@ fn G(comptime discriminator: u8) type {
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -18,8 +18,11 @@
const js = @import("js.zig");
const v8 = js.v8;
const log = @import("../../log.zig");
const DOMException = @import("../webapi/DOMException.zig");
const PromiseResolver = @This();
local: *const js.Local,
@@ -63,6 +66,43 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
};
}
pub const RejectError = union(enum) {
/// Not to be confused with `DOMException`; this is bare `Error`.
generic_error: []const u8,
range_error: []const u8,
reference_error: []const u8,
syntax_error: []const u8,
type_error: []const u8,
/// DOM exceptions are unknown to V8, belongs to web standards.
dom_exception: struct { err: anyerror },
};
/// Rejects the promise w/ an error object.
pub fn rejectError(
self: PromiseResolver,
comptime source: []const u8,
err: RejectError,
) void {
const handle = switch (err) {
.generic_error => |msg| self.local.isolate.createError(msg),
.range_error => |msg| self.local.isolate.createRangeError(msg),
.reference_error => |msg| self.local.isolate.createReferenceError(msg),
.syntax_error => |msg| self.local.isolate.createSyntaxError(msg),
.type_error => |msg| self.local.isolate.createTypeError(msg),
// "Exceptional".
.dom_exception => |exception| {
self._reject(DOMException.fromError(exception.err) orelse unreachable) catch |reject_err| {
log.err(.bug, "rejectDomException", .{ .source = source, .err = reject_err, .persistent = false });
};
return;
},
};
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
};
}
fn _reject(self: PromiseResolver, value: anytype) !void {
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
@@ -79,7 +119,7 @@ pub fn persist(self: PromiseResolver) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_promise_resolvers.append(ctx.arena, global);
try ctx.trackGlobal(global);
return .{ .handle = global };
}

View File

@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
});
}
pub fn run(self: *Scheduler) !?u64 {
_ = try self.runQueue(&self.low_priority);
return self.runQueue(&self.high_priority);
pub fn run(self: *Scheduler) !void {
const now = milliTimestamp(.monotonic);
try self.runQueue(&self.low_priority, now);
try self.runQueue(&self.high_priority, now);
}
pub fn hasReadyTasks(self: *Scheduler) bool {
@@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool {
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
if (queue.count() == 0) {
return null;
}
pub fn msToNextHigh(self: *Scheduler) ?u64 {
const task = self.high_priority.peek() orelse return null;
const now = milliTimestamp(.monotonic);
if (task.run_at <= now) {
return 0;
}
return @intCast(task.run_at - now);
}
fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void {
if (queue.count() == 0) {
return;
}
while (queue.peek()) |*task_| {
if (task_.run_at > now) {
return @intCast(task_.run_at - now);
return;
}
var task = queue.remove();
if (comptime IS_DEBUG) {
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
try self.low_priority.add(task);
}
}
return null;
return;
}
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {

View File

@@ -282,9 +282,13 @@ fn hasNamedIndexedGetter(comptime JsApi: type) bool {
fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000);
// +1 for the illegal constructor callback
var count: comptime_int = 1;
var has_non_template_property: bool = false;
var count: comptime_int = 0;
// +1 for the illegal constructor callback shared by various types
count += 1;
// +1 for the noop function shared by various types
count += 1;
inline for (JsApis) |JsApi| {
// Constructor (only if explicit)
@@ -304,17 +308,18 @@ fn countExternalReferences() comptime_int {
const T = @TypeOf(value);
if (T == bridge.Accessor) {
count += 1; // getter
if (value.setter != null) count += 1; // setter
if (value.setter != null) {
count += 1;
}
} else if (T == bridge.Function) {
count += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
count += 1;
} else if (T == bridge.Indexed) {
count += 1;
if (value.enumerator != null) {
count += 1;
}
} else if (T == bridge.NamedIndexed) {
count += 1; // getter
if (value.setter != null) count += 1;
@@ -323,10 +328,6 @@ fn countExternalReferences() comptime_int {
}
}
if (has_non_template_property) {
count += 1;
}
// In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
@@ -346,7 +347,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1;
var has_non_template_property = false;
references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
idx += 1;
inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) {
@@ -373,16 +375,16 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} else if (T == bridge.Function) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) {
references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1;
} else if (T == bridge.Indexed) {
references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1;
if (value.enumerator) |enumerator| {
references[idx] = @bitCast(@intFromPtr(enumerator));
idx += 1;
}
} else if (T == bridge.NamedIndexed) {
references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1;
@@ -398,11 +400,6 @@ fn collectExternalReferences() [countExternalReferences()]isize {
}
}
if (has_non_template_property) {
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
idx += 1;
}
// In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| {
@@ -486,8 +483,8 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
// Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false;
@@ -505,14 +502,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
} else {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
}
} else {
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
}
},
bridge.Function => {
@@ -521,16 +518,16 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
} else {
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
}
},
bridge.Indexed => {
var configuration: v8.IndexedPropertyHandlerConfiguration = .{
.getter = value.getter,
.enumerator = value.enumerator,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
@@ -559,7 +556,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
v8.v8__Symbol__GetAsyncIterator(isolate)
else
v8.v8__Symbol__GetIterator(isolate);
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None);
v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
},
bridge.Property => {
const js_value = switch (value.value) {
@@ -568,22 +565,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
};
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.template == false) {
// not defined on the template, only on the instance. This
// is like an Accessor, but because the value is known at
// compile time, we skip _a lot_ of code and quickly return
// the hard-coded value
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = bridge.Property.getter,
.data = js_value,
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
}));
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
} else {
// apply it both to the type itself
{
const flags = if (value.readonly) v8.ReadOnly + v8.DontDelete else 0;
v8.v8__Template__Set(@ptrCast(prototype), js_name, js_value, flags);
}
if (value.template) {
// apply it both to the type itself (e.g. Node.Elem)
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
}
},
bridge.Constructor => {}, // already handled in generateConstructor

View File

@@ -56,7 +56,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
pub fn toSSO(self: String, comptime global: bool) !(if (global) SSO.Global else SSO) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.arena) };
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.origin.arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}

View File

@@ -134,4 +134,17 @@ pub const Caught = struct {
try writer.write(prefix ++ ".line", self.line);
try writer.write(prefix ++ ".caught", self.caught);
}
pub fn jsonStringify(self: Caught, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("exception");
try jw.write(self.exception);
try jw.objectField("stack");
try jw.write(self.stack);
try jw.objectField("line");
try jw.write(self.line);
try jw.objectField("caught");
try jw.write(self.caught);
try jw.endObject();
}
};

View File

@@ -245,6 +245,46 @@ pub fn toJson(self: Value, allocator: Allocator) ![]u8 {
return js.String.toSliceWithAlloc(.{ .local = local, .handle = str_handle }, allocator);
}
// Currently does not support host objects (Blob, File, etc.) or transferables
// which require delegate callbacks to be implemented.
pub fn structuredClone(self: Value) !Value {
const local = self.local;
const v8_context = local.handle;
const v8_isolate = local.isolate.handle;
const size, const data = blk: {
const serializer = v8.v8__ValueSerializer__New(v8_isolate, null) orelse return error.JsException;
defer v8.v8__ValueSerializer__DELETE(serializer);
var write_result: v8.MaybeBool = undefined;
v8.v8__ValueSerializer__WriteHeader(serializer);
v8.v8__ValueSerializer__WriteValue(serializer, v8_context, self.handle, &write_result);
if (!write_result.has_value or !write_result.value) {
return error.JsException;
}
var size: usize = undefined;
const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException;
break :blk .{ size, data };
};
defer v8.v8__ValueSerializer__FreeBuffer(data);
const cloned_handle = blk: {
const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException;
defer v8.v8__ValueDeserializer__DELETE(deserializer);
var read_header_result: v8.MaybeBool = undefined;
v8.v8__ValueDeserializer__ReadHeader(deserializer, v8_context, &read_header_result);
if (!read_header_result.has_value or !read_header_result.value) {
return error.JsException;
}
break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, v8_context) orelse return error.JsException;
};
return .{ .local = local, .handle = cloned_handle };
}
pub fn persist(self: Value) !Global {
return self._persist(true);
}
@@ -259,11 +299,11 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.global_values.append(ctx.arena, global);
} else {
try ctx.global_values_temp.put(ctx.arena, global.data_ptr, global);
try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} };
}
return .{ .handle = global };
try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin };
}
pub fn toZig(self: Value, comptime T: type) !T {
@@ -310,15 +350,18 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
return js_str.format(writer);
}
pub const Temp = G(0);
pub const Global = G(1);
pub const Temp = G(.temp);
pub const Global = G(.global);
fn G(comptime discriminator: u8) type {
const GlobalType = enum(u8) {
temp,
global,
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
// makes the types different (G(0) != G(1)), without taking up space
comptime _: u8 = discriminator,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -336,5 +379,9 @@ fn G(comptime discriminator: u8) type {
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -21,11 +21,13 @@ const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const Origin = @import("Origin.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -46,8 +48,8 @@ pub fn Builder(comptime T: type) type {
return Function.init(T, func, opts);
}
pub fn indexed(comptime getter_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, opts);
pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, enumerator_func, opts);
}
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
@@ -104,24 +106,24 @@ pub fn Builder(comptime T: type) type {
return entries;
}
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer {
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, session: *Session) void) Finalizer {
return .{
.from_zig = struct {
fn wrap(ptr: *anyopaque) void {
func(@ptrCast(@alignCast(ptr)), true);
fn wrap(ptr: *anyopaque, session: *Session) void {
func(@ptrCast(@alignCast(ptr)), true, session);
}
}.wrap,
.from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const fc: *Context.FinalizerCallback = @ptrCast(@alignCast(ptr));
const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr));
const ctx = fc.ctx;
const origin = fc.origin;
const value_ptr = fc.ptr;
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false);
ctx.release(value_ptr);
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
origin.release(value_ptr);
} else {
// A bit weird, but v8 _requires_ that we release it
// If we don't. We'll 100% crash.
@@ -160,6 +162,7 @@ pub const Constructor = struct {
pub const Function = struct {
static: bool,
arity: usize,
noop: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
@@ -168,7 +171,7 @@ pub const Function = struct {
.cache = opts.cache,
.static = opts.static,
.arity = getArity(@TypeOf(func)),
.func = struct {
.func = if (opts.noop) noopFunction else struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
Caller.Function.call(T, handle.?, func, opts);
}
@@ -176,6 +179,8 @@ pub const Function = struct {
};
}
pub fn noopFunction(_: ?*const v8.FunctionCallbackInfo) callconv(.c) void {}
fn getArity(comptime T: type) usize {
var count: usize = 0;
var params = @typeInfo(T).@"fn".params;
@@ -227,26 +232,44 @@ pub const Accessor = struct {
pub const Indexed = struct {
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
const Opts = struct {
as_typed_array: bool = false,
null_as_undefined: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed {
return .{ .getter = struct {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
fn init(comptime T: type, comptime getter: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
var indexed = Indexed{
.enumerator = null,
.getter = struct {
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getIndex(T, getter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
}.wrap };
return caller.getIndex(T, getter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
}.wrap,
};
if (@typeInfo(@TypeOf(enumerator)) != .null) {
indexed.enumerator = struct {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getEnumerator(T, enumerator, handle.?, .{});
}
}.wrap;
}
return indexed;
}
};
@@ -367,6 +390,7 @@ pub const Callable = struct {
pub const Property = struct {
value: Value,
template: bool,
readonly: bool,
const Value = union(enum) {
null,
@@ -378,30 +402,25 @@ pub const Property = struct {
const Opts = struct {
template: bool,
readonly: bool = true,
};
fn init(value: Value, opts: Opts) Property {
return .{
.value = value,
.template = opts.template,
.readonly = opts.readonly,
};
}
pub fn getter(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const value = v8.v8__FunctionCallbackInfo__Data(handle.?);
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(handle.?, &rv);
v8.v8__ReturnValue__Set(rv, value);
}
};
const Finalizer = struct {
// The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit
from_zig: *const fn (ctx: *anyopaque) void,
// The finalizer wrapper when called from Zig. This is only called on
// Origin.deinit
from_zig: *const fn (ctx: *anyopaque, session: *Session) void,
// The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Context.denit). If it is called,
// (hence why we fallback to calling in Origin.deinit). If it is called,
// it is only ever called after we SetWeak on the Global.
from_v8: *const fn (?*const v8.WeakCallbackInfo) callconv(.c) void,
};
@@ -706,6 +725,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/collections.zig"),
@import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"),
@import("../webapi/Permissions.zig"),
@import("../webapi/StorageManager.zig"),
@import("../webapi/CSS.zig"),
@import("../webapi/css/CSSRule.zig"),
@import("../webapi/css/CSSRuleList.zig"),
@@ -713,6 +734,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFace.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"),
@import("../webapi/Document.zig"),
@@ -749,6 +772,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/Custom.zig"),
@import("../webapi/element/html/Data.zig"),
@import("../webapi/element/html/DataList.zig"),
@import("../webapi/element/html/Details.zig"),
@import("../webapi/element/html/Dialog.zig"),
@import("../webapi/element/html/Directory.zig"),
@import("../webapi/element/html/DList.zig"),
@@ -808,6 +832,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/svg/Generic.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
@import("../webapi/encoding/TextDecoderStream.zig"),
@import("../webapi/Event.zig"),
@import("../webapi/event/CompositionEvent.zig"),
@import("../webapi/event/CustomEvent.zig"),
@@ -824,6 +850,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@@ -844,6 +871,10 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/streams/ReadableStream.zig"),
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
@import("../webapi/streams/ReadableStreamDefaultController.zig"),
@import("../webapi/streams/WritableStream.zig"),
@import("../webapi/streams/WritableStreamDefaultWriter.zig"),
@import("../webapi/streams/WritableStreamDefaultController.zig"),
@import("../webapi/streams/TransformStream.zig"),
@import("../webapi/Node.zig"),
@import("../webapi/storage/storage.zig"),
@import("../webapi/URL.zig"),
@@ -857,6 +888,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IdleDeadline.zig"),
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
@import("../webapi/FileList.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
@@ -865,6 +898,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/navigation/NavigationActivation.zig"),
@import("../webapi/canvas/CanvasRenderingContext2D.zig"),
@import("../webapi/canvas/WebGLRenderingContext.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/SubtleCrypto.zig"),
@import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"),

View File

@@ -24,6 +24,7 @@ const string = @import("../../string.zig");
pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig");
pub const Caller = @import("Caller.zig");
pub const Origin = @import("Origin.zig");
pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig");
@@ -161,13 +162,23 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_values.append(ctx.arena, global);
try ctx.trackGlobal(global);
return .{ .handle = global };
}
};
}
// If a WebAPI takes a []const u8, then we'll coerce any JS value to that string
// so null -> "null". But if a WebAPI takes an optional string, ?[]const u8,
// how should we handle null? If the parameter _isn't_ passed, then it's obvious
// that it should be null, but what if `null` is passed? It's ambiguous, should
// that be null, or "null"? It could depend on the api. So, `null` passed to
// ?[]const u8 will be `null`. If you want it to be "null", use a `.js.NullableString`.
pub const NullableString = struct {
value: []const u8,
};
pub const Exception = struct {
local: *const Local,
handle: *const v8.Value,

View File

@@ -19,8 +19,12 @@
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
pub const Opts = struct {
// Options for future customization (e.g., dialect)
@@ -35,7 +39,6 @@ const State = struct {
list_depth: usize = 0,
list_stack: [32]ListState = undefined,
in_pre: bool = false,
pre_node: ?*Node = null,
in_code: bool = false,
in_table: bool = false,
@@ -44,13 +47,6 @@ const State = struct {
last_char_was_newline: bool = true,
};
fn isBlock(tag: Element.Tag) bool {
return switch (tag) {
.p, .div, .section, .article, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true,
else => false,
};
}
fn shouldAddSpacing(tag: Element.Tag) bool {
return switch (tag) {
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
@@ -58,344 +54,429 @@ fn shouldAddSpacing(tag: Element.Tag) bool {
};
}
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
fn isLayoutBlock(tag: Element.Tag) bool {
return switch (tag) {
.main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
else => false,
};
}
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts;
var state = State{};
try render(node, &state, writer, page);
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
fn isStandaloneAnchor(el: *Element) bool {
const node = el.asNode();
const parent = node.parentNode() orelse return false;
const parent_el = parent.is(Element) orelse return false;
if (!isLayoutBlock(parent_el.getTag())) return false;
var prev = node.previousSibling();
while (prev) |p| : (prev = p.previousSibling()) {
if (isSignificantText(p)) return false;
if (p.is(Element)) |pe| {
if (isVisibleElement(pe)) break;
}
}
var next = node.nextSibling();
while (next) |n| : (next = n.nextSibling()) {
if (isSignificantText(n)) return false;
if (n.is(Element)) |ne| {
if (isVisibleElement(ne)) break;
}
}
return true;
}
fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try renderChildren(node, state, writer, page);
},
.element => |el| {
try renderElement(el, state, writer, page);
},
.cdata => |cd| {
if (node.is(Node.CData.Text)) |_| {
var text = cd.getData();
if (state.in_pre) {
if (state.pre_node) |pre| {
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.getWholeText());
}
fn isVisibleElement(el: *Element) bool {
const tag = el.getTag();
return !tag.isMetadata() and tag != .svg;
}
fn getAnchorLabel(el: *Element) ?[]const u8 {
return el.getAttributeSafe(comptime .wrap("aria-label")) orelse el.getAttributeSafe(comptime .wrap("title"));
}
fn hasBlockDescendant(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.Elements.init(root, .{});
while (tw.next()) |el| {
if (el.getTag().isBlock()) return true;
}
return false;
}
fn hasVisibleContent(root: *Node) bool {
var tw = TreeWalker.FullExcludeSelf.init(root, .{});
while (tw.next()) |node| {
if (isSignificantText(node)) return true;
if (node.is(Element)) |el| {
if (!isVisibleElement(el)) {
tw.skipChildren();
} else if (el.getTag() == .img) {
return true;
}
}
}
return false;
}
const Context = struct {
state: State,
writer: *std.Io.Writer,
page: *Page,
fn ensureNewline(self: *Context) !void {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
}
}
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
switch (node._type) {
.document, .document_fragment => {
try self.renderChildren(node);
},
.element => |el| {
try self.renderElement(el);
},
.cdata => |cd| {
if (node.is(Node.CData.Text)) |_| {
var text = cd.getData().str();
if (self.state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n");
}
}
try self.renderText(text);
}
try renderText(text, state, writer);
}
},
else => {}, // Ignore other node types
}
}
fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try render(child, state, writer, page);
}
}
fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
const tag = el.getTag();
// Skip hidden/metadata elements
switch (tag) {
.script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => return,
else => {},
}
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (isBlock(tag)) {
if (!state.in_table) {
try ensureNewline(state, writer);
if (shouldAddSpacing(tag)) {
// Add an extra newline for spacing between blocks
try writer.writeByte('\n');
}
},
else => {},
}
} else if (tag == .li or tag == .tr) {
try ensureNewline(state, writer);
}
// Prefixes
switch (tag) {
.h1 => try writer.writeAll("# "),
.h2 => try writer.writeAll("## "),
.h3 => try writer.writeAll("### "),
.h4 => try writer.writeAll("#### "),
.h5 => try writer.writeAll("##### "),
.h6 => try writer.writeAll("###### "),
.ul => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
state.list_depth += 1;
}
},
.ol => {
if (state.list_depth < state.list_stack.len) {
state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
state.list_depth += 1;
}
},
.li => {
const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
for (0..indent) |_| try writer.writeAll(" ");
fn renderChildren(self: *Context, parent: *Node) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try self.render(child);
}
}
if (state.list_depth > 0) {
const current_list = &state.list_stack[state.list_depth - 1];
if (current_list.type == .ordered) {
try writer.print("{d}. ", .{current_list.index});
fn renderElement(self: *Context, el: *Element) !void {
const tag = el.getTag();
if (!isVisibleElement(el)) return;
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
if (shouldAddSpacing(tag)) {
try self.writer.writeByte('\n');
}
} else if (tag == .li or tag == .tr) {
try self.ensureNewline();
}
// Prefixes
switch (tag) {
.h1 => try self.writer.writeAll("# "),
.h2 => try self.writer.writeAll("## "),
.h3 => try self.writer.writeAll("### "),
.h4 => try self.writer.writeAll("#### "),
.h5 => try self.writer.writeAll("##### "),
.h6 => try self.writer.writeAll("###### "),
.ul => {
if (self.state.list_depth < self.state.list_stack.len) {
self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
self.state.list_depth += 1;
}
},
.ol => {
if (self.state.list_depth < self.state.list_stack.len) {
self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
self.state.list_depth += 1;
}
},
.li => {
const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
for (0..indent) |_| try self.writer.writeAll(" ");
if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
const current_list = &self.state.list_stack[self.state.list_depth - 1];
try self.writer.print("{d}. ", .{current_list.index});
current_list.index += 1;
} else {
try writer.writeAll("- ");
try self.writer.writeAll("- ");
}
} else {
try writer.writeAll("- ");
}
state.last_char_was_newline = false;
},
.table => {
state.in_table = true;
state.table_row_index = 0;
state.table_col_count = 0;
},
.tr => {
state.table_col_count = 0;
try writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
state.last_char_was_newline = false;
try writer.writeByte(' ');
},
.blockquote => {
try writer.writeAll("> ");
state.last_char_was_newline = false;
},
.pre => {
try writer.writeAll("```\n");
state.in_pre = true;
state.pre_node = el.asNode();
state.last_char_was_newline = true;
},
.code => {
if (!state.in_pre) {
try writer.writeByte('`');
state.in_code = true;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.hr => {
try writer.writeAll("---\n");
state.last_char_was_newline = true;
return; // Void element
},
.br => {
if (state.in_table) {
try writer.writeByte(' ');
} else {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
return; // Void element
},
.img => {
try writer.writeAll("![");
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try escapeMarkdown(writer, alt);
}
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
try writer.writeAll(src);
}
try writer.writeAll(")");
state.last_char_was_newline = false;
return; // Treat as void
},
.anchor => {
try writer.writeByte('[');
try renderChildren(el.asNode(), state, writer, page);
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
}
try writer.writeByte(')');
state.last_char_was_newline = false;
return;
},
.input => {
if (el.getAttributeSafe(comptime .wrap("type"))) |type_attr| {
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
if (el.getAttributeSafe(comptime .wrap("checked"))) |_| {
try writer.writeAll("[x] ");
} else {
try writer.writeAll("[ ] ");
}
state.last_char_was_newline = false;
}
}
return;
},
else => {},
}
// --- Render Children ---
try renderChildren(el.asNode(), state, writer, page);
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
}
try writer.writeAll("```\n");
state.in_pre = false;
state.pre_node = null;
state.last_char_was_newline = true;
},
.code => {
if (!state.in_pre) {
try writer.writeByte('`');
state.in_code = false;
state.last_char_was_newline = false;
}
},
.b, .strong => {
try writer.writeAll("**");
state.last_char_was_newline = false;
},
.i, .em => {
try writer.writeAll("*");
state.last_char_was_newline = false;
},
.s, .del => {
try writer.writeAll("~~");
state.last_char_was_newline = false;
},
.blockquote => {},
.ul, .ol => {
if (state.list_depth > 0) state.list_depth -= 1;
},
.table => {
state.in_table = false;
},
.tr => {
try writer.writeByte('\n');
if (state.table_row_index == 0) {
try writer.writeByte('|');
var i: usize = 0;
while (i < state.table_col_count) : (i += 1) {
try writer.writeAll("---|");
}
try writer.writeByte('\n');
}
state.table_row_index += 1;
state.last_char_was_newline = true;
},
.td, .th => {
try writer.writeAll(" |");
state.table_col_count += 1;
state.last_char_was_newline = false;
},
else => {},
}
// Post-block newlines
if (isBlock(tag)) {
if (!state.in_table) {
try ensureNewline(state, writer);
}
}
}
fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
if (text.len == 0) return;
if (state.in_pre) {
try writer.writeAll(text);
if (text.len > 0 and text[text.len - 1] == '\n') {
state.last_char_was_newline = true;
} else {
state.last_char_was_newline = false;
}
return;
}
// Check for pure whitespace
const is_all_whitespace = for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false;
} else true;
if (is_all_whitespace) {
if (!state.last_char_was_newline) {
try writer.writeByte(' ');
}
return;
}
// Collapse whitespace
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
var first = true;
while (it.next()) |word| {
if (first) {
if (!state.last_char_was_newline) {
if (text.len > 0 and std.ascii.isWhitespace(text[0])) {
try writer.writeByte(' ');
}
}
} else {
try writer.writeByte(' ');
}
try escapeMarkdown(writer, word);
state.last_char_was_newline = false;
first = false;
}
// Handle trailing whitespace from the original text
if (!first and !state.last_char_was_newline) {
if (text.len > 0 and std.ascii.isWhitespace(text[text.len - 1])) {
try writer.writeByte(' ');
}
}
}
fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
for (text) |c| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
try writer.writeByte('\\');
try writer.writeByte(c);
self.state.last_char_was_newline = false;
},
else => try writer.writeByte(c),
.table => {
self.state.in_table = true;
self.state.table_row_index = 0;
self.state.table_col_count = 0;
},
.tr => {
self.state.table_col_count = 0;
try self.writer.writeByte('|');
},
.td, .th => {
// Note: leading pipe handled by previous cell closing or tr opening
self.state.last_char_was_newline = false;
try self.writer.writeByte(' ');
},
.blockquote => {
try self.writer.writeAll("> ");
self.state.last_char_was_newline = false;
},
.pre => {
try self.writer.writeAll("```\n");
self.state.pre_node = el.asNode();
self.state.last_char_was_newline = true;
},
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = true;
self.state.last_char_was_newline = false;
}
},
.b, .strong => {
try self.writer.writeAll("**");
self.state.last_char_was_newline = false;
},
.i, .em => {
try self.writer.writeAll("*");
self.state.last_char_was_newline = false;
},
.s, .del => {
try self.writer.writeAll("~~");
self.state.last_char_was_newline = false;
},
.hr => {
try self.writer.writeAll("---\n");
self.state.last_char_was_newline = true;
return;
},
.br => {
if (self.state.in_table) {
try self.writer.writeByte(' ');
} else {
try self.writer.writeByte('\n');
self.state.last_char_was_newline = true;
}
return;
},
.img => {
try self.writer.writeAll("![");
if (el.getAttributeSafe(comptime .wrap("alt"))) |alt| {
try self.escape(alt);
}
try self.writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src;
try self.writer.writeAll(absolute_src);
}
try self.writer.writeAll(")");
self.state.last_char_was_newline = false;
return;
},
.anchor => {
const has_content = hasVisibleContent(el.asNode());
const label = getAnchorLabel(el);
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
if (!has_content and label == null and href_raw == null) return;
const has_block = hasBlockDescendant(el.asNode());
const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
try self.renderChildren(el.asNode());
if (href) |h| {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeAll("([](");
try self.writer.writeAll(h);
try self.writer.writeAll("))\n");
self.state.last_char_was_newline = true;
}
return;
}
if (isStandaloneAnchor(el)) {
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
try self.writer.writeByte('[');
if (has_content) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeAll(")\n");
self.state.last_char_was_newline = true;
return;
}
try self.writer.writeByte('[');
if (has_content) {
try self.renderChildren(el.asNode());
} else {
try self.writer.writeAll(label orelse "");
}
try self.writer.writeAll("](");
if (href) |h| {
try self.writer.writeAll(h);
}
try self.writer.writeByte(')');
self.state.last_char_was_newline = false;
return;
},
.input => {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
self.state.last_char_was_newline = false;
}
return;
},
else => {},
}
// --- Render Children ---
try self.renderChildren(el.asNode());
// --- Closing Tag Logic ---
// Suffixes
switch (tag) {
.pre => {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte('\n');
}
try self.writer.writeAll("```\n");
self.state.pre_node = null;
self.state.last_char_was_newline = true;
},
.code => {
if (self.state.pre_node == null) {
try self.writer.writeByte('`');
self.state.in_code = false;
self.state.last_char_was_newline = false;
}
},
.b, .strong => {
try self.writer.writeAll("**");
self.state.last_char_was_newline = false;
},
.i, .em => {
try self.writer.writeAll("*");
self.state.last_char_was_newline = false;
},
.s, .del => {
try self.writer.writeAll("~~");
self.state.last_char_was_newline = false;
},
.blockquote => {},
.ul, .ol => {
if (self.state.list_depth > 0) self.state.list_depth -= 1;
},
.table => {
self.state.in_table = false;
},
.tr => {
try self.writer.writeByte('\n');
if (self.state.table_row_index == 0) {
try self.writer.writeByte('|');
for (0..self.state.table_col_count) |_| {
try self.writer.writeAll("---|");
}
try self.writer.writeByte('\n');
}
self.state.table_row_index += 1;
self.state.last_char_was_newline = true;
},
.td, .th => {
try self.writer.writeAll(" |");
self.state.table_col_count += 1;
self.state.last_char_was_newline = false;
},
else => {},
}
// Post-block newlines
if (tag.isBlock() and !self.state.in_table) {
try self.ensureNewline();
}
}
fn renderText(self: *Context, text: []const u8) !void {
if (text.len == 0) return;
if (self.state.pre_node) |_| {
try self.writer.writeAll(text);
self.state.last_char_was_newline = text[text.len - 1] == '\n';
return;
}
// Check for pure whitespace
if (isAllWhitespace(text)) {
if (!self.state.last_char_was_newline) {
try self.writer.writeByte(' ');
}
return;
}
// Collapse whitespace
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
var first = true;
while (it.next()) |word| {
if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
try self.writer.writeByte(' ');
}
try self.escape(word);
self.state.last_char_was_newline = false;
first = false;
}
// Handle trailing whitespace from the original text
if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
try self.writer.writeByte(' ');
}
}
fn escape(self: *Context, text: []const u8) !void {
for (text) |c| {
switch (c) {
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
try self.writer.writeByte('\\');
try self.writer.writeByte(c);
},
else => try self.writer.writeByte(c),
}
}
}
};
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts;
var ctx: Context = .{
.state = .{},
.writer = writer,
.page = page,
};
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n');
}
}
@@ -403,6 +484,8 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
page.url = "http://localhost/";
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
@@ -415,35 +498,35 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
try testing.expectString(expected, aw.written());
}
test "markdown: basic" {
test "browser.markdown: basic" {
try testMarkdownHTML("Hello world", "Hello world\n");
}
test "markdown: whitespace" {
test "browser.markdown: whitespace" {
try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
}
test "markdown: escaping" {
test "browser.markdown: escaping" {
try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
}
test "markdown: strikethrough" {
test "browser.markdown: strikethrough" {
try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
}
test "markdown: task list" {
test "browser.markdown: task list" {
try testMarkdownHTML(
\\<input type="checkbox" checked><input type="checkbox">
, "[x] [ ] \n");
}
test "markdown: ordered list" {
test "browser.markdown: ordered list" {
try testMarkdownHTML(
\\<ol><li>First</li><li>Second</li></ol>
, "1. First\n2. Second\n");
}
test "markdown: table" {
test "browser.markdown: table" {
try testMarkdownHTML(
\\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
\\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
@@ -456,7 +539,7 @@ test "markdown: table" {
);
}
test "markdown: nested lists" {
test "browser.markdown: nested lists" {
try testMarkdownHTML(
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
,
@@ -466,19 +549,19 @@ test "markdown: nested lists" {
);
}
test "markdown: blockquote" {
test "browser.markdown: blockquote" {
try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
}
test "markdown: links" {
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
test "browser.markdown: links" {
try testMarkdownHTML("<a href=\"/relative\">Link</a>", "[Link](http://localhost/relative)\n");
}
test "markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](logo.png)\n");
test "browser.markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](http://localhost/logo.png)\n");
}
test "markdown: headings" {
test "browser.markdown: headings" {
try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
\\
\\# Title
@@ -488,7 +571,7 @@ test "markdown: headings" {
);
}
test "markdown: code" {
test "browser.markdown: code" {
try testMarkdownHTML(
\\<p>Use git push</p>
\\<pre><code>line 1
@@ -504,3 +587,106 @@ test "markdown: code" {
\\
);
}
test "browser.markdown: block link" {
try testMarkdownHTML(
\\<a href="https://example.com">
\\ <h3>Title</h3>
\\ <p>Description</p>
\\</a>
,
\\
\\### Title
\\
\\Description
\\([](https://example.com))
\\
);
}
test "browser.markdown: inline link" {
try testMarkdownHTML(
\\<p>Visit <a href="https://example.com">Example</a>.</p>
,
\\
\\Visit [Example](https://example.com).
\\
);
}
test "browser.markdown: standalone anchors" {
// Inside main, with whitespace between anchors -> treated as blocks
try testMarkdownHTML(
\\<main>
\\ <a href="1">Link 1</a>
\\ <a href="2">Link 2</a>
\\</main>
,
\\[Link 1](http://localhost/1)
\\[Link 2](http://localhost/2)
\\
);
}
test "browser.markdown: mixed anchors in main" {
// Anchors surrounded by text should remain inline
try testMarkdownHTML(
\\<main>
\\ Welcome <a href="1">Link 1</a>.
\\</main>
,
\\Welcome [Link 1](http://localhost/1).
\\
);
}
test "browser.markdown: skip empty links" {
try testMarkdownHTML(
\\<a href="/"></a>
\\<a href="/"><svg></svg></a>
,
\\[](http://localhost/)
\\[](http://localhost/)
\\
);
}
test "browser.markdown: resolve links" {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
page.url = "https://example.com/a/index.html";
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(),
\\<a href="b">Link</a>
\\<img src="../c.png" alt="Img">
\\<a href="/my page">Space</a>
);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(div.asNode(), .{}, &aw.writer, page);
try testing.expectString(
\\[Link](https://example.com/a/b)
\\![Img](https://example.com/c.png)
\\[Space](https://example.com/my%20page)
\\
, aw.written());
}
test "browser.markdown: anchor fallback label" {
try testMarkdownHTML(
\\<a href="/discord" aria-label="Discord Server"><svg></svg></a>
, "[Discord Server](http://localhost/discord)\n");
try testMarkdownHTML(
\\<a href="/search" title="Search Site"><svg></svg></a>
, "[Search Site](http://localhost/search)\n");
try testMarkdownHTML(
\\<a href="/no-label"><svg></svg></a>
, "[](http://localhost/no-label)\n");
}

View File

@@ -23,6 +23,9 @@ const h5e = @import("html5ever.zig");
const Page = @import("../Page.zig");
const Node = @import("../webapi/Node.zig");
const Element = @import("../webapi/Element.zig");
pub const AttributeIterator = h5e.AttributeIterator;
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;

View File

@@ -0,0 +1,489 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Allocator = std.mem.Allocator;
/// Key-value pair for structured data properties.
pub const Property = struct {
key: []const u8,
value: []const u8,
};
pub const AlternateLink = struct {
href: []const u8,
hreflang: ?[]const u8,
type: ?[]const u8,
title: ?[]const u8,
};
pub const StructuredData = struct {
json_ld: []const []const u8,
open_graph: []const Property,
twitter_card: []const Property,
meta: []const Property,
links: []const Property,
alternate: []const AlternateLink,
pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("jsonLd");
try jw.write(self.json_ld);
try jw.objectField("openGraph");
try writeProperties(jw, self.open_graph);
try jw.objectField("twitterCard");
try writeProperties(jw, self.twitter_card);
try jw.objectField("meta");
try writeProperties(jw, self.meta);
try jw.objectField("links");
try writeProperties(jw, self.links);
if (self.alternate.len > 0) {
try jw.objectField("alternate");
try jw.beginArray();
for (self.alternate) |alt| {
try jw.beginObject();
try jw.objectField("href");
try jw.write(alt.href);
if (alt.hreflang) |v| {
try jw.objectField("hreflang");
try jw.write(v);
}
if (alt.type) |v| {
try jw.objectField("type");
try jw.write(v);
}
if (alt.title) |v| {
try jw.objectField("title");
try jw.write(v);
}
try jw.endObject();
}
try jw.endArray();
}
try jw.endObject();
}
};
/// Serializes properties as a JSON object. When a key appears multiple times
/// (e.g. multiple og:image tags), values are grouped into an array.
/// Alternatives considered: always-array values (verbose), or an array of
/// {key, value} pairs (preserves order but less ergonomic for consumers).
fn writeProperties(jw: anytype, properties: []const Property) !void {
try jw.beginObject();
for (properties, 0..) |prop, i| {
// Skip keys already written by an earlier occurrence.
var already_written = false;
for (properties[0..i]) |prev| {
if (std.mem.eql(u8, prev.key, prop.key)) {
already_written = true;
break;
}
}
if (already_written) continue;
// Count total occurrences to decide string vs array.
var count: usize = 0;
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) count += 1;
}
try jw.objectField(prop.key);
if (count == 1) {
try jw.write(prop.value);
} else {
try jw.beginArray();
for (properties) |p| {
if (std.mem.eql(u8, p.key, prop.key)) {
try jw.write(p.value);
}
}
try jw.endArray();
}
}
try jw.endObject();
}
/// Extract all structured data from the page.
pub fn collectStructuredData(
root: *Node,
arena: Allocator,
page: *Page,
) !StructuredData {
var json_ld: std.ArrayList([]const u8) = .empty;
var open_graph: std.ArrayList(Property) = .empty;
var twitter_card: std.ArrayList(Property) = .empty;
var meta: std.ArrayList(Property) = .empty;
var links: std.ArrayList(Property) = .empty;
var alternate: std.ArrayList(AlternateLink) = .empty;
// Extract language from the root <html> element.
if (root.is(Element)) |root_el| {
if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
} else {
// Root is document — check documentElement.
var children = root.childrenIterator();
while (children.next()) |child| {
const el = child.is(Element) orelse continue;
if (el.getTag() == .html) {
if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| {
try meta.append(arena, .{ .key = "language", .value = lang });
}
break;
}
}
}
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
switch (el.getTag()) {
.script => {
try collectJsonLd(el, arena, &json_ld);
tw.skipChildren();
},
.meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {},
.title => try collectTitle(node, arena, &meta),
.link => try collectLink(el, arena, page, &links, &alternate),
// Skip body subtree for non-JSON-LD — all other metadata is in <head>.
// JSON-LD can appear in <body> so we don't skip the whole body.
else => {},
}
}
return .{
.json_ld = json_ld.items,
.open_graph = open_graph.items,
.twitter_card = twitter_card.items,
.meta = meta.items,
.links = links.items,
.alternate = alternate.items,
};
}
fn collectJsonLd(
el: *Element,
arena: Allocator,
json_ld: *std.ArrayList([]const u8),
) !void {
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return;
var buf: std.Io.Writer.Allocating = .init(arena);
try el.asNode().getTextContent(&buf.writer);
const text = buf.written();
if (text.len > 0) {
try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace));
}
}
fn collectMeta(
el: *Element,
open_graph: *std.ArrayList(Property),
twitter_card: *std.ArrayList(Property),
meta: *std.ArrayList(Property),
arena: Allocator,
) !void {
// charset: <meta charset="..."> (no content attribute needed).
if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| {
try meta.append(arena, .{ .key = "charset", .value = charset });
}
const content = el.getAttributeSafe(comptime .wrap("content")) orelse return;
// Open Graph: <meta property="og:...">
if (el.getAttributeSafe(comptime .wrap("property"))) |property| {
if (std.mem.startsWith(u8, property, "og:")) {
try open_graph.append(arena, .{ .key = property[3..], .value = content });
return;
}
// Article, profile, etc. are OG sub-namespaces.
if (std.mem.startsWith(u8, property, "article:") or
std.mem.startsWith(u8, property, "profile:") or
std.mem.startsWith(u8, property, "book:") or
std.mem.startsWith(u8, property, "music:") or
std.mem.startsWith(u8, property, "video:"))
{
try open_graph.append(arena, .{ .key = property, .value = content });
return;
}
}
// Twitter Cards: <meta name="twitter:...">
if (el.getAttributeSafe(comptime .wrap("name"))) |name| {
if (std.mem.startsWith(u8, name, "twitter:")) {
try twitter_card.append(arena, .{ .key = name[8..], .value = content });
return;
}
// Standard meta tags by name.
const known_names = [_][]const u8{
"description", "author", "keywords", "robots",
"viewport", "generator", "theme-color",
};
for (known_names) |known| {
if (std.ascii.eqlIgnoreCase(name, known)) {
try meta.append(arena, .{ .key = known, .value = content });
return;
}
}
}
// http-equiv (e.g. Content-Type, refresh)
if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| {
try meta.append(arena, .{ .key = http_equiv, .value = content });
}
}
fn collectTitle(
node: *Node,
arena: Allocator,
meta: *std.ArrayList(Property),
) !void {
var buf: std.Io.Writer.Allocating = .init(arena);
try node.getTextContent(&buf.writer);
const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
if (text.len > 0) {
try meta.append(arena, .{ .key = "title", .value = text });
}
}
fn collectLink(
el: *Element,
arena: Allocator,
page: *Page,
links: *std.ArrayList(Property),
alternate: *std.ArrayList(AlternateLink),
) !void {
const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return;
const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return;
const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href;
if (std.ascii.eqlIgnoreCase(rel, "alternate")) {
try alternate.append(arena, .{
.href = href,
.hreflang = el.getAttributeSafe(comptime .wrap("hreflang")),
.type = el.getAttributeSafe(comptime .wrap("type")),
.title = el.getAttributeSafe(comptime .wrap("title")),
});
return;
}
const relevant_rels = [_][]const u8{
"canonical", "icon", "manifest", "shortcut icon",
"apple-touch-icon", "search", "author", "license",
"dns-prefetch", "preconnect",
};
for (relevant_rels) |known| {
if (std.ascii.eqlIgnoreCase(rel, known)) {
try links.append(arena, .{ .key = known, .value = href });
return;
}
}
}
// --- Tests ---
const testing = @import("../testing.zig");
fn testStructuredData(html: []const u8) !StructuredData {
const page = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectStructuredData(div.asNode(), page.call_arena, page);
}
fn findProperty(props: []const Property, key: []const u8) ?[]const u8 {
for (props) |p| {
if (std.mem.eql(u8, p.key, key)) return p.value;
}
return null;
}
test "structured_data: json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
\\</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null);
}
test "structured_data: multiple json-ld" {
const data = try testStructuredData(
\\<script type="application/ld+json">{"@type":"Organization"}</script>
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
\\<script type="text/javascript">var x = 1;</script>
);
try testing.expectEqual(2, data.json_ld.len);
}
test "structured_data: open graph" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:description" content="A description">
\\<meta property="og:image" content="https://example.com/img.jpg">
\\<meta property="og:url" content="https://example.com">
\\<meta property="og:type" content="article">
\\<meta property="article:published_time" content="2026-03-10">
);
try testing.expectEqual(6, data.open_graph.len);
try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?);
try testing.expectEqual("article", findProperty(data.open_graph, "type").?);
try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?);
}
test "structured_data: open graph duplicate keys" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:image" content="https://example.com/img1.jpg">
\\<meta property="og:image" content="https://example.com/img2.jpg">
\\<meta property="og:image" content="https://example.com/img3.jpg">
);
// Duplicate keys are preserved as separate Property entries.
try testing.expectEqual(4, data.open_graph.len);
// Verify serialization groups duplicates into arrays.
const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{});
defer testing.allocator.free(json);
const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
defer parsed.deinit();
const og = parsed.value.object.get("openGraph").?.object;
// "title" appears once → string.
switch (og.get("title").?) {
.string => {},
else => return error.TestUnexpectedResult,
}
// "image" appears 3 times → array.
switch (og.get("image").?) {
.array => |arr| try testing.expectEqual(3, arr.items.len),
else => return error.TestUnexpectedResult,
}
}
test "structured_data: twitter card" {
const data = try testStructuredData(
\\<meta name="twitter:card" content="summary_large_image">
\\<meta name="twitter:site" content="@example">
\\<meta name="twitter:title" content="My Page">
);
try testing.expectEqual(3, data.twitter_card.len);
try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?);
try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?);
}
test "structured_data: meta tags" {
const data = try testStructuredData(
\\<title>Page Title</title>
\\<meta name="description" content="A test page">
\\<meta name="author" content="Test Author">
\\<meta name="keywords" content="test, example">
\\<meta name="robots" content="index, follow">
);
try testing.expectEqual("Page Title", findProperty(data.meta, "title").?);
try testing.expectEqual("A test page", findProperty(data.meta, "description").?);
try testing.expectEqual("Test Author", findProperty(data.meta, "author").?);
try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?);
try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?);
}
test "structured_data: link elements" {
const data = try testStructuredData(
\\<link rel="canonical" href="https://example.com/page">
\\<link rel="icon" href="/favicon.ico">
\\<link rel="manifest" href="/manifest.json">
\\<link rel="stylesheet" href="/style.css">
);
try testing.expectEqual(3, data.links.len);
try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?);
// stylesheet should be filtered out
try testing.expectEqual(null, findProperty(data.links, "stylesheet"));
}
test "structured_data: alternate links" {
const data = try testStructuredData(
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
);
try testing.expectEqual(2, data.alternate.len);
try testing.expectEqual("fr", data.alternate[0].hreflang.?);
try testing.expectEqual("French", data.alternate[0].title.?);
try testing.expectEqual("de", data.alternate[1].hreflang.?);
try testing.expectEqual(null, data.alternate[1].title);
}
test "structured_data: non-metadata elements ignored" {
const data = try testStructuredData(
\\<div>Just text</div>
\\<p>More text</p>
\\<a href="/link">Link</a>
);
try testing.expectEqual(0, data.json_ld.len);
try testing.expectEqual(0, data.open_graph.len);
try testing.expectEqual(0, data.twitter_card.len);
try testing.expectEqual(0, data.meta.len);
try testing.expectEqual(0, data.links.len);
}
test "structured_data: charset and http-equiv" {
const data = try testStructuredData(
\\<meta charset="utf-8">
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
);
try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?);
try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?);
}
test "structured_data: mixed content" {
const data = try testStructuredData(
\\<title>My Site</title>
\\<meta property="og:title" content="OG Title">
\\<meta name="twitter:card" content="summary">
\\<meta name="description" content="A page">
\\<link rel="canonical" href="https://example.com">
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
);
try testing.expectEqual(1, data.json_ld.len);
try testing.expectEqual(1, data.open_graph.len);
try testing.expectEqual(1, data.twitter_card.len);
try testing.expectEqual("My Site", findProperty(data.meta, "title").?);
try testing.expectEqual("A page", findProperty(data.meta, "description").?);
try testing.expectEqual(1, data.links.len);
}

View File

@@ -3,13 +3,67 @@
<script id=animation>
let a1 = document.createElement('div').animate(null, null);
testing.expectEqual('finished', a1.playState);
testing.expectEqual('idle', a1.playState);
let cb = [];
a1.ready.then(() => { cb.push('ready') });
a1.finished.then((x) => {
cb.push('finished');
cb.push(a1.playState);
cb.push(x == a1);
});
testing.eventually(() => testing.expectEqual(['finished', true], cb));
a1.ready.then(() => {
cb.push(a1.playState);
a1.play();
cb.push(a1.playState);
});
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
</script>
<script id=startTime>
let a2 = document.createElement('div').animate(null, null);
// startTime defaults to null
testing.expectEqual(null, a2.startTime);
// startTime is settable
a2.startTime = 42.5;
testing.expectEqual(42.5, a2.startTime);
// startTime can be reset to null
a2.startTime = null;
testing.expectEqual(null, a2.startTime);
</script>
<script id=onfinish>
let a3 = document.createElement('div').animate(null, null);
// onfinish defaults to null
testing.expectEqual(null, a3.onfinish);
let calls = [];
// onfinish callback should be scheduled and called asynchronously
a3.onfinish = function() { calls.push('finish'); };
a3.play();
testing.eventually(() => testing.expectEqual(['finish'], calls));
</script>
<script id=pause>
let a4 = document.createElement('div').animate(null, null);
let cb4 = [];
a4.finished.then((x) => { cb4.push(a4.playState) });
a4.ready.then(() => {
a4.play();
cb4.push(a4.playState)
a4.pause();
cb4.push(a4.playState)
});
testing.eventually(() => testing.expectEqual(['running', 'paused'], cb4));
</script>
<script id=finish>
let a5 = document.createElement('div').animate(null, null);
testing.expectEqual('idle', a5.playState);
let cb5 = [];
a5.finished.then((x) => { cb5.push(a5.playState) });
a5.ready.then(() => {
cb5.push(a5.playState);
a5.play();
});
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
</script>

View File

@@ -98,6 +98,64 @@
}
</script>
<script id=mime_parsing>
// MIME types are lowercased
{
const blob = new Blob([], { type: "TEXT/HTML" });
testing.expectEqual("text/html", blob.type);
}
{
const blob = new Blob([], { type: "Application/JSON" });
testing.expectEqual("application/json", blob.type);
}
// MIME with parameters - lowercased
{
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
testing.expectEqual("text/html; charset=utf-8", blob.type);
}
// Any ASCII string is accepted and lowercased (no MIME structure validation)
{
const blob = new Blob([], { type: "invalid" });
testing.expectEqual("invalid", blob.type);
}
{
const blob = new Blob([], { type: "/" });
testing.expectEqual("/", blob.type);
}
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
{
const blob = new Blob([], { type: "ý/x" });
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "text/plàin" });
testing.expectEqual("", blob.type);
}
// Control characters cause empty string
{
const blob = new Blob([], { type: "text/html\x00" });
testing.expectEqual("", blob.type);
}
// Empty type stays empty
{
const blob = new Blob([]);
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "" });
testing.expectEqual("", blob.type);
}
</script>
<script id=slice>
{
const parts = ["la", "symphonie", "des", "éclairs"];

View File

@@ -33,3 +33,105 @@
testing.expectEqual(ctx.fillStyle, "rgba(255, 0, 0, 0.06)");
}
</script>
<script id="CanvasRenderingContext2D#createImageData(width, height)">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
const imageData = ctx.createImageData(100, 200);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 100);
testing.expectEqual(imageData.height, 200);
testing.expectEqual(imageData.data.length, 100 * 200 * 4);
testing.expectEqual(true, imageData.data instanceof Uint8ClampedArray);
// All pixels should be initialized to 0.
testing.expectEqual(imageData.data[0], 0);
testing.expectEqual(imageData.data[1], 0);
testing.expectEqual(imageData.data[2], 0);
testing.expectEqual(imageData.data[3], 0);
}
</script>
<script id="CanvasRenderingContext2D#createImageData(imageData)">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
const source = ctx.createImageData(50, 75);
const imageData = ctx.createImageData(source);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 50);
testing.expectEqual(imageData.height, 75);
testing.expectEqual(imageData.data.length, 50 * 75 * 4);
}
</script>
<script id="CanvasRenderingContext2D#putImageData">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
const imageData = ctx.createImageData(10, 10);
testing.expectEqual(true, imageData instanceof ImageData);
// Modify some pixel data.
imageData.data[0] = 255;
imageData.data[1] = 0;
imageData.data[2] = 0;
imageData.data[3] = 255;
// putImageData should not throw.
ctx.putImageData(imageData, 0, 0);
ctx.putImageData(imageData, 10, 20);
// With dirty rect parameters.
ctx.putImageData(imageData, 0, 0, 0, 0, 5, 5);
}
</script>
<script id="CanvasRenderingContext2D#getImageData">
{
const element = document.createElement("canvas");
element.width = 100;
element.height = 50;
const ctx = element.getContext("2d");
const imageData = ctx.getImageData(0, 0, 10, 20);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 10);
testing.expectEqual(imageData.height, 20);
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
testing.expectEqual(true, imageData.data instanceof Uint8ClampedArray);
// Undrawn canvas should return transparent black pixels.
testing.expectEqual(imageData.data[0], 0);
testing.expectEqual(imageData.data[1], 0);
testing.expectEqual(imageData.data[2], 0);
testing.expectEqual(imageData.data[3], 0);
}
</script>
<script id="CanvasRenderingContext2D#getImageData invalid">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
// Zero or negative width/height should throw IndexSizeError.
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, 0));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, -5, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
}
</script>
<script id="getter">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual('10px sans-serif', ctx.font);
ctx.font = 'bold 48px serif'
testing.expectEqual('bold 48px serif', ctx.font);
}
</script>

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=OffscreenCanvas>
{
const canvas = new OffscreenCanvas(256, 256);
testing.expectEqual(true, canvas instanceof OffscreenCanvas);
testing.expectEqual(canvas.width, 256);
testing.expectEqual(canvas.height, 256);
}
</script>
<script id=OffscreenCanvas#width>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.width, 100);
canvas.width = 300;
testing.expectEqual(canvas.width, 300);
}
</script>
<script id=OffscreenCanvas#height>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.height, 200);
canvas.height = 400;
testing.expectEqual(canvas.height, 400);
}
</script>
<script id=OffscreenCanvas#getContext>
{
const canvas = new OffscreenCanvas(64, 64);
const ctx = canvas.getContext("2d");
testing.expectEqual(true, ctx instanceof OffscreenCanvasRenderingContext2D);
// We can't really test rendering but let's try to call it at least.
ctx.fillRect(0, 0, 10, 10);
}
</script>
<script id=OffscreenCanvas#convertToBlob>
{
const canvas = new OffscreenCanvas(64, 64);
const promise = canvas.convertToBlob();
testing.expectEqual(true, promise instanceof Promise);
// The promise should resolve to a Blob (even if empty)
promise.then(blob => {
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(blob.size, 0); // Empty since no rendering
});
}
</script>
<script id=HTMLCanvasElement#transferControlToOffscreen>
{
const htmlCanvas = document.createElement("canvas");
htmlCanvas.width = 128;
htmlCanvas.height = 96;
const offscreen = htmlCanvas.transferControlToOffscreen();
testing.expectEqual(true, offscreen instanceof OffscreenCanvas);
testing.expectEqual(offscreen.width, 128);
testing.expectEqual(offscreen.height, 96);
}
</script>
<script id=OffscreenCanvasRenderingContext2D#getImageData>
{
const canvas = new OffscreenCanvas(100, 50);
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, 10, 20);
testing.expectEqual(true, imageData instanceof ImageData);
testing.expectEqual(imageData.width, 10);
testing.expectEqual(imageData.height, 20);
testing.expectEqual(imageData.data.length, 10 * 20 * 4);
// Undrawn canvas should return transparent black pixels.
testing.expectEqual(imageData.data[0], 0);
testing.expectEqual(imageData.data[1], 0);
testing.expectEqual(imageData.data[2], 0);
testing.expectEqual(imageData.data[3], 0);
// Zero or negative dimensions should throw.
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 0, 10));
testing.expectError('Index or size', () => ctx.getImageData(0, 0, 10, -5));
}
</script>

View File

@@ -4,4 +4,6 @@
<script id=comment>
testing.expectEqual('', new Comment().data);
testing.expectEqual('over 9000! ', new Comment('over 9000! ').data);
testing.expectEqual('null', new Comment(null).data);
</script>

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<a id="link" href="foo" class="ok">OK</a>
<script src="../../testing.js"></script>
<script src="../testing.js"></script>
<script id=text>
let t = new Text('foo');
testing.expectEqual('foo', t.data);
@@ -16,4 +16,7 @@
let split = text.splitText('OK'.length);
testing.expectEqual(' modified', split.data);
testing.expectEqual('OK', text.data);
let x = new Text(null);
testing.expectEqual("null", x.data);
</script>

View File

@@ -69,3 +69,11 @@
testing.expectEqual(true, CSS.supports('z-index', '10'));
}
</script>
<script id="escape_null_character">
{
testing.expectEqual('\uFFFD', CSS.escape('\x00'));
testing.expectEqual('test\uFFFDvalue', CSS.escape('test\x00value'));
testing.expectEqual('\uFFFDabc', CSS.escape('\x00abc'));
}
</script>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_basic">
{
const face = new FontFace("TestFont", "url(test.woff)");
testing.expectTrue(face instanceof FontFace);
}
</script>
<script id="constructor_name">
{
testing.expectEqual('FontFace', FontFace.name);
}
</script>
<script id="family_property">
{
const face = new FontFace("MyFont", "url(font.woff2)");
testing.expectEqual("MyFont", face.family);
}
</script>
<script id="status_is_loaded">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("loaded", face.status);
}
</script>
<script id="loaded_is_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.loaded instanceof Promise);
}
</script>
<script id="load_returns_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.load() instanceof Promise);
}
</script>
<script id="default_descriptors">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("normal", face.style);
testing.expectEqual("normal", face.weight);
testing.expectEqual("normal", face.stretch);
testing.expectEqual("normal", face.variant);
testing.expectEqual("normal", face.featureSettings);
testing.expectEqual("auto", face.display);
}
</script>
<script id="document_fonts_add">
{
const face = new FontFace("AddedFont", "url(added.woff)");
const result = document.fonts.add(face);
testing.expectTrue(result === document.fonts);
}
</script>

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="document_fonts_exists">
{
testing.expectTrue(document.fonts !== undefined);
testing.expectTrue(document.fonts !== null);
}
</script>
<script id="document_fonts_same_instance">
{
// Should return same instance each time
const f1 = document.fonts;
const f2 = document.fonts;
testing.expectTrue(f1 === f2);
}
</script>
<script id="document_fonts_status">
{
testing.expectEqual('loaded', document.fonts.status);
}
</script>
<script id="document_fonts_size">
{
testing.expectEqual(0, document.fonts.size);
}
</script>
<script id="document_fonts_ready_is_promise">
{
const ready = document.fonts.ready;
testing.expectTrue(ready instanceof Promise);
}
</script>
<script id="document_fonts_ready_resolves">
{
let resolved = false;
document.fonts.ready.then(() => { resolved = true; });
// Promise resolution is async; just confirm .then() does not throw
testing.expectTrue(typeof document.fonts.ready.then === 'function');
}
</script>
<script id="document_fonts_check">
{
testing.expectTrue(document.fonts.check('16px sans-serif'));
}
</script>
<script id="document_fonts_constructor_name">
{
testing.expectEqual('FontFaceSet', document.fonts.constructor.name);
}
</script>
<script id="document_fonts_addEventListener">
{
let loading = false;
document.fonts.addEventListener('loading', function() {
loading = true;
});
let loadingdone = false;
document.fonts.addEventListener('loadingdone', function() {
loadingdone = true;
});
document.fonts.load("italic bold 16px Roboto");
testing.eventually(() => {
testing.expectEqual(true, loading);
testing.expectEqual(true, loadingdone);
});
testing.expectEqual(true, true);
}
</script>

View File

@@ -205,3 +205,217 @@
testing.expectEqual('', style.getPropertyPriority('content'));
}
</script>
<script id="CSSStyleDeclaration_style_syncs_to_attribute">
{
// JS style modifications must be reflected in getAttribute.
const div = document.createElement('div');
// Named property assignment (element.style.X = ...)
div.style.opacity = '0';
testing.expectEqual('opacity: 0;', div.getAttribute('style'));
// Update existing property
div.style.opacity = '1';
testing.expectEqual('opacity: 1;', div.getAttribute('style'));
// Add a second property
div.style.color = 'red';
testing.expectTrue(div.getAttribute('style').includes('opacity: 1'));
testing.expectTrue(div.getAttribute('style').includes('color: red'));
// removeProperty syncs back
div.style.removeProperty('opacity');
testing.expectTrue(!div.getAttribute('style').includes('opacity'));
testing.expectTrue(div.getAttribute('style').includes('color: red'));
// setCssText syncs back
div.style.cssText = 'filter: blur(0px)';
testing.expectEqual('filter: blur(0px);', div.getAttribute('style'));
// setCssText with empty string clears attribute
div.style.cssText = '';
testing.expectEqual('', div.getAttribute('style'));
}
</script>
<script id="CSSStyleDeclaration_outerHTML_reflects_style_changes">
{
// outerHTML must reflect JS-modified styles (regression test for
// DOM serialization reading stale HTML-parsed attribute values).
const div = document.createElement('div');
div.setAttribute('style', 'filter:blur(10px);opacity:0');
div.style.filter = 'blur(0px)';
div.style.opacity = '1';
const html = div.outerHTML;
testing.expectTrue(html.includes('filter: blur(0px)'));
testing.expectTrue(html.includes('opacity: 1'));
testing.expectTrue(!html.includes('blur(10px)'));
testing.expectTrue(!html.includes('opacity:0'));
}
</script>
<script id="CSSStyleDeclaration_non_ascii_custom_property">
{
// Regression test: accessing element.style must not crash when the inline
// style attribute contains CSS custom properties with non-ASCII (UTF-8
// multibyte) names, such as French accented characters.
// The CSS Tokenizer's consumeName() must advance over whole UTF-8 sequences
// rather than byte-by-byte to avoid landing on a continuation byte.
const div = document.createElement('div');
div.setAttribute('style',
'--color-store-bulles-\u00e9t\u00e9-fg: #6a818f;' +
'--color-store-soir\u00e9es-odl-fg: #56b3b3;' +
'color: red;'
);
// Must not crash, and ASCII properties that follow non-ASCII ones must be readable.
testing.expectEqual('red', div.style.getPropertyValue('color'));
}
</script>
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
{
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
const div = document.createElement('div');
div.style.width = '0';
testing.expectEqual('0px', div.style.width);
div.style.margin = '0';
testing.expectEqual('0px', div.style.margin);
div.style.padding = '0';
testing.expectEqual('0px', div.style.padding);
div.style.top = '0';
testing.expectEqual('0px', div.style.top);
// Scroll properties
div.style.scrollMarginTop = '0';
testing.expectEqual('0px', div.style.scrollMarginTop);
div.style.scrollPaddingBottom = '0';
testing.expectEqual('0px', div.style.scrollPaddingBottom);
// Multi-column
div.style.columnWidth = '0';
testing.expectEqual('0px', div.style.columnWidth);
div.style.columnRuleWidth = '0';
testing.expectEqual('0px', div.style.columnRuleWidth);
// Outline shorthand
div.style.outline = '0';
testing.expectEqual('0px', div.style.outline);
// Shapes
div.style.shapeMargin = '0';
testing.expectEqual('0px', div.style.shapeMargin);
// Non-length properties should not be affected
div.style.opacity = '0';
testing.expectEqual('0', div.style.opacity);
div.style.zIndex = '0';
testing.expectEqual('0', div.style.zIndex);
}
</script>
<script id="CSSStyleDeclaration_normalize_first_baseline">
{
// "first baseline" should serialize canonically as "baseline"
const div = document.createElement('div');
div.style.alignItems = 'first baseline';
testing.expectEqual('baseline', div.style.alignItems);
div.style.alignContent = 'first baseline';
testing.expectEqual('baseline', div.style.alignContent);
div.style.alignSelf = 'first baseline';
testing.expectEqual('baseline', div.style.alignSelf);
div.style.justifySelf = 'first baseline';
testing.expectEqual('baseline', div.style.justifySelf);
// "last baseline" should remain unchanged
div.style.alignItems = 'last baseline';
testing.expectEqual('last baseline', div.style.alignItems);
}
</script>
<script id="CSSStyleDeclaration_normalize_duplicate_values">
{
// For 2-value shorthand properties, "X X" should collapse to "X"
const div = document.createElement('div');
div.style.placeContent = 'center center';
testing.expectEqual('center', div.style.placeContent);
div.style.placeContent = 'start start';
testing.expectEqual('start', div.style.placeContent);
div.style.gap = '10px 10px';
testing.expectEqual('10px', div.style.gap);
// Different values should not collapse
div.style.placeContent = 'center start';
testing.expectEqual('center start', div.style.placeContent);
div.style.gap = '10px 20px';
testing.expectEqual('10px 20px', div.style.gap);
// New shorthands
div.style.overflow = 'hidden hidden';
testing.expectEqual('hidden', div.style.overflow);
div.style.scrollSnapAlign = 'start start';
testing.expectEqual('start', div.style.scrollSnapAlign);
div.style.overscrollBehavior = 'auto auto';
testing.expectEqual('auto', div.style.overscrollBehavior);
}
</script>
<script id="CSSStyleDeclaration_normalize_anchor_size">
{
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
const div = document.createElement('div');
// Already canonical order - should stay the same
div.style.width = 'anchor-size(--foo width)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// Non-canonical order - should be reordered
div.style.width = 'anchor-size(width --foo)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// With fallback value
div.style.width = 'anchor-size(height --bar, 100px)';
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
// Different size keywords
div.style.width = 'anchor-size(block --baz)';
testing.expectEqual('anchor-size(--baz block)', div.style.width);
div.style.width = 'anchor-size(inline --qux)';
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
div.style.width = 'anchor-size(self-block --test)';
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
div.style.width = 'anchor-size(self-inline --test)';
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
// Without anchor name (implicit default anchor)
div.style.width = 'anchor-size(width)';
testing.expectEqual('anchor-size(width)', div.style.width);
// Nested anchor-size in fallback
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
}
</script>

View File

@@ -53,3 +53,78 @@
testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName);
}
</script>
<div id=clone_container></div>
<script id=clone>
{
let calls = 0;
class MyCloneElementA extends HTMLElement {
constructor() {
super();
calls += 1;
$('#clone_container').appendChild(this);
}
}
customElements.define('my-clone_element_a', MyCloneElementA);
const original = document.createElement('my-clone_element_a');
$('#clone_container').cloneNode(true);
testing.expectEqual(2, calls);
}
</script>
<div id=fragment_clone_container></div>
<script id=clone_fragment>
{
let calls = 0;
class MyFragmentCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#fragment_clone_container').appendChild(this);
}
}
customElements.define('my-fragment-clone-element', MyFragmentCloneElement);
// Create a DocumentFragment with a custom element
const fragment = document.createDocumentFragment();
const customEl = document.createElement('my-fragment-clone-element');
fragment.appendChild(customEl);
// Clone the fragment - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedFragment = fragment.cloneNode(true);
testing.expectEqual(2, calls);
}
</script>
<div id=range_clone_container></div>
<script id=clone_range>
{
let calls = 0;
class MyRangeCloneElement extends HTMLElement {
constructor() {
super();
calls += 1;
$('#range_clone_container').appendChild(this);
}
}
customElements.define('my-range-clone-element', MyRangeCloneElement);
// Create a container with a custom element
const container = document.createElement('div');
const customEl = document.createElement('my-range-clone-element');
container.appendChild(customEl);
// Create a range that includes the custom element
const range = document.createRange();
range.selectNodeContents(container);
// Clone the range contents - this should trigger the crash
// because the constructor will attach the element during cloning
const clonedContents = range.cloneContents();
testing.expectEqual(2, calls);
}
</script>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<head>
<script src="../testing.js"></script>
<script>
// Test that document.open/write/close throw InvalidStateError during custom element
// reactions when the element is parsed from HTML
window.constructorOpenException = null;
window.constructorWriteException = null;
window.constructorCloseException = null;
window.constructorCalled = false;
class ThrowTestElement extends HTMLElement {
constructor() {
super();
window.constructorCalled = true;
// Try document.open on the same document during constructor - should throw
try {
document.open();
} catch (e) {
window.constructorOpenException = e;
}
// Try document.write on the same document during constructor - should throw
try {
document.write('<b>test</b>');
} catch (e) {
window.constructorWriteException = e;
}
// Try document.close on the same document during constructor - should throw
try {
document.close();
} catch (e) {
window.constructorCloseException = e;
}
}
}
customElements.define('throw-test-element', ThrowTestElement);
</script>
</head>
<body>
<!-- This element will be parsed from HTML, triggering the constructor -->
<throw-test-element id="test-element"></throw-test-element>
<script id="verify_throws">
{
// Verify the constructor was called
testing.expectEqual(true, window.constructorCalled);
// Verify document.open threw InvalidStateError
testing.expectEqual(true, window.constructorOpenException !== null);
testing.expectEqual('InvalidStateError', window.constructorOpenException.name);
// Verify document.write threw InvalidStateError
testing.expectEqual(true, window.constructorWriteException !== null);
testing.expectEqual('InvalidStateError', window.constructorWriteException.name);
// Verify document.close threw InvalidStateError
testing.expectEqual(true, window.constructorCloseException !== null);
testing.expectEqual('InvalidStateError', window.constructorCloseException.name);
}
</script>
</body>

View File

@@ -7,9 +7,11 @@
testing.expectEqual(true, htmlDiv1 instanceof HTMLDivElement);
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv1.namespaceURI);
// Per spec, createElementNS does NOT lowercase — 'DIV' != 'div', so this
// creates an HTMLUnknownElement, not an HTMLDivElement.
const htmlDiv2 = document.createElementNS('http://www.w3.org/1999/xhtml', 'DIV');
testing.expectEqual('DIV', htmlDiv2.tagName);
testing.expectEqual(true, htmlDiv2 instanceof HTMLDivElement);
testing.expectEqual(false, htmlDiv2 instanceof HTMLDivElement);
testing.expectEqual('http://www.w3.org/1999/xhtml', htmlDiv2.namespaceURI);
const svgRect = document.createElementNS('http://www.w3.org/2000/svg', 'RecT');

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<head id="the_head">
<meta charset="UTF-8">
<title>Test Document Title</title>
<script src="../testing.js"></script>
</head>
@@ -11,7 +12,7 @@
testing.expectEqual(10, document.childNodes[0].nodeType);
testing.expectEqual(null, document.parentNode);
testing.expectEqual(undefined, document.getCurrentScript);
testing.expectEqual("http://127.0.0.1:9582/src/browser/tests/document/document.html", document.URL);
testing.expectEqual(testing.BASE_URL + 'document/document.html', document.URL);
testing.expectEqual(window, document.defaultView);
testing.expectEqual(false, document.hidden);
testing.expectEqual("visible", document.visibilityState);
@@ -56,7 +57,7 @@
testing.expectEqual('CSS1Compat', document.compatMode);
testing.expectEqual(document.URL, document.documentURI);
testing.expectEqual('', document.referrer);
testing.expectEqual('127.0.0.1', document.domain);
testing.expectEqual(testing.HOST, document.domain);
</script>
<script id=programmatic_document_metadata>
@@ -69,7 +70,7 @@
testing.expectEqual('CSS1Compat', doc.compatMode);
testing.expectEqual('', doc.referrer);
// Programmatic document should have empty domain (no URL/origin)
testing.expectEqual('127.0.0.1', doc.domain);
testing.expectEqual(testing.HOST, doc.domain);
</script>
<!-- Test anchors and links -->
@@ -176,15 +177,111 @@
testing.expectEqual(initialLength, anchors.length);
</script>
<script id=cookie>
testing.expectEqual('', document.cookie);
document.cookie = 'name=Oeschger;';
document.cookie = 'favorite_food=tripe;';
<script id=cookie_basic>
// Basic cookie operations
document.cookie = 'testbasic1=Oeschger';
testing.expectEqual(true, document.cookie.includes('testbasic1=Oeschger'));
testing.expectEqual('name=Oeschger; favorite_food=tripe', document.cookie);
// "" should be returned, but the framework overrules it atm
document.cookie = 'testbasic2=tripe';
testing.expectEqual(true, document.cookie.includes('testbasic1=Oeschger'));
testing.expectEqual(true, document.cookie.includes('testbasic2=tripe'));
// HttpOnly should be ignored from JavaScript
const beforeHttp = document.cookie;
document.cookie = 'IgnoreMy=Ghost; HttpOnly';
testing.expectEqual('name=Oeschger; favorite_food=tripe', document.cookie);
testing.expectEqual(false, document.cookie.includes('IgnoreMy=Ghost'));
// Clean up
document.cookie = 'testbasic1=; Max-Age=0';
document.cookie = 'testbasic2=; Max-Age=0';
</script>
<script id=cookie_special_chars>
// Test special characters in cookie values
document.cookie = 'testspaces=hello world';
testing.expectEqual(true, document.cookie.includes('testspaces=hello world'));
document.cookie = 'testspaces=; Max-Age=0';
// Test various allowed special characters
document.cookie = 'testspecial=!#$%&\'()*+-./';
testing.expectEqual(true, document.cookie.includes('testspecial='));
document.cookie = 'testspecial=; Max-Age=0';
// Semicolon terminates the cookie value
document.cookie = 'testsemi=before;after';
testing.expectEqual(true, document.cookie.includes('testsemi=before'));
testing.expectEqual(false, document.cookie.includes('after'));
document.cookie = 'testsemi=; Max-Age=0';
</script>
<script id=cookie_empty_name>
// Cookie with empty name (just a value)
document.cookie = 'teststandalone';
testing.expectEqual(true, document.cookie.includes('teststandalone'));
document.cookie = 'teststandalone; Max-Age=0';
</script>
<script id=cookie_whitespace>
// Names and values should be trimmed
document.cookie = ' testtrim = trimmed_value ';
testing.expectEqual(true, document.cookie.includes('testtrim=trimmed_value'));
document.cookie = 'testtrim=; Max-Age=0';
</script>
<script id=cookie_max_age>
// Max-Age=0 should immediately delete
document.cookie = 'testtemp0=value; Max-Age=0';
testing.expectEqual(false, document.cookie.includes('testtemp0=value'));
// Negative Max-Age should also delete
document.cookie = 'testinstant=value';
testing.expectEqual(true, document.cookie.includes('testinstant=value'));
document.cookie = 'testinstant=value; Max-Age=-1';
testing.expectEqual(false, document.cookie.includes('testinstant=value'));
// Positive Max-Age should keep cookie
document.cookie = 'testkept=value; Max-Age=3600';
testing.expectEqual(true, document.cookie.includes('testkept=value'));
document.cookie = 'testkept=; Max-Age=0';
</script>
<script id=cookie_overwrite>
// Setting a cookie with the same name should overwrite
document.cookie = 'testoverwrite=first';
testing.expectEqual(true, document.cookie.includes('testoverwrite=first'));
document.cookie = 'testoverwrite=second';
testing.expectEqual(true, document.cookie.includes('testoverwrite=second'));
testing.expectEqual(false, document.cookie.includes('testoverwrite=first'));
document.cookie = 'testoverwrite=; Max-Age=0';
</script>
<script id=cookie_path>
// Path attribute
document.cookie = 'testpath1=value; Path=/';
testing.expectEqual(true, document.cookie.includes('testpath1=value'));
// Different path cookie should coexist
document.cookie = 'testpath2=value2; Path=/src';
testing.expectEqual(true, document.cookie.includes('testpath1=value'));
document.cookie = 'testpath1=; Max-Age=0; Path=/';
document.cookie = 'testpath2=; Max-Age=0; Path=/src';
</script>
<script id=cookie_invalid_chars>
// Control characters (< 32 or > 126) should be rejected
const beforeBad = document.cookie;
document.cookie = 'testbad1\x00=value';
testing.expectEqual(false, document.cookie.includes('testbad1'));
document.cookie = 'testbad2\x1F=value';
testing.expectEqual(false, document.cookie.includes('testbad2'));
document.cookie = 'testbad3=val\x7F';
testing.expectEqual(false, document.cookie.includes('testbad3'));
</script>
<script id=createAttribute>

View File

@@ -297,3 +297,25 @@
testing.expectEqual(btn, document.activeElement);
}
</script>
<script id="focus_disconnected_no_blur">
{
const input1 = $('#input1');
if (document.activeElement) {
document.activeElement.blur();
}
input1.focus();
testing.expectEqual(input1, document.activeElement);
let blurCount = 0;
input1.addEventListener('blur', () => { blurCount++ });
// Focusing a disconnected element should be a no-op:
// blur must not fire on the currently focused element
document.createElement('a').focus();
testing.expectEqual(input1, document.activeElement);
testing.expectEqual(0, blurCount);
}
</script>

View File

@@ -24,11 +24,10 @@
<script id=byId name="test1">
testing.expectEqual(1, document.querySelector.length);
testing.expectError("SyntaxError: Syntax Error", () => document.querySelector(''));
testing.expectError("SyntaxError", () => document.querySelector(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => document.querySelector(''));
testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name'));

View File

@@ -27,16 +27,17 @@
testing.expectEqual(expected.length, result.length);
testing.expectEqual(expected, Array.from(result).map((e) => e.textContent));
testing.expectEqual(expected, Array.from(result.values()).map((e) => e.textContent));
testing.expectEqual(expected.map((e, i) => i), Array.from(result.keys()));
testing.expectEqual(expected.map((e, i) => i.toString()), Object.keys(result));
}
</script>
<script id=script1 name="test1">
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
testing.expectError("SyntaxError", () => document.querySelectorAll(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => document.querySelectorAll(''));
</script>
@@ -376,3 +377,93 @@
}
</script>
<form id="form-validity-test">
<input id="vi-required-empty" type="text" required>
<input id="vi-optional" type="text">
<input id="vi-hidden-required" type="hidden" required>
<fieldset id="vi-fieldset">
<input id="vi-nested-required" type="text" required>
<select id="vi-select-required" required>
<option value="">Pick one</option>
<option value="a">A</option>
</select>
</fieldset>
</form>
<input id="vi-checkbox" type="checkbox">
<script id=invalidPseudo>
{
// Inputs with required + empty value are :invalid
testing.expectEqual(true, document.getElementById('vi-required-empty').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-required-empty').matches(':valid'));
// Inputs without required are :valid
testing.expectEqual(false, document.getElementById('vi-optional').matches(':invalid'));
testing.expectEqual(true, document.getElementById('vi-optional').matches(':valid'));
// hidden inputs are not candidates for constraint validation
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-hidden-required').matches(':valid'));
// select with required and empty selected value is :invalid
testing.expectEqual(true, document.getElementById('vi-select-required').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-select-required').matches(':valid'));
// fieldset containing invalid controls is :invalid
testing.expectEqual(true, document.getElementById('vi-fieldset').matches(':invalid'));
testing.expectEqual(false, document.getElementById('vi-fieldset').matches(':valid'));
// form containing invalid controls is :invalid
testing.expectEqual(true, document.getElementById('form-validity-test').matches(':invalid'));
testing.expectEqual(false, document.getElementById('form-validity-test').matches(':valid'));
}
</script>
<script id=validAfterValueSet>
{
// After setting a value, a required input becomes :valid
const input = document.getElementById('vi-required-empty');
input.value = 'hello';
testing.expectEqual(false, input.matches(':invalid'));
testing.expectEqual(true, input.matches(':valid'));
input.value = '';
}
</script>
<script id=indeterminatePseudo>
{
const cb = document.getElementById('vi-checkbox');
testing.expectEqual(false, cb.matches(':indeterminate'));
cb.indeterminate = true;
testing.expectEqual(true, cb.matches(':indeterminate'));
cb.indeterminate = false;
testing.expectEqual(false, cb.matches(':indeterminate'));
}
</script>
<script id=iterator_list_lifetime>
// This test is intended to ensure that a list remains alive as long as it
// must, i.e. as long as any iterator referencing the list is alive.
// This test depends on being able to force the v8 GC to cleanup, which
// we have no way of controlling. At worst, the test will pass without
// actually testing correct lifetime. But it was at least manually verified
// for me that this triggers plenty of GCs.
const expected = Array.from(document.querySelectorAll('*')).length;
{
let keys = [];
// Phase 1: Create many lists+iterators to fill up the arena pool
for (let i = 0; i < 1000; i++) {
let list = document.querySelectorAll('*');
keys.push(list.keys());
// Create an Event every iteration to compete for arenas
new Event('arena_compete');
}
for (let k of keys) {
const result = Array.from(k);
testing.expectEqual(expected, result.length);
}
}
</script>

View File

@@ -111,3 +111,15 @@
const containerDataTest = document.querySelector('#container [data-test]');
testing.expectEqual('First', containerDataTest.innerText);
</script>
<link rel="preload" as="image" imagesrcset="url1.png 1x, url2.png 2x" id="preload-link">
<script id="commaInAttrValue">
// Commas inside quoted attribute values must not be treated as selector separators
const el = document.querySelector('link[rel="preload"][as="image"][imagesrcset="url1.png 1x, url2.png 2x"]');
testing.expectEqual('preload-link', el.id);
// Also test with single quotes inside selector
const el2 = document.querySelector("link[imagesrcset='url1.png 1x, url2.png 2x']");
testing.expectEqual('preload-link', el2.id);
</script>

View File

@@ -127,7 +127,7 @@
testing.withError((err) => {
testing.expectEqual(3, err.code);
testing.expectEqual('Hierarchy Error', err.message);
testing.expectEqual('HierarchyRequestError', err.name);
testing.expectEqual(true, err instanceof DOMException);
testing.expectEqual(true, err instanceof Error);
}, () => link.appendChild(content));

View File

@@ -108,6 +108,20 @@
}
</script>
<script id=createHTMLDocument_nulll_title>
{
const impl = document.implementation;
const doc = impl.createHTMLDocument(null);
testing.expectEqual('null', doc.title);
// Should have title element in head
const titleElement = doc.head.querySelector('title');
testing.expectEqual(true, titleElement !== null);
testing.expectEqual('null', titleElement.textContent);
}
</script>
<script id=createHTMLDocument_structure>
{
const impl = document.implementation;

View File

@@ -4,9 +4,17 @@
<script id=basic>
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
{
const parser = new DOMParser();
testing.expectEqual('object', typeof parser);
testing.expectEqual('function', typeof parser.parseFromString);
}
{
const parser = new DOMParser();
let d = parser.parseFromString('', 'text/xml');
testing.expectEqual('<parsererror>error</parsererror>', new XMLSerializer().serializeToString(d));
}
}
</script>
@@ -389,3 +397,25 @@
}
}
</script>
<script id=getElementsByTagName-xml>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
// Test getElementsByTagName on document
const rows = doc.getElementsByTagName('row');
testing.expectEqual(1, rows.length);
// Test getElementsByTagName on element
const row = rows[0];
const cols = row.getElementsByTagName('col');
testing.expectEqual(2, cols.length);
testing.expectEqual('A', cols[0].textContent);
testing.expectEqual('B', cols[1].textContent);
// Test getElementsByTagName('*') on element
const allElements = row.getElementsByTagName('*');
testing.expectEqual(2, allElements.length);
}
</script>

View File

@@ -36,7 +36,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => el1.removeAttributeNode(script_id_node));
testing.expectEqual(an1, el1.removeAttributeNode(an1));

View File

@@ -154,3 +154,11 @@
testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function');
}
</script>
<div id=crash1 style="background-position: 5% .1em"></div>
<script id="crash_case_1">
{
testing.expectEqual('5% .1em', $('#crash1').style.backgroundPosition);
}
</script>

View File

@@ -11,11 +11,11 @@
<script id=empty_href>
testing.expectEqual('', $('#a0').href);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/anchor1.html', $('#a1').href);
testing.expectEqual('http://127.0.0.1:9582/hello/world/anchor2.html', $('#a2').href);
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', $('#link').href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);
</script>
<script id=dynamic_anchor_defaults>
@@ -129,7 +129,7 @@
testing.expectEqual('https://foo.bar/?q=bar#frag', link.href);
link.href = 'foo';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', link.href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', link.href);
testing.expectEqual('', link.type);
link.type = 'text/html';
@@ -245,3 +245,11 @@
testing.expectEqual('', b.toString());
}
</script>
<script id=url_encode>
{
let a = document.createElement('a');
a.href = 'over 9000!';
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!', a.href);
}
</script>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Details elements -->
<details id="details1">
<summary>Summary</summary>
Content
</details>
<details id="details2" open>
<summary>Open Summary</summary>
Content
</details>
<script id="instanceof">
{
const details = document.createElement('details')
testing.expectTrue(details instanceof HTMLDetailsElement)
}
</script>
<script id="open_initial">
testing.expectEqual(false, $('#details1').open)
testing.expectEqual(true, $('#details2').open)
</script>
<script id="open_set">
{
$('#details1').open = true
testing.expectEqual(true, $('#details1').open)
$('#details2').open = false
testing.expectEqual(false, $('#details2').open)
}
</script>
<script id="open_reflects_attribute">
{
const details = document.createElement('details')
testing.expectEqual(null, details.getAttribute('open'))
details.open = true
testing.expectEqual('', details.getAttribute('open'))
details.open = false
testing.expectEqual(null, details.getAttribute('open'))
}
</script>
<script id="name_initial">
{
const details = document.createElement('details')
testing.expectEqual('', details.name)
}
</script>
<script id="name_set">
{
const details = document.createElement('details')
details.name = 'group1'
testing.expectEqual('group1', details.name)
testing.expectEqual('group1', details.getAttribute('name'))
}
</script>

View File

@@ -143,6 +143,29 @@
}
</script>
<script id="js_setter_null_clears_listener">
{
// Setting an event handler property to null must silently clear it (not throw).
// Browsers also accept undefined and non-function values without throwing.
const div = document.createElement('div');
div.onload = () => 42;
testing.expectEqual('function', typeof div.onload);
// Setting to null removes the listener; getter returns null
div.onload = null;
testing.expectEqual(null, div.onload);
div.onerror = () => {};
div.onerror = null;
testing.expectEqual(null, div.onerror);
div.onclick = () => {};
div.onclick = null;
testing.expectEqual(null, div.onclick);
}
</script>
<script id="different_event_types_independent">
{
// Test that different event types are stored independently

View File

@@ -23,6 +23,22 @@
}
</script>
<script id="action">
{
const form = document.createElement('form')
testing.expectEqual(testing.BASE_URL + 'element/html/form.html', form.action)
form.action = 'hello';
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
testing.expectEqual(testing.ORIGIN + '/hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)
}
</script>
<!-- Test fixtures for form.method -->
<form id="form_get" method="get"></form>
<form id="form_post" method="post"></form>
@@ -327,3 +343,123 @@
testing.expectEqual('', form.elements['choice'].value)
}
</script>
<!-- Test: requestSubmit() fires the submit event (unlike submit()) -->
<form id="test_form2" action="/should-not-navigate2" method="get">
<input name="q" value="test2">
</form>
<script id="requestSubmit_fires_submit_event">
{
const form = $('#test_form2');
let submitFired = false;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitFired = true;
});
form.requestSubmit();
testing.expectEqual(true, submitFired);
}
</script>
<!-- Test: requestSubmit() with preventDefault stops navigation -->
<form id="test_form3" action="/should-not-navigate3" method="get">
<input name="q" value="test3">
</form>
<script id="requestSubmit_respects_preventDefault">
{
const form = $('#test_form3');
form.addEventListener('submit', (e) => {
e.preventDefault();
});
form.requestSubmit();
// Form submission was prevented, so no navigation should be scheduled
testing.expectEqual(true, true);
}
</script>
<!-- Test: requestSubmit() with non-submit-button submitter throws TypeError -->
<form id="test_form_rs1" action="/should-not-navigate4" method="get">
<input id="rs1_text" type="text" name="q" value="test">
<input id="rs1_submit" type="submit" value="Go">
<input id="rs1_image" type="image" src="x.png">
<button id="rs1_btn_submit" type="submit">Submit</button>
<button id="rs1_btn_reset" type="reset">Reset</button>
<button id="rs1_btn_button" type="button">Button</button>
</form>
<script id="requestSubmit_rejects_non_submit_button">
{
const form = $('#test_form_rs1');
form.addEventListener('submit', (e) => e.preventDefault());
// A text input is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_text'));
});
// A reset button is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_btn_reset'));
});
// A <button type="button"> is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_btn_button'));
});
// A <div> is not a submit button — should throw TypeError
const div = document.createElement('div');
form.appendChild(div);
testing.expectError('TypeError', () => {
form.requestSubmit(div);
});
}
</script>
<!-- Test: requestSubmit() accepts valid submit buttons -->
<script id="requestSubmit_accepts_submit_buttons">
{
const form = $('#test_form_rs1');
let submitCount = 0;
form.addEventListener('submit', (e) => { e.preventDefault(); submitCount++; });
// <input type="submit"> is a valid submitter
form.requestSubmit($('#rs1_submit'));
testing.expectEqual(1, submitCount);
// <input type="image"> is a valid submitter
form.requestSubmit($('#rs1_image'));
testing.expectEqual(2, submitCount);
// <button type="submit"> is a valid submitter
form.requestSubmit($('#rs1_btn_submit'));
testing.expectEqual(3, submitCount);
}
</script>
<!-- Test: requestSubmit() with submitter not owned by form throws NotFoundError -->
<form id="test_form_rs2" action="/should-not-navigate5" method="get">
<input type="text" name="q" value="test">
</form>
<form id="test_form_rs3">
<input id="rs3_submit" type="submit" value="Other Submit">
</form>
<script id="requestSubmit_rejects_wrong_form_submitter">
{
const form = $('#test_form_rs2');
// Submit button belongs to a different form — should throw NotFoundError
testing.expectError('NotFoundError', () => {
form.requestSubmit($('#rs3_submit'));
});
}
</script>

View File

@@ -32,12 +32,12 @@
img.src = 'test.png';
// src property returns resolved absolute URL
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.png', img.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src);
// getAttribute returns the raw attribute value
testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png';
testing.expectEqual('http://127.0.0.1:9582/absolute/path.png', img.src);
testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src);
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
img.src = 'https://example.com/image.png';
@@ -114,48 +114,15 @@
}
</script>
<script id="load-trigger-event">
<body></body>
<script id="img-load-event">
{
// An img fires a load event when src is set.
const img = document.createElement("img");
let count = 0;
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(true, count < 3);
count++;
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
});
for (let i = 0; i < 3; i++) {
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.expectEqual("https://cdn.lightpanda.io/website/assets/images/docs/hn.png", img.src);
}
// Make sure count is incremented asynchronously.
testing.expectEqual(0, count);
}
</script>
<img
id="inline-img"
src="https://cdn.lightpanda.io/website/assets/images/docs/hn.png"
onload="(() => testing.expectEqual(true, true))()"
/>
<script id="inline-on-load">
{
const img = document.getElementById("inline-img");
testing.expectEqual(true, img.onload instanceof Function);
// Also call inline to double check.
img.onload();
// Make sure ones attached with `addEventListener` also executed.
let result = false;
testing.async(async () => {
const result = await new Promise(resolve => {
await new Promise(resolve => {
img.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
@@ -163,12 +130,46 @@
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(img, target);
return resolve(true);
result = true;
return resolve();
});
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
});
testing.expectEqual(true, result);
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>
<script id="img-no-load-without-src">
{
// An img without src should not fire a load event.
let fired = false;
const img = document.createElement("img");
img.addEventListener("load", () => { fired = true; });
document.body.appendChild(img);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-src-set">
{
// Append to DOM first, then set src — load should still fire.
const img = document.createElement("img");
let result = false;
img.onload = () => result = true;
document.body.appendChild(img);
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.eventually(() => testing.expectEqual(true, result));
}
</script>
<script id=url_encode>
{
let img = document.createElement('img');
img.src = 'over 9000!?hello=world !';
testing.expectEqual('over 9000!?hello=world !', img.getAttribute('src'));
testing.expectEqual(testing.BASE_URL + 'element/html/over%209000!?hello=world%20!', img.src);
}
</script>

View File

@@ -46,7 +46,7 @@
testing.expectEqual(5, input.maxLength);
input.maxLength = 'banana';
testing.expectEqual(0, input.maxLength);
testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;});
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { input.maxLength = -45;});
testing.expectEqual(20, input.size);
input.size = 5;
@@ -57,9 +57,9 @@
testing.expectEqual('', input.src);
input.src = 'foo'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', input.src);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', input.src);
input.src = '-3'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/-3', input.src);
testing.expectEqual(testing.BASE_URL + 'element/html/-3', input.src);
input.src = ''
}
</script>
@@ -191,14 +191,14 @@
let eventCount = 0;
let lastEvent = null;
input.addEventListener('selectionchange', (e) => {
eventCount++;
lastEvent = e;
});
testing.expectEqual(0, eventCount);
input.setSelectionRange(0, 5);
input.select();
input.selectionStart = 3;
@@ -221,6 +221,44 @@
}
</script>
<script id="select_event">
{
const input = document.createElement('input');
input.value = 'Hello World';
document.body.appendChild(input);
let eventCount = 0;
let lastEvent = null;
input.addEventListener('select', (e) => {
eventCount++;
lastEvent = e;
});
let onselectFired = false;
input.onselect = () => { onselectFired = true; };
let bubbledToBody = false;
document.body.addEventListener('select', () => {
bubbledToBody = true;
});
testing.expectEqual(0, eventCount);
input.select();
testing.eventually(() => {
testing.expectEqual(1, eventCount);
testing.expectEqual('select', lastEvent.type);
testing.expectEqual(input, lastEvent.target);
testing.expectEqual(true, lastEvent.bubbles);
testing.expectEqual(false, lastEvent.cancelable);
testing.expectEqual(true, bubbledToBody);
testing.expectEqual(true, onselectFired);
});
}
</script>
<script id="defaultChecked">
testing.expectEqual(true, $('#check1').defaultChecked)
testing.expectEqual(false, $('#check2').defaultChecked)

View File

@@ -16,3 +16,59 @@
testing.expectEqual('', l2.htmlFor);
}
</script>
<label id="l2" for="input1"><span>Name</span></label>
<input id="input2" type="text">
<input id="input-hidden" type="hidden">
<select id="sel1"><option>a</option></select>
<button id="btn1">Click</button>
<label id="l3"><input id="input3"><span>desc</span></label>
<label id="l4"><span>no control here</span></label>
<label id="l5"><label id="l5-inner"><input id="input5"></label></label>
<script id="control">
{
// for attribute pointing to a text input
const l2 = document.getElementById('l2');
testing.expectEqual('input1', l2.control.id);
// for attribute pointing to a non-existent id
const lMissing = document.createElement('label');
lMissing.htmlFor = 'does-not-exist';
testing.expectEqual(null, lMissing.control);
// for attribute pointing to a hidden input -> not labelable, returns null
const lHidden = document.createElement('label');
lHidden.htmlFor = 'input-hidden';
document.body.appendChild(lHidden);
testing.expectEqual(null, lHidden.control);
// for attribute pointing to a select
const lSel = document.createElement('label');
lSel.htmlFor = 'sel1';
document.body.appendChild(lSel);
testing.expectEqual('sel1', lSel.control.id);
// for attribute pointing to a button
const lBtn = document.createElement('label');
lBtn.htmlFor = 'btn1';
document.body.appendChild(lBtn);
testing.expectEqual('btn1', lBtn.control.id);
// no for attribute: first labelable descendant
const l3 = document.getElementById('l3');
testing.expectEqual('input3', l3.control.id);
// no for attribute: no labelable descendant -> null
const l4 = document.getElementById('l4');
testing.expectEqual(null, l4.control);
// no for attribute: nested labels, first labelable in tree order
const l5 = document.getElementById('l5');
testing.expectEqual('input5', l5.control.id);
// label with no for and not in document -> null
const lDetached = document.createElement('label');
testing.expectEqual(null, lDetached.control);
}
</script>

View File

@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000';
testing.expectEqual('http://127.0.0.1:9582/over/9000', l2.href);
testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin);
@@ -19,3 +19,89 @@
l2.crossOrigin = '';
testing.expectEqual('anonymous', l2.crossOrigin);
</script>
<script id="link-load-event">
{
// A link with rel=stylesheet and a non-empty href fires a load event when appended to the DOM
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.async(async () => {
const result = await new Promise(resolve => {
link.addEventListener('load', ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(link, target);
resolve(true);
});
document.head.appendChild(link);
});
testing.expectEqual(true, result);
});
testing.expectEqual(true, true);
}
</script>
<script id="link-no-load-without-href">
{
// A link with rel=stylesheet but no href should not fire a load event
let fired = false;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="link-no-load-wrong-rel">
{
// A link without rel=stylesheet should not fire a load event
let fired = false;
const link = document.createElement('link');
link.href = 'https://lightpanda.io/opensource-browser/15';
link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired));
}
</script>
<script id="lazy-href-set">
{
let result = false;
const link = document.createElement("link");
link.rel = "stylesheet";
link.onload = () => result = true;
// Append to DOM,
document.head.appendChild(link);
// then set href.
link.href = 'https://lightpanda.io/opensource-browser/15';
testing.eventually(() => testing.expectEqual(true, result));
}
</script>
<script id="refs">
{
const rels = ['stylesheet', 'preload', 'modulepreload'];
const results = rels.map(() => false);
rels.forEach((rel, i) => {
let link = document.createElement('link')
link.rel = rel;
link.href = '/nope';
link.onload = () => results[i] = true;
document.documentElement.appendChild(link);
});
testing.eventually(() => {
results.forEach((r) => {
testing.expectEqual(true, r);
});
});
}
</script>

View File

@@ -238,7 +238,7 @@
testing.expectEqual('', audio.src);
audio.src = 'test.mp3';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.mp3', audio.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
}
</script>
@@ -248,7 +248,7 @@
testing.expectEqual('', video.poster);
video.poster = 'poster.jpg';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/poster.jpg', video.poster);
testing.expectEqual(testing.BASE_URL + 'element/html/poster.jpg', video.poster);
}
</script>

View File

@@ -29,6 +29,12 @@
testing.expectEqual('Text 3', $('#opt3').text)
</script>
<script id="text_set">
$('#opt1').text = 'New Text 1'
testing.expectEqual('New Text 1', $('#opt1').text)
testing.expectEqual('New Text 1', $('#opt1').textContent)
</script>
<script id="selected">
testing.expectEqual(false, $('#opt1').selected)
testing.expectEqual(true, $('#opt2').selected)

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<script id=force_async>
{
// Dynamically created scripts have async=true by default
let s = document.createElement('script');
testing.expectEqual(true, s.async);
// Setting async=false clears the force async flag and removes attribute
s.async = false;
testing.expectEqual(false, s.async);
testing.expectEqual(false, s.hasAttribute('async'));
// Setting async=true adds the attribute
s.async = true;
testing.expectEqual(true, s.async);
testing.expectEqual(true, s.hasAttribute('async'));
}
</script>
<script></script>
<script id=empty>
{
// Empty parser-inserted script should have async=true (force async retained)
let scripts = document.getElementsByTagName('script');
let emptyScript = scripts[scripts.length - 2];
testing.expectEqual(true, emptyScript.async);
}
</script>
<script id=text_content>
{
let s = document.createElement('script');
s.appendChild(document.createComment('COMMENT'));
s.appendChild(document.createTextNode(' TEXT '));
s.appendChild(document.createProcessingInstruction('P', 'I'));
let a = s.appendChild(document.createElement('a'));
a.appendChild(document.createTextNode('ELEMENT'));
// script.text should return only direct Text node children
testing.expectEqual(' TEXT ', s.text);
// script.textContent should return all descendant text
testing.expectEqual(' TEXT ELEMENT', s.textContent);
}
</script>
<script id=lazy_inline>
{
// Empty script in DOM, then append text - should execute
window.lazyScriptRan = false;
let s = document.createElement('script');
document.head.appendChild(s);
// Script is in DOM but empty, so not yet executed
testing.expectEqual(false, window.lazyScriptRan);
// Append text node with code
s.appendChild(document.createTextNode('window.lazyScriptRan = true;'));
// Now it should have executed
testing.expectEqual(true, window.lazyScriptRan);
}
</script>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<head></head>
<script src="../../../testing.js"></script>
<script id=textContent_inline>
window.inline_executed = false;
const s1 = document.createElement('script');
s1.textContent = 'window.inline_executed = true;';
document.head.appendChild(s1);
testing.expectTrue(window.inline_executed);
</script>
<script id=text_property_inline>
window.text_executed = false;
const s2 = document.createElement('script');
s2.text = 'window.text_executed = true;';
document.head.appendChild(s2);
testing.expectTrue(window.text_executed);
</script>
<script id=innerHTML_inline>
window.innerHTML_executed = false;
const s3 = document.createElement('script');
s3.innerHTML = 'window.innerHTML_executed = true;';
document.head.appendChild(s3);
testing.expectTrue(window.innerHTML_executed);
</script>
<script id=no_double_execute_inline>
window.inline_counter = 0;
const s4 = document.createElement('script');
s4.textContent = 'window.inline_counter++;';
document.head.appendChild(s4);
document.head.appendChild(s4);
testing.expectEqual(1, window.inline_counter);
</script>
<script id=empty_script_no_execute>
window.empty_ran = false;
const s5 = document.createElement('script');
document.head.appendChild(s5);
testing.expectFalse(window.empty_ran);
</script>
<script id=module_inline>
window.module_executed = false;
const s6 = document.createElement('script');
s6.type = 'module';
s6.textContent = 'window.module_executed = true;';
document.head.appendChild(s6);
testing.eventually(() => {
testing.expectTrue(window.module_executed);
});
</script>

View File

@@ -2,11 +2,28 @@
<script src="../../../testing.js"></script>
<script id="script">
{
let s = document.createElement('script');
testing.expectEqual('', s.src);
{
let dom_load = false;
let attribute_load = false;
s.src = '/over.9000.js';
testing.expectEqual('http://127.0.0.1:9582/over.9000.js', s.src);
let s = document.createElement('script');
document.documentElement.addEventListener('load', (e) => {
testing.expectEqual(s, e.target);
dom_load = true;
}, true);
testing.expectEqual('', s.src);
s.onload = function(e) {
testing.expectEqual(s, e.target);
attribute_load = true;
}
s.src = 'empty.js';
testing.expectEqual(testing.BASE_URL + 'element/html/script/empty.js', s.src);
document.head.appendChild(s);
testing.eventually(() => {
testing.expectEqual(true, dom_load);
testing.expectEqual(true, attribute_load);
});
}
</script>

View File

@@ -106,3 +106,28 @@
testing.expectEqual(true, style.disabled);
}
</script>
<script id="style-load-event">
{
// A style element fires a load event when appended to the DOM.
const style = document.createElement("style");
let result = false;
testing.async(async () => {
await new Promise(resolve => {
style.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(style, target);
result = true;
return resolve();
});
document.head.appendChild(style);
});
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -230,6 +230,44 @@
}
</script>
<script id="select_event">
{
const textarea = document.createElement('textarea');
textarea.value = 'Hello World';
document.body.appendChild(textarea);
let eventCount = 0;
let lastEvent = null;
textarea.addEventListener('select', (e) => {
eventCount++;
lastEvent = e;
});
let onselectFired = false;
textarea.onselect = () => { onselectFired = true; };
let bubbledToBody = false;
document.body.addEventListener('select', () => {
bubbledToBody = true;
});
testing.expectEqual(0, eventCount);
textarea.select();
testing.eventually(() => {
testing.expectEqual(1, eventCount);
testing.expectEqual('select', lastEvent.type);
testing.expectEqual(textarea, lastEvent.target);
testing.expectEqual(true, lastEvent.bubbles);
testing.expectEqual(false, lastEvent.cancelable);
testing.expectEqual(true, bubbledToBody);
testing.expectEqual(true, onselectFired);
});
}
</script>
<script id="selectionchange_event">
{
const textarea = document.createElement('textarea');

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<video id="video1">
<track id="track1" kind="subtitles">
<track id="track2" kind="captions">
<track id="track3" kind="invalid-kind">
</video>
<script id="instanceof">
{
const track = document.createElement("track");
testing.expectEqual(true, track instanceof HTMLTrackElement);
testing.expectEqual("[object HTMLTrackElement]", track.toString());
}
</script>
<script id="kind_default">
{
const track = document.createElement("track");
testing.expectEqual("subtitles", track.kind);
}
</script>
<script id="kind_valid_values">
{
const track = document.createElement("track");
track.kind = "captions";
testing.expectEqual("captions", track.kind);
track.kind = "descriptions";
testing.expectEqual("descriptions", track.kind);
track.kind = "chapters";
testing.expectEqual("chapters", track.kind);
track.kind = "metadata";
testing.expectEqual("metadata", track.kind);
}
</script>
<script id="kind_invalid">
{
const track = document.createElement("track");
track.kind = null;
testing.expectEqual("metadata", track.kind);
track.kind = "Subtitles";
testing.expectEqual("subtitles", track.kind);
track.kind = "";
testing.expectEqual("metadata", track.kind);
}
</script>
<script id="constants">
{
const track = document.createElement("track");
testing.expectEqual(0, track.NONE);
testing.expectEqual(1, track.LOADING);
testing.expectEqual(2, track.LOADED);
testing.expectEqual(3, track.ERROR);
}
</script>
<script id="constants_static">
{
testing.expectEqual(0, HTMLTrackElement.NONE);
testing.expectEqual(1, HTMLTrackElement.LOADING);
testing.expectEqual(2, HTMLTrackElement.LOADED);
testing.expectEqual(3, HTMLTrackElement.ERROR);
}
</script>

View File

@@ -45,7 +45,6 @@
testing.expectEqual('hi', $('#link').innerText);
d1.innerHTML = '';
testing.todo(null, $('#link'));
</script>
<script id=attributeSerialization>

View File

@@ -66,11 +66,10 @@
{
const container = $('#test-container');
testing.expectError("SyntaxError: Syntax Error", () => container.matches(''));
testing.expectError("SyntaxError", () => container.matches(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => container.matches(''));
}
</script>

View File

@@ -81,6 +81,17 @@
}
</script>
<script id="is_empty">
{
// Empty :is() and :where() are valid per spec and match nothing
const isEmptyResult = document.querySelectorAll(':is()');
testing.expectEqual(0, isEmptyResult.length);
const whereEmptyResult = document.querySelectorAll(':where()');
testing.expectEqual(0, whereEmptyResult.length);
}
</script>
<div id=escaped class=":popover-open"></div>
<script id="escaped">
{

Some files were not shown because too many files have changed in this diff Show More