171 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
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
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
Halil Durak
8b310ce993 add failing body.onload tests 2026-03-13 17:23:26 +03:00
121 changed files with 3787 additions and 1430 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.3.3'
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

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
@@ -126,7 +120,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -134,7 +128,7 @@ jobs:
- run: npm install
- name: download artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -182,39 +176,41 @@ jobs:
name: wba-test
needs: zig-build-release
env:
LIGHTPANDA_DISABLE_TELEMETRY: true
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
- name: download artifact
uses: actions/download-artifact@v4
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 2
sleep 5
./lightpanda fetch http://127.0.0.1:8989/ \
--web_bot_auth_key_file private_key.pem \
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
@@ -224,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
@@ -239,7 +234,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -247,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
@@ -333,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: |
@@ -356,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
@@ -379,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

@@ -10,7 +10,7 @@ env:
on:
schedule:
- cron: "23 2 * * *"
- cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -19,23 +19,31 @@ jobs:
wpt-build-release:
name: zig build release
runs-on: ubuntu-latest
timeout-minutes: 15
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: 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 -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=generic -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: |
@@ -45,7 +53,7 @@ jobs:
wpt-build-runner:
name: build wpt runner
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-arm
timeout-minutes: 15
steps:
@@ -59,7 +67,7 @@ jobs:
CGO_ENABLED=0 go build
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: wptrunner
path: |
@@ -73,8 +81,8 @@ jobs:
- wpt-build-runner
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 180
runs-on: lpd-wpt-aws
timeout-minutes: 600
steps:
- uses: actions/checkout@v6
@@ -91,14 +99,14 @@ jobs:
run: ./wpt manifest
- name: download lightpanda release
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: wptrunner
@@ -107,8 +115,8 @@ jobs:
- name: run test with json output
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
sleep 20s
./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid`
- name: write commit
@@ -116,7 +124,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: wpt-results
path: |
@@ -139,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

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.3.3
ARG ZIG_V8=v0.3.4
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands
# ------------
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
## Build v8 snapshot
build-v8-snapshot:
@@ -77,11 +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;)
## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos)
test:
@@ -106,4 +101,3 @@ install: build
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig

377
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,55 +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
```
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
@@ -266,107 +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).
We use [a fork](https://github.com/lightpanda-io/wpt/tree/fork) including a custom
[`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
You can also run individual WPT test cases in your browser via [wpt.live](https://wpt.live).
For reference, you can easily execute a WPT test case with your browser via
[wpt.live](https://wpt.live).
**Setup WPT HTTP server:**
#### Configure WPT HTTP server
To run the test, you must clone the repository, configure the custom hosts and generate the
`MANIFEST.json` file.
Clone the repository with the `fork` branch.
```
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
```
Enter into the `wpt/` dir.
Install custom domains in your `/etc/hosts`
```
cd wpt
./wpt make-hosts-file | sudo tee -a /etc/hosts
```
Generate `MANIFEST.json`
```
./wpt manifest
```
Use the [WPT's setup
guide](https://web-platform-tests.org/running-tests/from-local-system.html) for
details.
#### Run WPT test suite
See the [WPT setup guide](https://web-platform-tests.org/running-tests/from-local-system.html) for details.
An external [Go](https://go.dev) runner is provided by
[github.com/lightpanda-io/demo/](https://github.com/lightpanda-io/demo/)
repository, located into `wptrunner/` dir.
You need to clone the project first.
**Run WPT tests:**
Start the WPT HTTP server:
First start the WPT's HTTP server from your `wpt/` clone dir.
```
./wpt serve
```
Run a Lightpanda browser
Run Lightpanda:
```
zig build run -- --insecure_disable_tls_host_verification
```
Then you can start the wptrunner from the Demo's clone dir:
Run the test suite (from [demo](https://github.com/lightpanda-io/demo/) clone):
```
cd wptrunner && go run .
```
Or one specific test:
Run a specific test:
```
cd wptrunner && go run . Node-childNodes.html
```
`wptrunner` command accepts `--summary` and `--json` options modifying output.
Also `--concurrency` define the concurrency limit.
Options: `--summary`, `--json`, `--concurrency`.
:warning: Running the whole test suite will take a long time. In this case,
it's useful to build in `releaseFast` mode to make tests faster.
**Note:** The full suite takes a long time. Build with `zig build -Doptimize=ReleaseFast run` for faster test execution.
```
zig build -Doptimize=ReleaseFast run
```
---
## 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

View File

@@ -27,12 +27,14 @@ pub fn build(b: *Build) !void {
const manifest = Manifest.init(b);
const git_commit = b.option([]const u8, "git_commit", "Current git commit");
const git_version = b.option([]const u8, "git_version", "Current git version (from tag)");
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
var opts = b.addOptions();
opts.addOption([]const u8, "version", manifest.version);
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
opts.addOption(?[]const u8, "git_version", git_version orelse null);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.3.tar.gz",
.hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD",
.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 = .{

View File

@@ -67,7 +67,7 @@ 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, 512, 1024 * 16);
errdefer app.arena_pool.deinit();
@@ -85,7 +85,7 @@ pub fn deinit(self: *App) void {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
self.telemetry.deinit();
self.telemetry.deinit(allocator);
self.network.deinit();
self.snapshot.deinit();
self.platform.deinit();

View File

@@ -36,7 +36,9 @@ dom_node: *Node,
registry: *CDPNode.Registry,
page: *Page,
arena: std.mem.Allocator,
prune: bool = false,
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 };
@@ -45,7 +47,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| {
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;
};
@@ -58,7 +60,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed;
};
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets) catch |err| {
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;
};
@@ -71,7 +73,7 @@ const OptionData = struct {
};
const NodeData = struct {
id: u32,
id: CDPNode.Id,
axn: AXNode,
role: []const u8,
name: ?[]const u8,
@@ -82,7 +84,9 @@ const NodeData = struct {
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) !void {
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();
@@ -174,7 +178,23 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
};
var should_visit = true;
if (self.prune) {
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;
}
@@ -213,7 +233,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
}
gop.value_ptr.* += 1;
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets);
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
}
}
@@ -389,36 +409,45 @@ const TextVisitor = struct {
depth: usize,
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
// Format: " [12] link: Hacker News (value)"
for (0..(self.depth * 2)) |_| {
for (0..self.depth) |_| {
try self.writer.writeByte(' ');
}
try self.writer.print("[{d}] {s}: ", .{ data.id, data.role });
var name_to_print: ?[]const u8 = null;
if (data.name) |n| {
if (n.len > 0) {
try self.writer.writeAll(n);
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) {
try self.writer.writeAll(trimmed);
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});
try self.writer.print(" value='{s}'", .{v});
}
}
if (data.options) |options| {
try self.writer.writeAll(" options: [");
try self.writer.writeAll(" options=[");
for (options, 0..) |opt, i| {
if (i > 0) try self.writer.writeAll(", ");
if (i > 0) try self.writer.writeAll(",");
try self.writer.print("'{s}'", .{opt.value});
if (opt.selected) {
try self.writer.writeAll(" (selected)");
try self.writer.writeAll("*");
}
}
try self.writer.writeAll("]\n");
@@ -448,3 +477,56 @@ const TextVisitor = struct {
}
}
};
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);
}

View File

@@ -64,17 +64,17 @@ pub fn init(app: *App, address: net.Address) !*Server {
return self;
}
pub fn deinit(self: *Server) void {
// Stop all active clients
{
self.client_mutex.lock();
defer self.client_mutex.unlock();
pub fn shutdown(self: *Server) void {
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items) |client| {
client.stop();
}
for (self.clients.items) |client| {
client.stop();
}
}
pub fn deinit(self: *Server) void {
self.shutdown();
self.joinThreads();
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
@@ -242,7 +242,10 @@ pub const Client = struct {
fn stop(self: *Client) void {
switch (self.mode) {
.http => {},
.cdp => |*cdp| cdp.browser.env.terminate(),
.cdp => |*cdp| {
cdp.browser.env.terminate();
self.ws.sendClose();
},
}
self.ws.shutdown();
}
@@ -295,7 +298,7 @@ pub const Client = struct {
}
var cdp = &self.mode.cdp;
var last_message = timestamp(.monotonic);
var last_message = milliTimestamp(.monotonic);
var ms_remaining = self.ws.timeout_ms;
while (true) {
@@ -304,7 +307,7 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.no_page => {
@@ -319,16 +322,18 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
last_message = timestamp(.monotonic);
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.done => {
const elapsed = timestamp(.monotonic) - last_message;
if (elapsed > ms_remaining) {
const now = milliTimestamp(.monotonic);
const elapsed = now - last_message;
if (elapsed >= ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
last_message = now;
},
}
}
@@ -501,6 +506,7 @@ fn buildJSONVersionResponse(
}
pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = std.testing;
test "server: buildJSONVersionResponse" {

View File

@@ -91,25 +91,32 @@ pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks();
}
pub fn runMacrotasks(self: *Browser) !?u64 {
pub fn runMacrotasks(self: *Browser) !void {
const env = &self.env;
const time_to_next = try self.env.runMacrotasks();
try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
return time_to_next;
}
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

@@ -233,6 +233,12 @@ const DispatchDirectOptions = struct {
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);
@@ -398,6 +404,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
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.

View File

@@ -110,6 +110,8 @@ use_proxy: bool,
// Current TLS verification state, applied per-connection in makeRequest.
tls_verify: bool = true,
obey_robots: bool,
cdp_client: ?CDPClient = null,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
@@ -154,6 +156,7 @@ pub fn init(allocator: Allocator, network: *Network) !*Client {
.http_proxy = http_proxy,
.use_proxy = http_proxy != null,
.tls_verify = network.config.tlsVerifyHost(),
.obey_robots = network.config.obeyRobots(),
.transfer_pool = transfer_pool,
};
@@ -257,34 +260,33 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus {
}
pub fn request(self: *Client, req: Request) !void {
if (self.network.config.obeyRobots()) {
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
errdefer self.allocator.free(robots_url);
// If we have this robots cached, we can take a fast path.
if (self.network.robot_store.get(robots_url)) |robot_entry| {
defer self.allocator.free(robots_url);
switch (robot_entry) {
// If we have a found robots entry, we check it.
.present => |robots| {
const path = URL.getPathname(req.url);
if (!robots.isAllowed(path)) {
req.error_callback(req.ctx, error.RobotsBlocked);
return;
}
},
// Otherwise, we assume we won't find it again.
.absent => {},
}
return self.processRequest(req);
}
return self.fetchRobotsThenProcessRequest(robots_url, req);
if (self.obey_robots == false) {
return self.processRequest(req);
}
return self.processRequest(req);
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
errdefer self.allocator.free(robots_url);
// If we have this robots cached, we can take a fast path.
if (self.network.robot_store.get(robots_url)) |robot_entry| {
defer self.allocator.free(robots_url);
switch (robot_entry) {
// If we have a found robots entry, we check it.
.present => |robots| {
const path = URL.getPathname(req.url);
if (!robots.isAllowed(path)) {
req.error_callback(req.ctx, error.RobotsBlocked);
return;
}
},
// Otherwise, we assume we won't find it again.
.absent => {},
}
return self.processRequest(req);
}
return self.fetchRobotsThenProcessRequest(robots_url, req);
}
fn processRequest(self: *Client, req: Request) !void {

View File

@@ -25,6 +25,7 @@ params: []const u8 = "",
// We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len,
is_default_charset: bool = true,
/// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
@@ -130,6 +131,7 @@ pub fn parse(input: []u8) !Mime {
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| {
@@ -156,6 +158,7 @@ pub fn parse(input: []u8) !Mime {
// Null-terminate right after attribute value.
charset[attribute_value.len] = 0;
charset_len = attribute_value.len;
has_explicit_charset = true;
},
}
}
@@ -165,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 });
@@ -178,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;
}
@@ -540,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 {
@@ -576,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));
}

View File

@@ -62,6 +62,7 @@ const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
@@ -307,14 +308,16 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
document._page = self;
if (comptime builtin.is_test == false) {
// HTML test runner manually calls these as necessary
try self.js.scheduler.add(session.browser, struct {
fn runIdleTasks(ctx: *anyopaque) !?u32 {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
b.runIdleTasks();
return 200;
}
}.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
if (parent == null) {
// HTML test runner manually calls these as necessary
try self.js.scheduler.add(session.browser, struct {
fn runIdleTasks(ctx: *anyopaque) !?u32 {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
b.runIdleTasks();
return 200;
}
}.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
}
}
}
@@ -407,16 +410,9 @@ pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
return std.mem.startsWith(u8, url, current_origin);
}
/// Look up a blob URL in this page's registry, walking up the parent chain.
/// Look up a blob URL in this page's registry.
pub fn lookupBlobUrl(self: *Page, url: []const u8) ?*Blob {
var current: ?*Page = self;
while (current) |page| {
if (page._blob_urls.get(url)) |blob| {
return blob;
}
current = page.parent;
}
return null;
return self._blob_urls.get(url);
}
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void {
@@ -457,7 +453,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// Content injection
if (is_blob) {
const blob = self.lookupBlobUrl(request_url) orelse {
// For navigation, walk up the parent chain to find blob URLs
// (e.g., parent creates blob URL and sets iframe.src to it)
const blob = blk: {
var current: ?*Page = self.parent;
while (current) |page| {
if (page._blob_urls.get(request_url)) |b| break :blk b;
current = page.parent;
}
log.warn(.js, "invalid blob", .{ .url = request_url });
return error.BlobNotFound;
};
@@ -709,11 +712,14 @@ pub fn scriptsCompletedLoading(self: *Page) void {
}
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
blk: {
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
var ls: JS.Local.Scope = undefined;
self.js.localScope(&ls);
defer ls.deinit();
const entered = self.js.enter(&ls.handle_scope);
defer entered.exit();
blk: {
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk;
@@ -722,6 +728,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
};
}
self.pendingLoadCompleted();
}
@@ -848,13 +855,25 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data
// to sniff the content type
const mime: Mime = blk: {
var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| {
break :blk try Mime.parse(ct);
}
break :blk Mime.sniff(data);
} orelse .unknown;
// If the HTTP Content-Type header didn't specify a charset and this is HTML,
// prescan the first 1024 bytes for a <meta charset> declaration.
if (mime.content_type == .text_html and mime.is_default_charset) {
if (Mime.prescanCharset(data)) |charset| {
if (charset.len <= 40) {
@memcpy(mime.charset[0..charset.len], charset);
mime.charset[charset.len] = 0;
mime.charset_len = charset.len;
}
}
}
if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{
.content_type = mime.content_type,
@@ -1091,7 +1110,6 @@ pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
self._pending_loads -= 1;
iframe._window = null;
page_frame.deinit(true);
return error.IFrameLoadError;
};
@@ -3256,14 +3274,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
.type = self._type,
});
}
const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
}, self)).asEvent();
try self._event_manager.dispatch(target.asEventTarget(), event);
}, self);
try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent());
}
// callback when the "click" event reaches the pages.
@@ -3507,13 +3525,16 @@ fn asUint(comptime string: anytype) std.meta.Int(
const testing = @import("../testing.zig");
test "WebApi: Page" {
const filter: testing.LogFilter = .init(.http);
const filter: testing.LogFilter = .init(&.{ .http, .js });
defer filter.deinit();
try testing.htmlRunner("page", .{});
}
test "WebApi: Frames" {
const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("frames", .{});
}

View File

@@ -63,9 +63,6 @@ shutdown: bool = false,
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,
@@ -101,18 +98,14 @@ pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptM
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
.buffer_pool = BufferPool.init(allocator, 5),
.page_notified_of_completion = false,
.script_pool = std.heap.MemoryPool(Script).init(allocator),
};
}
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.
@@ -121,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();
@@ -138,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) !net_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;
}
@@ -191,19 +187,26 @@ 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 {
var buf = std.Io.Writer.Allocating.init(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();
@@ -211,6 +214,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
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 };
@@ -218,15 +222,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// Only set _executed (already-started) when we actually have content to execute
script_element._executed = true;
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
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,
@@ -270,7 +272,7 @@ 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(.{
@@ -278,7 +280,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(url),
.headers = try self.getHeaders(arena, url),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
@@ -289,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;
@@ -318,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;
}
@@ -327,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);
}
@@ -359,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,
@@ -373,11 +379,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.mode = .import,
};
gop.value_ptr.* = ImportedModule{
.manager = self,
};
const page = self.page;
gop.value_ptr.* = ImportedModule{};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -392,12 +394,18 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
});
}
try self.client.request(.{
// 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(url),
.headers = try self.getHeaders(arena, url),
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
.notification = page._session.notification,
@@ -406,13 +414,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.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);
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
@@ -433,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;
@@ -447,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,
@@ -456,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,
@@ -473,7 +481,6 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} },
};
const page = self.page;
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -496,11 +503,12 @@ 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,
.frame_id = page._frame_id,
.headers = try self.getHeaders(url),
.headers = try self.getHeaders(arena, url),
.ctx = script,
.resource_type = .script,
.cookie_jar = &page._session.cookie_jar,
@@ -510,9 +518,10 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
.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
@@ -537,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,
});
}
},
@@ -574,7 +583,7 @@ fn evaluate(self: *ScriptManager) void {
}
defer {
_ = self.defer_scripts.popFirst();
script.deinit(true);
script.deinit();
}
script.eval(page);
}
@@ -625,11 +634,12 @@ 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,
@@ -680,11 +690,8 @@ 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: *HttpClient.Transfer) !void {
@@ -750,9 +757,9 @@ pub const Script = struct {
}
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;
@@ -766,7 +773,7 @@ pub const Script = struct {
};
}
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.manager.allocator, data);
try self.source.remote.appendSlice(self.arena, data);
}
fn doneCallback(ctx: *anyopaque) !void {
@@ -783,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();
}
@@ -811,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;
}
@@ -823,7 +829,7 @@ pub const Script = struct {
},
else => {},
}
self.deinit(true);
self.deinit();
manager.evaluate();
}
@@ -951,76 +957,6 @@ pub const Script = struct {
}
};
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,
@@ -1030,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();
}
}
@@ -1045,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,
};
};

View File

@@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
@@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0);
}
var ms: u64 = ms_to_next_task orelse 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;
}
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
@@ -441,9 +441,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
break :blk 20;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
break :blk browser.msToNextMacrotask() orelse return .done;
};
if (ms > ms_remaining) {
@@ -470,9 +468,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// 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 lowPriority tasks, so we
// minimize how long we'll poll for network I/O.
var ms_to_wait = @min(200, ms_to_next_task orelse 200);
// 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
@@ -548,7 +546,9 @@ fn processQueuedNavigation(self: *Session) !void {
continue;
}
try self.processFrameNavigation(page, qn);
self.processFrameNavigation(page, qn) catch |err| {
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
};
}
// Clear the queue after first pass
@@ -588,7 +588,8 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
errdefer iframe._window = null;
if (page._parent_notified) {
const parent_notified = page._parent_notified;
if (parent_notified) {
// we already notified the parent that we had loaded
parent._pending_loads += 1;
}
@@ -598,7 +599,19 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
page.* = undefined;
try Page.init(page, frame_id, self, parent);
errdefer page.deinit(true);
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;

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

@@ -253,17 +253,52 @@ pub fn classifyInteractivity(
return null;
}
fn isInteractiveRole(role: []const u8) bool {
const interactive_roles = [_][]const u8{
"button", "link", "tab", "menuitem",
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
"radio", "slider", "spinbutton", "searchbox",
"combobox", "option", "treeitem",
};
for (interactive_roles) |r| {
if (std.ascii.eqlIgnoreCase(role, r)) return true;
}
return false;
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 {

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 {
@@ -537,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;

View File

@@ -119,12 +119,22 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null,
};
pub fn fromC(c_context: *const v8.Context) *Context {
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 {
return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?);
/// 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).?;
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 {
@@ -155,6 +165,11 @@ pub fn deinit(self: *Context) void {
self.session.releaseOrigin(self.origin);
// 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
@@ -167,12 +182,11 @@ 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 self.origin.transferTo(origin);
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
self.origin.deinit(env.app);
try origin.takeover(self.origin);
self.origin = origin;
@@ -197,18 +211,20 @@ pub fn trackTemp(self: *Context, global: v8.Global) !void {
}
pub fn weakRef(self: *Context, obj: anytype) void {
const fc = self.origin.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.origin.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);
@@ -216,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.origin.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);
@@ -252,6 +269,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(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,
@@ -303,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 };
@@ -473,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.?,
@@ -506,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.?,
@@ -524,13 +545,13 @@ pub fn dynamicModuleCallback(
break :blk 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);
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(
@@ -539,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.?,

View File

@@ -382,8 +382,7 @@ pub fn runMicrotasks(self: *Env) void {
}
}
pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null;
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
@@ -398,13 +397,17 @@ 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 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 {
@@ -492,20 +495,25 @@ pub fn terminate(self: *const Env) void {
}
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

@@ -78,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

@@ -1206,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();
}

View File

@@ -68,6 +68,8 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// 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);
@@ -86,14 +88,19 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
.rc = 1,
.arena = arena,
.key = owned_key,
.globals = .empty,
.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();
@@ -196,42 +203,44 @@ pub fn createFinalizerCallback(
return fc;
}
pub fn transferTo(self: *Origin, dest: *Origin) !void {
const arena = dest.arena;
pub fn takeover(self: *Origin, original: *Origin) !void {
const arena = self.arena;
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
for (self.globals.items) |obj| {
dest.globals.appendAssumeCapacity(obj);
try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
for (original.globals.items) |obj| {
self.globals.appendAssumeCapacity(obj);
}
self.globals.clearRetainingCapacity();
original.globals.clearRetainingCapacity();
{
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
var it = self.temps.iterator();
try self.temps.ensureUnusedCapacity(arena, original.temps.count());
var it = original.temps.iterator();
while (it.next()) |kv| {
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.temps.clearRetainingCapacity();
original.temps.clearRetainingCapacity();
}
{
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
var it = self.finalizer_callbacks.iterator();
try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
var it = original.finalizer_callbacks.iterator();
while (it.next()) |kv| {
kv.value_ptr.*.origin = dest;
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
kv.value_ptr.*.origin = self;
try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.finalizer_callbacks.clearRetainingCapacity();
original.finalizer_callbacks.clearRetainingCapacity();
}
{
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
var it = self.identity_map.iterator();
try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
var it = original.identity_map.iterator();
while (it.next()) |kv| {
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
self.identity_map.clearRetainingCapacity();
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.

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, .{});

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

@@ -725,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"),
@@ -848,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"),

View File

@@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool {
return false;
}
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;
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);
}
},
else => {},
}
}
fn renderChildren(self: *Context, parent: *Node) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
try self.render(child);
}
}
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 self.writer.writeAll("- ");
}
self.state.last_char_was_newline = false;
},
.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 state = State{};
try render(node, &state, writer, page);
if (!state.last_char_was_newline) {
var ctx: Context = .{
.state = .{},
.writer = writer,
.page = page,
};
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n');
}
}
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().str();
if (state.pre_node) |pre| {
if (node.parentNode() == pre and node.nextSibling() == null) {
text = std.mem.trimRight(u8, text, " \t\r\n");
}
}
try renderText(text, state, writer);
}
},
else => {},
}
}
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();
if (!isVisibleElement(el)) return;
// --- Opening Tag Logic ---
// Ensure block elements start on a new line (double newline for paragraphs etc)
if (tag.isBlock() and !state.in_table) {
try ensureNewline(state, writer);
if (shouldAddSpacing(tag)) {
try writer.writeByte('\n');
}
} 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(" ");
if (state.list_depth > 0 and state.list_stack[state.list_depth - 1].type == .ordered) {
const current_list = &state.list_stack[state.list_depth - 1];
try writer.print("{d}. ", .{current_list.index});
current_list.index += 1;
} 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.pre_node = el.asNode();
state.last_char_was_newline = true;
},
.code => {
if (state.pre_node == null) {
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;
},
.br => {
if (state.in_table) {
try writer.writeByte(' ');
} else {
try writer.writeByte('\n');
state.last_char_was_newline = true;
}
return;
},
.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| {
const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
try writer.writeAll(absolute_src);
}
try writer.writeAll(")");
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(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
if (has_block) {
try renderChildren(el.asNode(), state, writer, page);
if (href) |h| {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeAll("([](");
try writer.writeAll(h);
try writer.writeAll("))\n");
state.last_char_was_newline = true;
}
return;
}
if (isStandaloneAnchor(el)) {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeAll(")\n");
state.last_char_was_newline = true;
return;
}
try writer.writeByte('[');
if (has_content) {
try renderChildren(el.asNode(), state, writer, page);
} else {
try writer.writeAll(label orelse "");
}
try writer.writeAll("](");
if (href) |h| {
try writer.writeAll(h);
}
try writer.writeByte(')');
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 writer.writeAll(if (checked) "[x] " else "[ ] ");
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.pre_node = null;
state.last_char_was_newline = true;
},
.code => {
if (state.pre_node == null) {
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('|');
for (0..state.table_col_count) |_| {
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 (tag.isBlock() and !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.pre_node) |_| {
try writer.writeAll(text);
state.last_char_was_newline = text[text.len - 1] == '\n';
return;
}
// Check for pure whitespace
if (isAllWhitespace(text)) {
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 or (!state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
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 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);
},
else => try writer.writeByte(c),
}
}
}
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();

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

@@ -34,11 +34,10 @@
</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>

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

@@ -11,9 +11,9 @@
}
{
// Empty XML is a parse error (no root element)
const parser = new DOMParser();
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
let d = parser.parseFromString('', 'text/xml');
testing.expectEqual('<parsererror>error</parsererror>', new XMLSerializer().serializeToString(d));
}
}
</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

@@ -12,7 +12,7 @@
testing.expectEqual('', $('#a0').href);
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href);
testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);

View File

@@ -32,7 +32,7 @@
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
testing.expectEqual(testing.ORIGIN + '/hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)
@@ -343,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

@@ -37,7 +37,7 @@
testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png';
testing.expectEqual(testing.ORIGIN + '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';

View File

@@ -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;

View File

@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000';
testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href);
testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin);
@@ -84,3 +84,24 @@
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

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

@@ -12,11 +12,10 @@
const p1 = $('#p1');
testing.expectEqual(null, p1.querySelector('#p1'));
testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector(''));
testing.expectError("SyntaxError", () => p1.querySelector(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => p1.querySelector(''));
testing.expectEqual($('#c2'), p1.querySelector('#c2'));

View File

@@ -24,11 +24,10 @@
<script id=errors>
{
const root = $('#root');
testing.expectError("SyntaxError: Syntax Error", () => root.querySelectorAll(''));
testing.expectError("SyntaxError", () => root.querySelectorAll(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => root.querySelectorAll(''));
}
</script>

View File

@@ -43,8 +43,8 @@
const container = $('#container');
// Empty selectors
testing.expectError("SyntaxError: Syntax Error", () => container.querySelector(''));
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
testing.expectError("SyntaxError", () => container.querySelector(''));
testing.expectError("SyntaxError", () => document.querySelectorAll(''));
}
</script>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=onerrorFiveArguments>
let called = false;
let argCount = 0;
window.onerror = function() {
called = true;
argCount = arguments.length;
return true; // suppress default
};
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual(true, called);
testing.expectEqual(5, argCount);
window.onerror = null;
</script>
<script id=onerrorCalledBeforeEventListener>
let callOrder = [];
window.onerror = function() { callOrder.push('onerror'); return true; };
window.addEventListener('error', function() { callOrder.push('listener'); });
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual('onerror', callOrder[0]);
testing.expectEqual('listener', callOrder[1]);
window.onerror = null;
</script>
<script id=onerrorReturnTrueSuppresses>
let listenerCalled = false;
window.onerror = function() { return true; };
window.addEventListener('error', function(e) {
// listener still fires even when onerror returns true
listenerCalled = true;
});
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual(true, listenerCalled);
window.onerror = null;
</script>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe id="receiver"></iframe>
<script id="messages">
{
let reply = null;
window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data;
});
const iframe = $('#receiver');
iframe.src = 'support/message_receiver.html';
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage('ping', '*');
});
testing.eventually(() => {
testing.expectEqual('pong', reply.data);
testing.expectEqual(testing.ORIGIN, reply.origin);
});
}
</script>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<script>
window.addEventListener('message', (e) => {
console.warn('Frame Message', e.data);
if (e.data === 'ping') {
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
}
});
</script>

View File

@@ -2,37 +2,17 @@
<script src="testing.js"></script>
<script id=history>
testing.expectEqual('auto', history.scrollRestoration);
history.scrollRestoration = 'manual';
testing.expectEqual('manual', history.scrollRestoration);
history.scrollRestoration = 'auto';
testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/browser/tests/history_after_nav.skip.html');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json');
history.replaceState({ "new": "field", testComplete: true }, null);
let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
let popstateEventFired = false;
let popstateEventState = null;
window.addEventListener('popstate', (event) => {
popstateEventFired = true;
popstateEventState = event.state;
});
// This test is a bit wonky. But it's trying to test navigation, which is
// something we can't do in the main page (we can't navigate away from this
// page and still assertOk in the test runner).
// If support/history.html has a failed assertion, it'll log the error and
// stop the script. If it succeeds, it'll set support_history_completed
// which we can use here to assume everything passed.
testing.eventually(() => {
testing.expectEqual(true, popstateEventFired);
testing.expectEqual({testInProgress: true }, popstateEventState);
})
history.back();
testing.expectEqual(true, window.support_history_completed);
testing.expectEqual(true, window.support_history_popstateEventFired);
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);
});
</script>
<iframe id=frame src="support/history.html"></iframe>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body>
<button id="btn" onclick="window.clicked = true;">Click Me</button>
<input id="inp" oninput="window.inputVal = this.value" onchange="window.changed = true;">
<select id="sel" onchange="window.selChanged = this.value">
<option value="opt1">Option 1</option>
<option value="opt2">Option 2</option>
</select>
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div>
</div>
</body>
</html>

View File

@@ -27,3 +27,44 @@
testing.expectEqual(false, navigator.javaEnabled());
testing.expectEqual(false, navigator.webdriver);
</script>
<script id=permission_query>
testing.async(async (restore) => {
const p = navigator.permissions.query({ name: 'notifications' });
testing.expectTrue(p instanceof Promise);
const status = await p;
restore();
testing.expectEqual('prompt', status.state);
testing.expectEqual('notifications', status.name);
});
</script>
<script id=storage_estimate>
testing.async(async (restore) => {
const p = navigator.storage.estimate();
testing.expectTrue(p instanceof Promise);
const estimate = await p;
restore();
testing.expectEqual(0, estimate.usage);
testing.expectEqual(1024 * 1024 * 1024, estimate.quota);
});
</script>
<script id=deviceMemory>
testing.expectEqual(8, navigator.deviceMemory);
</script>
<script id=getBattery>
testing.async(async (restore) => {
const p = navigator.getBattery();
try {
await p;
testing.fail('getBattery should reject');
} catch (err) {
restore();
testing.expectEqual('NotSupportedError', err.name);
}
});
</script>

View File

@@ -203,3 +203,39 @@
testing.expectEqual(true, response.body !== null);
});
</script>
<script id=fetch_blob_url>
testing.async(async (restore) => {
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const response = await fetch(blobUrl);
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(blobUrl, response.url);
testing.expectEqual('text/plain', response.headers.get('Content-Type'));
const text = await response.text();
testing.expectEqual('Hello from blob!', text);
// Clean up
URL.revokeObjectURL(blobUrl);
});
</script>
<script id=abort>
testing.async(async (restore) => {
const controller = new AbortController();
controller.abort();
try {
await fetch('http://127.0.0.1:9582/xhr', { signal: controller.signal });
testain.fail('fetch should have been aborted');
} catch (e) {
restore();
testing.expectEqual("AbortError", e.name);
}
});
</script>

View File

@@ -283,3 +283,26 @@
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
</script>
<script id=xhr_blob_url>
testing.async(async (restore) => {
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', blobUrl);
req.send();
});
restore();
testing.expectEqual(200, req.status);
testing.expectEqual('Hello from blob!', req.responseText);
testing.expectEqual(blobUrl, req.responseURL);
// Clean up
URL.revokeObjectURL(blobUrl);
});
</script>

View File

@@ -19,7 +19,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => d1.insertBefore(document.createElement('div'), d2));
let c1 = document.createElement('div');

View File

@@ -7,7 +7,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => $('#d1').removeChild($('#p1')));
const p1 = $('#p1');

View File

@@ -25,7 +25,6 @@
testing.withError((err) => {
testing.expectEqual(3, err.code);
testing.expectEqual("HierarchyRequestError", err.name);
testing.expectEqual("Hierarchy Error", err.message);
}, () => d1.replaceChild(c4, c3));
testing.expectEqual(c2, d1.replaceChild(c4, c2));

View File

@@ -451,12 +451,12 @@
const p1 = $('#p1');
// Test setStart with offset beyond node length
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.setStart(p1, 999);
});
// Test with negative offset (wraps to large u32)
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.setStart(p1.firstChild, -1);
});
}
@@ -468,12 +468,12 @@
const p1 = $('#p1');
// Test setEnd with offset beyond node length
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.setEnd(p1, 999);
});
// Test with text node
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.setEnd(p1.firstChild, 9999);
});
}
@@ -525,11 +525,11 @@
range.setEnd(p1, 1);
// Test comparePoint with invalid offset
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.comparePoint(p1, 20);
});
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.comparePoint(p1.firstChild, -1);
});
}
@@ -650,11 +650,11 @@
range.setEnd(p1, 1);
// Invalid offset should throw IndexSizeError
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.isPointInRange(p1, 999);
});
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
testing.expectError('IndexSizeError:', () => {
range.isPointInRange(p1.firstChild, 9999);
});
}
@@ -854,11 +854,11 @@
range2.setStart(p, 0);
// Invalid how parameter should throw NotSupportedError
testing.expectError('NotSupportedError: Not Supported', () => {
testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(4, range2);
});
testing.expectError('NotSupportedError: Not Supported', () => {
testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(99, range2);
});
}
@@ -883,7 +883,7 @@
range2.setEnd(foreignP, 1);
// Comparing ranges in different documents should throw WrongDocumentError
testing.expectError('WrongDocumentError: wrong_document_error', () => {
testing.expectError('WrongDocumentError:', () => {
range1.compareBoundaryPoints(Range.START_TO_START, range2);
});
}

View File

@@ -5,7 +5,7 @@
<div id="host2"></div>
<div id="host3"></div>
<!-- <script id="attachShadow_open">
<script id="attachShadow_open">
{
const host = $('#host1');
const shadow = host.attachShadow({ mode: 'open' });
@@ -140,7 +140,7 @@
shadow.replaceChildren('New content');
testing.expectEqual('New content', shadow.innerHTML);
}
</script> -->
</script>
<script id="getElementById">
{
@@ -154,3 +154,16 @@
testing.expectEqual(null, shadow.getElementById('nonexistent'));
}
</script>
<script id=adoptedStyleSheets>
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const acss = shadow.adoptedStyleSheets;
testing.expectEqual(0, acss.length);
acss.push(new CSSStyleSheet());
testing.expectEqual(1, acss.length);
}
</script>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=history>
testing.expectEqual('auto', history.scrollRestoration);
history.scrollRestoration = 'manual';
testing.expectEqual('manual', history.scrollRestoration);
history.scrollRestoration = 'auto';
testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
history.pushState({ testInProgress: true }, null, testing.BASE_URL + 'history_after_nav.skip.html');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({ testInProgress: false }, null, testing.ORIGIN + '/xhr/json');
history.replaceState({ "new": "field", testComplete: true }, null);
let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
let popstateEventFired = false;
let popstateEventState = null;
window.top.support_history_completed = true;
window.addEventListener('popstate', (event) => {
window.top.window.support_history_popstateEventFired = true;
window.top.window.support_history_popstateEventState = event.state;
});
history.back();
</script>

View File

@@ -99,8 +99,7 @@
}
}
// our test runner sets this to true
const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true;
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
window.testing = {
fail: fail,
@@ -114,17 +113,17 @@
eventually: eventually,
IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1',
ORIGIN: 'http://127.0.0.1:9582/',
ORIGIN: 'http://127.0.0.1:9582',
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
};
if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
if (IS_TEST_RUNNER === false) {
// The page is running in a different browser. Probably a developer making sure
// a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls.
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
window.testing.HOST = location.hostname;
window.testing.ORIGIN = location.origin + '/';
window.testing.ORIGIN = location.origin;
window.testing.BASE_URL = location.origin + '/src/browser/tests/';
window.addEventListener('load', testing.assertOk);
}

View File

@@ -1,15 +1,15 @@
<!DOCTYPE html>
<body onload="loaded()"></body>
<body onload="loadEvent = event"></body>
<script src="../testing.js"></script>
<script id=bodyOnLoad2>
let called = 0;
function loaded(e) {
called += 1;
}
// Per spec, the handler is compiled as: function(event) { loadEvent = event }
// Verify: handler fires, "event" parameter is a proper Event, and handler is a function.
let loadEvent = null;
testing.eventually(() => {
testing.expectEqual(1, called);
testing.expectEqual("function", typeof document.body.onload);
testing.expectTrue(loadEvent instanceof Event);
testing.expectEqual("load", loadEvent.type);
});
</script>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<body onload="called++"></body>
<script src="../testing.js"></script>
<script id=bodyOnLoad3>
// Per spec, the handler is compiled as: function(event) { called++ }
// Verify: handler fires exactly once, and body.onload reflects to window.onload.
let called = 0;
testing.eventually(() => {
// The attribute handler should have fired exactly once.
testing.expectEqual(1, called);
// body.onload is a Window-reflecting handler per spec.
testing.expectEqual("function", typeof document.body.onload);
testing.expectEqual(document.body.onload, window.onload);
// Setting body.onload via property replaces the attribute handler.
let propertyCalled = false;
document.body.onload = function() { propertyCalled = true; };
testing.expectEqual(document.body.onload, window.onload);
// Setting onload to null removes the handler.
document.body.onload = null;
testing.expectEqual(null, document.body.onload);
testing.expectEqual(null, window.onload);
});
</script>

View File

@@ -82,7 +82,7 @@
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw
testing.expectError('InvalidCharacterError: Invalid Character', () => {
testing.expectError('InvalidCharacterError', () => {
atob('Y');
});
</script>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=windowEventUndefinedOutsideHandler>
testing.expectEqual(undefined, window.event);
</script>
<script id=windowEventSetDuringWindowHandler>
var capturedEvent = null;
window.addEventListener('test-event', function(e) {
capturedEvent = window.event;
});
var ev = new Event('test-event');
window.dispatchEvent(ev);
testing.expectEqual(ev, capturedEvent);
testing.expectEqual(undefined, window.event);
</script>
<script id=windowEventRestoredAfterHandler>
var captured2 = null;
window.addEventListener('test-event-2', function(e) {
captured2 = window.event;
});
var ev2 = new Event('test-event-2');
window.dispatchEvent(ev2);
testing.expectEqual(ev2, captured2);
testing.expectEqual(undefined, window.event);
</script>

View File

@@ -125,8 +125,8 @@ pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page)
return local.resolvePromise(definition.constructor);
}
validateName(name) catch |err| {
return local.rejectPromise(DOMException.fromError(err) orelse unreachable);
validateName(name) catch |err| switch (err) {
error.SyntaxError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.SyntaxError } }),
};
const gop = try self._when_defined.getOrPut(page.arena, name);

View File

@@ -104,13 +104,27 @@ pub fn getMessage(self: *const DOMException) []const u8 {
}
return switch (self._code) {
.none => "",
.invalid_character_error => "Invalid Character",
.index_size_error => "Index or size is negative or greater than the allowed amount",
.syntax_error => "Syntax Error",
.not_supported => "Not Supported",
.not_found => "Not Found",
.hierarchy_error => "Hierarchy Error",
else => @tagName(self._code),
.hierarchy_error => "The operation would yield an incorrect node tree",
.wrong_document_error => "The object is in the wrong document",
.invalid_character_error => "The string contains invalid characters",
.no_modification_allowed_error => "The object can not be modified",
.not_found => "The object can not be found here",
.not_supported => "The operation is not supported",
.inuse_attribute_error => "The attribute already in use",
.invalid_state_error => "The object is in an invalid state",
.syntax_error => "The string did not match the expected pattern",
.invalid_modification_error => "The object can not be modified in this way",
.namespace_error => "The operation is not allowed by Namespaces in XML",
.invalid_access_error => "The object does not support the operation or argument",
.security_error => "The operation is insecure",
.network_error => "A network error occurred",
.abort_error => "The operation was aborted",
.url_mismatch_error => "The given URL does not match another URL",
.quota_exceeded_error => "The quota has been exceeded",
.timeout_error => "The operation timed out",
.invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation",
.data_clone_error => "The object can not be cloned",
};
}

View File

@@ -86,15 +86,15 @@ pub fn parseFromString(
var parser = Parser.init(arena, doc_node, page);
parser.parseXML(html);
if (parser.err) |pe| {
return pe.err;
if (parser.err != null or doc_node.firstChild() == null) {
// Return a document with a <parsererror> element per spec.
const err_doc = try page._factory.document(XMLDocument{ ._proto = undefined });
var err_parser = Parser.init(arena, err_doc.asNode(), page);
err_parser.parseXML("<parsererror xmlns=\"http://www.mozilla.org/newlayout/xml/parsererror.xml\">error</parsererror>");
return err_doc.asDocument();
}
const first_child = doc_node.firstChild() orelse {
// Empty XML or no root element - this is a parse error.
// TODO: Return a document with a <parsererror> element per spec.
return error.JsException;
};
const first_child = doc_node.firstChild().?;
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {

View File

@@ -365,6 +365,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
return (try KeyboardEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "inputevent")) {
const InputEvent = @import("event/InputEvent.zig");
return (try InputEvent.init("", null, page)).asEvent();
}
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig");
return (try MouseEvent.init("", null, page)).asEvent();

View File

@@ -93,12 +93,12 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
}
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
self._callback.release();
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
if (shutdown) {
self._callback.release();
session.releaseArena(self._arena);
} else if (comptime IS_DEBUG) {
std.debug.assert(false);
}
session.releaseArena(self._arena);
}
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
@@ -111,7 +111,6 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerIntersectionObserver(self);
}
@@ -146,22 +145,18 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
break;
}
}
if (self._observing.items.len == 0) {
page.js.safeWeakRef(self);
}
}
pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
page.unregisterIntersectionObserver(self);
self._observing.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| {
entry.deinit(false, page._session);
}
self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self);
self._observing.clearRetainingCapacity();
page.unregisterIntersectionObserver(self);
}
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -363,7 +358,6 @@ pub const JsApi = struct {
pub const name = "IntersectionObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(IntersectionObserver.deinit);
};

View File

@@ -86,12 +86,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
}
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
self._callback.release();
if ((comptime IS_DEBUG) and !shutdown) {
std.debug.assert(self._observing.items.len == 0);
if (shutdown) {
self._callback.release();
session.releaseArena(self._arena);
} else if (comptime IS_DEBUG) {
std.debug.assert(false);
}
session.releaseArena(self._arena);
}
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
@@ -158,7 +158,6 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerMutationObserver(self);
}
@@ -169,13 +168,13 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
}
pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| {
record.deinit(false, page._session);
}
self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self);
self._observing.clearRetainingCapacity();
page.unregisterMutationObserver(self);
}
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
@@ -441,7 +440,6 @@ pub const JsApi = struct {
pub const name = "MutationObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(MutationObserver.deinit);
};

View File

@@ -18,13 +18,21 @@
const std = @import("std");
const builtin = @import("builtin");
const log = @import("../../log.zig");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const StorageManager = @import("StorageManager.zig");
const Navigator = @This();
_pad: bool = false,
_plugins: PluginArray = .{},
_permissions: Permissions = .{},
_storage: StorageManager = .{},
pub const init: Navigator = .{};
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
return &self._plugins;
}
pub fn getPermissions(self: *Navigator) *Permissions {
return &self._permissions;
}
pub fn getStorage(self: *Navigator) *StorageManager {
return &self._storage;
}
pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise {
log.info(.not_implemented, "navigator.getBattery", .{});
return page.js.local.?.rejectErrorPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
try validateProtocolHandlerScheme(scheme);
try validateProtocolHandlerURL(url, page);
@@ -144,6 +165,7 @@ pub const JsApi = struct {
pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .template = false });
pub const hardwareConcurrency = bridge.property(4, .{ .template = false });
pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false });
pub const maxTouchPoints = bridge.property(0, .{ .template = false });
pub const vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false });
@@ -156,4 +178,12 @@ pub const JsApi = struct {
// Methods
pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{});
pub const getBattery = bridge.function(Navigator.getBattery, .{});
pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: Navigator" {
try testing.htmlRunner("navigator", .{});
}

View File

@@ -0,0 +1,94 @@
// 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/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{ Permissions, PermissionStatus };
}
const Permissions = @This();
// Padding to avoid zero-size struct pointer collisions
_pad: bool = false,
const QueryDescriptor = struct {
name: []const u8,
};
// We always report 'prompt' (the default safe value — neither granted nor denied).
pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise {
const arena = try page.getArena(.{ .debug = "PermissionStatus" });
errdefer page.releaseArena(arena);
const status = try arena.create(PermissionStatus);
status.* = .{
._arena = arena,
._state = "prompt",
._name = try arena.dupe(u8, qd.name),
};
return page.js.local.?.resolvePromise(status);
}
const PermissionStatus = struct {
_arena: Allocator,
_name: []const u8,
_state: []const u8,
pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void {
session.releaseArena(self._arena);
}
fn getName(self: *const PermissionStatus) []const u8 {
return self._name;
}
fn getState(self: *const PermissionStatus) []const u8 {
return self._state;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(PermissionStatus);
pub const Meta = struct {
pub const name = "PermissionStatus";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(PermissionStatus.deinit);
};
pub const name = bridge.accessor(getName, null, .{});
pub const state = bridge.accessor(getState, null, .{});
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(Permissions);
pub const Meta = struct {
pub const name = "Permissions";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const query = bridge.function(Permissions.query, .{ .dom_exception = true });
};

View File

@@ -40,6 +40,7 @@ _mode: Mode,
_host: *Element,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
_adopted_style_sheets: ?js.Object.Global = null,
pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
return page._factory.documentFragment(ShadowRoot{
@@ -99,6 +100,20 @@ pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element
return null;
}
pub fn getAdoptedStyleSheets(self: *ShadowRoot, page: *Page) !js.Object.Global {
if (self._adopted_style_sheets) |ass| {
return ass;
}
const js_arr = page.js.local.?.newArray(0);
const js_obj = js_arr.toObject();
self._adopted_style_sheets = try js_obj.persist();
return self._adopted_style_sheets.?;
}
pub fn setAdoptedStyleSheets(self: *ShadowRoot, sheets: js.Object) !void {
self._adopted_style_sheets = try sheets.persist();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(ShadowRoot);
@@ -121,6 +136,7 @@ pub const JsApi = struct {
}
return self.getElementById(try value.toZig([]const u8), page);
}
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -0,0 +1,71 @@
// 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 js = @import("../js/js.zig");
const Page = @import("../Page.zig");
pub fn registerTypes() []const type {
return &.{ StorageManager, StorageEstimate };
}
const StorageManager = @This();
_pad: bool = false,
pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise {
const est = try page._factory.create(StorageEstimate{
._usage = 0,
._quota = 1024 * 1024 * 1024, // 1 GiB
});
return page.js.local.?.resolvePromise(est);
}
const StorageEstimate = struct {
_quota: u64,
_usage: u64,
fn getUsage(self: *const StorageEstimate) u64 {
return self._usage;
}
fn getQuota(self: *const StorageEstimate) u64 {
return self._quota;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(StorageEstimate);
pub const Meta = struct {
pub const name = "StorageEstimate";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const quota = bridge.accessor(getQuota, null, .{});
pub const usage = bridge.accessor(getUsage, null, .{});
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(StorageManager);
pub const Meta = struct {
pub const name = "StorageManager";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const estimate = bridge.function(StorageManager.estimate, .{});
};

View File

@@ -96,8 +96,8 @@ pub fn generateKey(
key_usages: []const []const u8,
page: *Page,
) !js.Promise {
const key_or_pair = CryptoKey.init(algorithm, extractable, key_usages, page) catch |err| {
return page.js.local.?.rejectPromise(@errorName(err));
const key_or_pair = CryptoKey.init(algorithm, extractable, key_usages, page) catch {
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.SyntaxError } });
};
return page.js.local.?.resolvePromise(key_or_pair);
@@ -112,7 +112,7 @@ pub fn exportKey(
page: *Page,
) !js.Promise {
if (!key.canExportKey()) {
return error.InvalidAccessError;
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
}
if (std.mem.eql(u8, format, "raw")) {
@@ -124,9 +124,10 @@ pub fn exportKey(
if (is_unsupported) {
log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = format });
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
return page.js.local.?.rejectPromise(@errorName(error.NotSupported));
return page.js.local.?.rejectPromise(.{ .type_error = "invalid format" });
}
/// Derive a secret key from a master key.
@@ -148,7 +149,7 @@ pub fn deriveBits(
log.warn(.not_implemented, "SubtleCrypto.deriveBits", .{ .name = name });
}
return page.js.local.?.rejectPromise(@errorName(error.NotSupported));
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
},
};
}
@@ -185,19 +186,19 @@ pub fn sign(
.hmac => {
// Verify algorithm.
if (!algorithm.isHMAC()) {
return page.js.local.?.rejectPromise(@errorName(error.InvalidAccessError));
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
}
// Call sign for HMAC.
const result = key.signHMAC(data, page) catch |err| {
return page.js.local.?.rejectPromise(@errorName(err));
const result = key.signHMAC(data, page) catch {
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
};
return page.js.local.?.resolvePromise(result);
},
else => {
log.warn(.not_implemented, "SubtleCrypto.sign", .{ .key_type = key._type });
return page.js.local.?.rejectPromise(@errorName(error.InvalidAccessError));
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
},
};
}
@@ -211,18 +212,20 @@ pub fn verify(
data: []const u8, // ArrayBuffer.
page: *Page,
) !js.Promise {
if (!algorithm.isHMAC()) return error.InvalidAccessError;
if (!algorithm.isHMAC()) {
return page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
}
return switch (key._type) {
.hmac => key.verifyHMAC(signature, data, page),
else => return error.InvalidAccessError,
else => page.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }),
};
}
pub fn digest(_: *const SubtleCrypto, algorithm: []const u8, data: js.TypedArray(u8), page: *Page) !js.Promise {
const local = page.js.local.?;
if (algorithm.len > 10) {
return local.rejectPromise(DOMException.fromError(error.NotSupported));
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
const normalized = std.ascii.lowerString(&page.buf, algorithm);
if (std.mem.eql(u8, normalized, "sha-1")) {
@@ -245,7 +248,7 @@ pub fn digest(_: *const SubtleCrypto, algorithm: []const u8, data: js.TypedArray
Sha512.hash(data.values, page.buf[0..Sha512.digest_length], .{});
return local.resolvePromise(js.ArrayBuffer{ .values = page.buf[0..Sha512.digest_length] });
}
return local.rejectPromise(DOMException.fromError(error.NotSupported));
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
/// Returns the desired digest by its name.

View File

@@ -66,7 +66,10 @@ _on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null,
_on_error: ?js.Function.Global = null,
_on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error
_on_message: ?js.Function.Global = null,
_on_rejection_handled: ?js.Function.Global = null,
_on_unhandled_rejection: ?js.Function.Global = null,
_current_event: ?*Event = null,
_location: *Location,
_timer_id: u30 = 0,
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
@@ -89,6 +92,10 @@ pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto;
}
pub fn getEvent(self: *const Window) ?*Event {
return self._current_event;
}
pub fn getSelf(self: *Window) *Window {
return self;
}
@@ -208,6 +215,22 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter);
}
pub fn getOnMessage(self: *const Window) ?js.Function.Global {
return self._on_message;
}
pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void {
self._on_message = getFunctionFromSetter(setter);
}
pub fn getOnRejectionHandled(self: *const Window) ?js.Function.Global {
return self._on_rejection_handled;
}
pub fn setOnRejectionHandled(self: *Window, setter: ?FunctionSetter) void {
self._on_rejection_handled = getFunctionFromSetter(setter);
}
pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global {
return self._on_unhandled_rejection;
}
@@ -334,7 +357,11 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
const event = error_event.asEvent();
event._prevent_default = prevent_default;
try page._event_manager.dispatch(self.asEventTarget(), event);
// Pass null as handler: onerror was already called above with 5 args.
// We still dispatch so that addEventListener('error', ...) listeners fire.
try page._event_manager.dispatchDirect(self.asEventTarget(), event, null, .{
.context = "window.reportError",
});
if (comptime builtin.is_test == false) {
if (!event._prevent_default) {
@@ -369,19 +396,26 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
// In a full implementation, we would validate the origin
_ = target_origin;
// postMessage queues a task (not a microtask), so use the scheduler
const arena = try page.getArena(.{ .debug = "Window.schedule" });
errdefer page.releaseArena(arena);
// self = the window that will get the message
// page = the context calling postMessage
const target_page = self._page;
const source_window = target_page.js.getIncumbent().window;
const origin = try self._location.getOrigin(page);
const arena = try target_page.getArena(.{ .debug = "Window.postMessage" });
errdefer target_page.releaseArena(arena);
// Origin should be the source window's origin (where the message came from)
const origin = try source_window._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback);
callback.* = .{
.page = page,
.arena = arena,
.message = message,
.page = target_page,
.source = source_window,
.origin = try arena.dupe(u8, origin),
};
try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
@@ -547,7 +581,7 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
return self.scrollTo(.{ .x = absx }, absy, page);
}
pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void {
pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void {
if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{
.value = rejection.reason(),
@@ -555,13 +589,20 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
});
}
const event_name, const attribute_callback = blk: {
if (no_handler) {
break :blk .{ "unhandledrejection", self._on_unhandled_rejection };
}
break :blk .{ "rejectionhandled", self._on_rejection_handled };
};
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
if (page._event_manager.hasDirectListeners(target, event_name, attribute_callback)) {
const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" });
}
}
@@ -702,6 +743,7 @@ const ScheduleCallback = struct {
const PostMessageCallback = struct {
page: *Page,
source: *Window,
arena: Allocator,
origin: []const u8,
message: js.Value.Temp,
@@ -712,7 +754,7 @@ const PostMessageCallback = struct {
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
self.page.releaseArena(self.arena);
self.deinit();
}
fn run(ctx: *anyopaque) !?u32 {
@@ -722,14 +764,17 @@ const PostMessageCallback = struct {
const page = self.page;
const window = page.window;
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = self.origin,
.source = window,
.bubbles = false,
.cancelable = false,
}, page)).asEvent();
try page._event_manager.dispatch(window.asEventTarget(), event);
const event_target = window.asEventTarget();
if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = self.message,
.origin = self.origin,
.source = self.source,
.bubbles = false,
.cancelable = false,
}, page)).asEvent();
try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
}
return null;
}
@@ -783,7 +828,10 @@ pub const JsApi = struct {
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{});
pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{});
pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{});
pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});
pub const event = bridge.accessor(Window.getEvent, null, .{ .null_as_undefined = true });
pub const fetch = bridge.function(Window.fetch, .{});
pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});
pub const setTimeout = bridge.function(Window.setTimeout, .{});
@@ -853,3 +901,7 @@ test "WebApi: Window" {
test "WebApi: Window scroll" {
try testing.htmlRunner("window_scroll.html", .{});
}
test "WebApi: Window.onerror" {
try testing.htmlRunner("event/report_error.html", .{});
}

View File

@@ -117,6 +117,47 @@ pub fn submit(self: *Form, page: *Page) !void {
return page.submitForm(null, self, .{ .fire_event = false });
}
/// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit
/// Like submit(), but fires the submit event and validates the form.
pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void {
const submitter_element = if (submitter) |s| blk: {
// The submitter must be a submit button.
if (!isSubmitButton(s)) return error.TypeError;
// The submitter's form owner must be this form element.
const submitter_form = getFormOwner(s, page);
if (submitter_form == null or submitter_form.? != self) return error.NotFound;
break :blk s;
} else self.asElement();
return page.submitForm(submitter_element, self, .{});
}
/// Returns true if the element is a submit button per the HTML spec:
/// - <input type="submit"> or <input type="image">
/// - <button type="submit"> (including default, since button's default type is "submit")
fn isSubmitButton(element: *Element) bool {
if (element.is(Input)) |input| {
return input._input_type == .submit or input._input_type == .image;
}
if (element.is(Button)) |button| {
return std.mem.eql(u8, button.getType(), "submit");
}
return false;
}
/// Returns the form owner of a submittable element (Input or Button).
fn getFormOwner(element: *Element, page: *Page) ?*Form {
if (element.is(Input)) |input| {
return input.getForm(page);
}
if (element.is(Button)) |button| {
return button.getForm(page);
}
return null;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Form);
pub const Meta = struct {
@@ -132,6 +173,7 @@ pub const JsApi = struct {
pub const elements = bridge.accessor(Form.getElements, null, .{});
pub const length = bridge.accessor(Form.getLength, null, .{});
pub const submit = bridge.function(Form.submit, .{});
pub const requestSubmit = bridge.function(Form.requestSubmit, .{ .dom_exception = true });
};
const testing = @import("../../../../testing.zig");

View File

@@ -28,6 +28,7 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const Input = @This();
@@ -103,6 +104,11 @@ fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void {
try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *Input) *Element {
return self._proto._proto;
}
@@ -425,6 +431,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void {
try self.setValue(new_value, page);
},
}
try self.dispatchInputEvent(str, "insertText", page);
}
pub fn getSelectionDirection(self: *const Input) []const u8 {

View File

@@ -93,13 +93,21 @@ pub fn linkAddedCallback(self: *Link, page: *Page) !void {
}
const element = self.asElement();
// Exit if rel not set.
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
// Exit if rel is not stylesheet.
if (!std.mem.eql(u8, rel, "stylesheet")) return;
// Exit if href not set.
const loadable_rels = std.StaticStringMap(void).initComptime(.{
.{ "stylesheet", {} },
.{ "preload", {} },
.{ "modulepreload", {} },
});
if (loadable_rels.has(rel) == false) {
return;
}
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) return;
if (href.len == 0) {
return;
}
try page._to_load.append(page.arena, self._proto);
}

View File

@@ -26,6 +26,7 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const TextArea = @This();
@@ -55,6 +56,11 @@ fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void {
try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *TextArea) *Element {
return self._proto._proto;
}
@@ -189,6 +195,7 @@ pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void {
try self.setValue(new_value, page);
},
}
try self.dispatchInputEvent(str, "insertText", page);
}
pub fn getSelectionDirection(self: *const TextArea) []const u8 {

View File

@@ -0,0 +1,121 @@
// 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 String = @import("../../../string.zig").String;
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const js = @import("../../js/js.zig");
const Event = @import("../Event.zig");
const UIEvent = @import("UIEvent.zig");
const Allocator = std.mem.Allocator;
const InputEvent = @This();
_proto: *UIEvent,
_data: ?[]const u8,
// TODO: add dataTransfer
_input_type: []const u8,
_is_composing: bool,
pub const InputEventOptions = struct {
data: ?[]const u8 = null,
inputType: ?[]const u8 = null,
isComposing: bool = false,
};
const Options = Event.inheritOptions(
InputEvent,
InputEventOptions,
);
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*InputEvent {
const arena = try page.getArena(.{ .debug = "InputEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*InputEvent {
const arena = try page.getArena(.{ .debug = "InputEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*InputEvent {
const opts = _opts orelse Options{};
const event = try page._factory.uiEvent(
arena,
typ,
InputEvent{
._proto = undefined,
._data = if (opts.data) |d| try arena.dupe(u8, d) else null,
._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "",
._is_composing = opts.isComposing,
},
);
Event.populatePrototypes(event, opts, trusted);
// https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event
const rootevt = event._proto._proto;
rootevt._bubbles = true;
rootevt._cancelable = false;
rootevt._composed = true;
return event;
}
pub fn deinit(self: *InputEvent, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
}
pub fn asEvent(self: *InputEvent) *Event {
return self._proto.asEvent();
}
pub fn getData(self: *const InputEvent) ?[]const u8 {
return self._data;
}
pub fn getInputType(self: *const InputEvent) []const u8 {
return self._input_type;
}
pub fn getIsComposing(self: *const InputEvent) bool {
return self._is_composing;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(InputEvent);
pub const Meta = struct {
pub const name = "InputEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(InputEvent.deinit);
};
pub const constructor = bridge.constructor(InputEvent.init, .{});
pub const data = bridge.accessor(InputEvent.getData, null, .{});
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
};

View File

@@ -219,6 +219,13 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
);
Event.populatePrototypes(event, opts, trusted);
// https://w3c.github.io/uievents/#event-type-keyup
const rootevt = event._proto._proto;
rootevt._bubbles = true;
rootevt._cancelable = true;
rootevt._composed = true;
return event;
}

View File

@@ -28,6 +28,8 @@ const EventTarget = @import("../EventTarget.zig");
const UIEvent = @import("UIEvent.zig");
const PointerEvent = @import("PointerEvent.zig");
const Allocator = std.mem.Allocator;
const MouseEvent = @This();
pub const MouseButton = enum(u8) {
@@ -83,12 +85,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
const arena = try page.getArena(.{ .debug = "MouseEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent {
const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent {
const opts = _opts orelse Options{};
const event = try page._factory.uiEvent(
arena,
type_string,
typ,
MouseEvent{
._type = .generic,
._proto = undefined,
@@ -106,7 +117,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
},
);
Event.populatePrototypes(event, opts, false);
Event.populatePrototypes(event, opts, trusted);
return event;
}

View File

@@ -37,6 +37,7 @@ pub const Type = union(enum) {
keyboard_event: *@import("KeyboardEvent.zig"),
focus_event: *@import("FocusEvent.zig"),
text_event: *@import("TextEvent.zig"),
input_event: *@import("InputEvent.zig"),
};
pub const UIEventOptions = struct {
@@ -88,6 +89,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T {
.keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null,
.focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null,
.text_event => |e| return if (T == @import("TextEvent.zig")) e else null,
.input_event => |e| return if (T == @import("InputEvent.zig")) e else null,
}
return null;
}

View File

@@ -25,8 +25,11 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig");
const Request = @import("Request.zig");
const Response = @import("Response.zig");
const AbortSignal = @import("../AbortSignal.zig");
const DOMException = @import("../DOMException.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -38,17 +41,29 @@ _buf: std.ArrayList(u8),
_response: *Response,
_resolver: js.PromiseResolver.Global,
_owns_response: bool,
_signal: ?*AbortSignal,
pub const Input = Request.Input;
pub const InitOpts = Request.InitOpts;
pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
const request = try Request.init(input, options, page);
const resolver = page.js.local.?.createPromiseResolver();
if (request._signal) |signal| {
if (signal._aborted) {
resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError"));
return resolver.promise();
}
}
if (std.mem.startsWith(u8, request._url, "blob:")) {
return handleBlobUrl(request._url, resolver, page);
}
const response = try Response.init(null, .{ .status = 0 }, page);
errdefer response.deinit(true, page._session);
const resolver = page.js.local.?.createPromiseResolver();
const fetch = try response._arena.create(Fetch);
fetch.* = .{
._page = page,
@@ -57,6 +72,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
._resolver = try resolver.persist(),
._response = response,
._owns_response = true,
._signal = request._signal,
};
const http_client = page._session.browser.http_client;
@@ -90,6 +106,26 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
return resolver.promise();
}
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js.Promise {
const blob: *Blob = page.lookupBlobUrl(url) orelse {
resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" });
return resolver.promise();
};
const response = try Response.init(null, .{ .status = 200 }, page);
response._body = try response._arena.dupe(u8, blob._slice);
response._url = try response._arena.dupeZ(u8, url);
response._type = .basic;
if (blob._mime.len > 0) {
try response._headers.append("Content-Type", blob._mime, page);
}
const js_val = try page.js.local.?.zigValueToJs(response, .{});
resolver.resolve("fetch blob done", js_val);
return resolver.promise();
}
fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
if (comptime IS_DEBUG) {
@@ -101,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
if (self._signal) |signal| {
if (signal._aborted) {
return false;
}
}
const arena = self._response._arena;
if (transfer.getContentLength()) |cl| {
try self._buf.ensureTotalCapacity(arena, cl);
@@ -150,6 +192,14 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
// Check if aborted
if (self._signal) |signal| {
if (signal._aborted) {
return error.Abort;
}
}
try self._buf.appendSlice(self._response._arena, data);
}
@@ -192,7 +242,8 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._page.js.localScope(&ls);
defer ls.deinit();
ls.toLocal(self._resolver).reject("fetch error", @errorName(err));
// fetch() must reject with a TypeError on network errors per spec
ls.toLocal(self._resolver).rejectError("fetch error", .{ .type_error = "fetch error" });
}
fn httpShutdownCallback(ctx: *anyopaque) void {

View File

@@ -25,6 +25,7 @@ const URL = @import("../URL.zig");
const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig");
const AbortSignal = @import("../AbortSignal.zig");
const Allocator = std.mem.Allocator;
const Request = @This();
@@ -36,6 +37,7 @@ _body: ?[]const u8,
_arena: Allocator,
_cache: Cache,
_credentials: Credentials,
_signal: ?*AbortSignal,
pub const Input = union(enum) {
request: *Request,
@@ -48,6 +50,7 @@ pub const InitOpts = struct {
body: ?[]const u8 = null,
cache: Cache = .default,
credentials: Credentials = .@"same-origin",
signal: ?*AbortSignal = null,
};
const Credentials = enum {
@@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
.request => |r| r._body,
};
const signal = if (opts.signal) |s|
s
else switch (input) {
.url => null,
.request => |r| r._signal,
};
return page._factory.create(Request{
._url = url,
._arena = arena,
@@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
._cache = opts.cache,
._credentials = opts.credentials,
._body = body,
._signal = signal,
});
}
@@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 {
return @tagName(self._credentials);
}
pub fn getSignal(self: *const Request) ?*AbortSignal {
return self._signal;
}
pub fn getHeaders(self: *Request, page: *Page) !*Headers {
if (self._headers) |headers| {
return headers;
@@ -177,8 +192,8 @@ pub fn text(self: *const Request, page: *Page) !js.Promise {
pub fn json(self: *const Request, page: *Page) !js.Promise {
const body = self._body orelse "";
const local = page.js.local.?;
const value = local.parseJSON(body) catch |err| {
return local.rejectPromise(.{@errorName(err)});
const value = local.parseJSON(body) catch {
return local.rejectPromise(.{ .syntax_error = "failed to parse" });
};
return local.resolvePromise(try value.persist());
}
@@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request {
._cache = self._cache,
._credentials = self._credentials,
._body = self._body,
._signal = self._signal,
});
}
@@ -218,6 +234,7 @@ pub const JsApi = struct {
pub const headers = bridge.accessor(Request.getHeaders, null, .{});
pub const cache = bridge.accessor(Request.getCache, null, .{});
pub const credentials = bridge.accessor(Request.getCredentials, null, .{});
pub const signal = bridge.accessor(Request.getSignal, null, .{});
pub const blob = bridge.function(Request.blob, .{});
pub const text = bridge.function(Request.text, .{});
pub const json = bridge.function(Request.json, .{});

View File

@@ -139,8 +139,8 @@ pub fn getText(self: *const Response, page: *Page) !js.Promise {
pub fn getJson(self: *Response, page: *Page) !js.Promise {
const body = self._body orelse "";
const local = page.js.local.?;
const value = local.parseJSON(body) catch |err| {
return local.rejectPromise(.{@errorName(err)});
const value = local.parseJSON(body) catch {
return local.rejectPromise(.{ .syntax_error = "failed to parse" });
};
return local.resolvePromise(try value.persist());
}

View File

@@ -29,6 +29,7 @@ const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const Blob = @import("../Blob.zig");
const Event = @import("../Event.zig");
const Headers = @import("Headers.zig");
const EventTarget = @import("../EventTarget.zig");
@@ -211,6 +212,11 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
}
const page = self._page;
if (std.mem.startsWith(u8, self._url, "blob:")) {
return self.handleBlobUrl(page);
}
const http_client = page._session.browser.http_client;
var headers = try http_client.newHeaders();
@@ -242,6 +248,39 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
page.js.strongRef(self);
}
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
const blob = page.lookupBlobUrl(self._url) orelse {
self.handleError(error.BlobNotFound);
return;
};
self._response_status = 200;
self._response_url = self._url;
try self._response_data.appendSlice(self._arena, blob._slice);
self._response_len = blob._slice.len;
try self.stateChanged(.headers_received, page);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page);
try self.stateChanged(.loading, page);
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response_data.items.len,
}, page);
try self.stateChanged(.done, page);
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
}, page);
try self._proto.dispatch(.load_end, .{
.total = loaded,
.loaded = loaded,
}, page);
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
return @intFromEnum(self._ready_state);
}

View File

@@ -255,7 +255,7 @@ pub fn pipeThrough(self: *ReadableStream, transform: PipeTransform, page: *Page)
/// Returns a promise that resolves when piping is complete.
pub fn pipeTo(self: *ReadableStream, destination: *WritableStream, page: *Page) !js.Promise {
if (self.getLocked()) {
return page.js.local.?.rejectPromise("ReadableStream is locked");
return page.js.local.?.rejectPromise(.{ .type_error = "ReadableStream is locked" });
}
const local = page.js.local.?;

View File

@@ -58,12 +58,12 @@ pub const ReadResult = struct {
pub fn read(self: *ReadableStreamDefaultReader, page: *Page) !js.Promise {
const stream = self._stream orelse {
return page.js.local.?.rejectPromise("Reader has been released");
return page.js.local.?.rejectPromise(.{ .type_error = "Reader has been released" });
};
if (stream._state == .errored) {
const err = stream._stored_error orelse "Stream errored";
return page.js.local.?.rejectPromise(err);
//const err = stream._stored_error orelse "Stream errored";
return page.js.local.?.rejectPromise(.{ .type_error = "Stream errored" });
}
if (stream._controller.dequeue()) |chunk| {
@@ -95,7 +95,7 @@ pub fn releaseLock(self: *ReadableStreamDefaultReader) void {
pub fn cancel(self: *ReadableStreamDefaultReader, reason_: ?[]const u8, page: *Page) !js.Promise {
const stream = self._stream orelse {
return page.js.local.?.rejectPromise("Reader has been released");
return page.js.local.?.rejectPromise(.{ .type_error = "Reader has been released" });
};
self.releaseLock();

View File

@@ -32,11 +32,11 @@ pub fn init(stream: *WritableStream, page: *Page) !*WritableStreamDefaultWriter
pub fn write(self: *WritableStreamDefaultWriter, chunk: js.Value, page: *Page) !js.Promise {
const stream = self._stream orelse {
return page.js.local.?.rejectPromise("Writer has been released");
return page.js.local.?.rejectPromise(.{ .type_error = "Writer has been released" });
};
if (stream._state != .writable) {
return page.js.local.?.rejectPromise("Stream is not writable");
return page.js.local.?.rejectPromise(.{ .type_error = "Stream is not writable" });
}
try stream.writeChunk(chunk, page);
@@ -46,11 +46,11 @@ pub fn write(self: *WritableStreamDefaultWriter, chunk: js.Value, page: *Page) !
pub fn close(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise {
const stream = self._stream orelse {
return page.js.local.?.rejectPromise("Writer has been released");
return page.js.local.?.rejectPromise(.{ .type_error = "Writer has been released" });
};
if (stream._state != .writable) {
return page.js.local.?.rejectPromise("Stream is not writable");
return page.js.local.?.rejectPromise(.{ .type_error = "Stream is not writable" });
}
try stream.closeStream(page);
@@ -67,7 +67,7 @@ pub fn releaseLock(self: *WritableStreamDefaultWriter) void {
pub fn getClosed(self: *WritableStreamDefaultWriter, page: *Page) !js.Promise {
const stream = self._stream orelse {
return page.js.local.?.rejectPromise("Writer has been released");
return page.js.local.?.rejectPromise(.{ .type_error = "Writer has been released" });
};
if (stream._state == .closed) {

View File

@@ -228,6 +228,13 @@ pub const Writer = struct {
try w.objectField("value");
switch (value) {
.integer => |v| {
// CDP spec requires integer values to be serialized as strings.
// 20 bytes is enough for the decimal representation of a 64-bit integer.
var buf: [20]u8 = undefined;
const s = try std.fmt.bufPrint(&buf, "{d}", .{v});
try w.write(s);
},
inline else => |v| try w.write(v),
}
@@ -1212,4 +1219,25 @@ test "AXNode: writer" {
// Check childIds array exists
const child_ids = doc_node.get("childIds").?.array.items;
try testing.expect(child_ids.len > 0);
// Find the h1 node and verify its level property is serialized as a string
for (nodes) |node_val| {
const obj = node_val.object;
const role_obj = obj.get("role") orelse continue;
const role_val = role_obj.object.get("value") orelse continue;
if (!std.mem.eql(u8, role_val.string, "heading")) continue;
const props = obj.get("properties").?.array.items;
for (props) |prop| {
const prop_obj = prop.object;
const name_str = prop_obj.get("name").?.string;
if (!std.mem.eql(u8, name_str, "level")) continue;
const level_value = prop_obj.get("value").?.object;
try testing.expectEqual("integer", level_value.get("type").?.string);
// CDP spec: integer values must be serialized as strings
try testing.expectEqual("1", level_value.get("value").?.string);
return;
}
}
return error.HeadingNodeNotFound;
}

View File

@@ -168,13 +168,11 @@ pub fn CDPT(comptime TypeProvider: type) type {
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch {};
return err;
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch {};
return err;
command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
@@ -924,18 +922,20 @@ test "cdp: invalid json" {
// method is required
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
try ctx.processMessage(.{
.method = "Target",
}));
});
try ctx.expectSentError(-31998, "InvalidMethod", .{});
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
try ctx.processMessage(.{
.method = "Unknown.domain",
}));
});
try ctx.expectSentError(-31998, "UnknownDomain", .{});
try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
try ctx.processMessage(.{
.method = "Target.over9000",
}));
});
try ctx.expectSentError(-31998, "UnknownMethod", .{});
}
test "cdp: invalid sessionId" {

View File

@@ -550,11 +550,12 @@ test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context();
defer ctx.deinit();
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{
try ctx.processMessage(.{
.id = 8,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 },
}));
});
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 8 });
}
test "cdp.dom: search flow" {
@@ -604,11 +605,12 @@ test "cdp.dom: search flow" {
try ctx.expectSentResult(null, .{ .id = 16 });
// make sure the delete actually did something
try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{
try ctx.processMessage(.{
.id = 17,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
}));
});
try ctx.expectSentError(-31998, "SearchResultNotFound", .{ .id = 17 });
}
test "cdp.dom: querySelector unknown search id" {
@@ -645,11 +647,12 @@ test "cdp.dom: querySelector Node not found" {
});
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 });
try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{
try ctx.processMessage(.{
.id = 4,
.method = "DOM.querySelector",
.params = .{ .nodeId = 1, .selector = "a" },
}));
});
try ctx.expectSentError(-31998, "NodeNotFoundForGivenId", .{ .id = 4 });
try ctx.processMessage(.{
.id = 5,

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
@@ -24,6 +25,7 @@ pub fn processMessage(cmd: anytype) !void {
setFocusEmulationEnabled,
setDeviceMetricsOverride,
setTouchEmulationEnabled,
setUserAgentOverride,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -31,6 +33,7 @@ pub fn processMessage(cmd: anytype) !void {
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
.setUserAgentOverride => return setUserAgentOverride(cmd),
}
}
@@ -64,3 +67,8 @@ fn setDeviceMetricsOverride(cmd: anytype) !void {
fn setTouchEmulationEnabled(cmd: anytype) !void {
return cmd.sendResult(null, .{});
}
fn setUserAgentOverride(cmd: anytype) !void {
log.info(.app, "setUserAgentOverride ignored", .{});
return cmd.sendResult(null, .{});
}

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