528 Commits

Author SHA1 Message Date
Pierre Tachoire
e25c33eaa6 Merge pull request #1673 from arrufat/mcp
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add Model Context Protocol (MCP) server support
2026-03-03 15:18:34 +01:00
Adrià Arrufat
7bddc0a89c mcp: remove search and over tools 2026-03-03 22:50:06 +09:00
Karl Seguin
403f42bf38 Merge pull request #1702 from arrufat/cdp-namespaces
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add LP domain for CDP and getMarkdown method
2026-03-03 18:08:45 +08:00
Adrià Arrufat
b2e301418f cdp.lp: use page.document instead of window._document 2026-03-03 17:11:16 +09:00
Adrià Arrufat
334a2e44a1 lp: simplify dom_node resolution in getMarkdown 2026-03-03 17:08:43 +09:00
Adrià Arrufat
c9121a03d2 cdp: move LP.getMarkdown test to lp domain 2026-03-03 16:39:31 +09:00
Adrià Arrufat
cc93180d57 cdp: add LP domain and getMarkdown method
This PR introduces a custom CDP domain 'LP' (Lightpanda) to expose browser-specific tools. The first method, 'LP.getMarkdown', allows retrieving a Markdown representation of the DOM or a specific node by its 'nodeId'. This is optimized for AI agents and LLM-based scraping tasks.
2026-03-03 16:35:48 +09:00
Pierre Tachoire
4062a425cb Merge pull request #1700 from lightpanda-io/minor_cleanup
Remove unused file and unused .gitignore paths
2026-03-03 08:09:44 +01:00
Karl Seguin
cce533ebb6 Merge pull request #1701 from arrufat/markdown-test-namespace
markdown: namespace tests
2026-03-03 14:21:24 +08:00
Adrià Arrufat
48df38cbfe mcp: improve evaluate error reporting and refactor tool result types 2026-03-03 15:17:59 +09:00
Adrià Arrufat
f982f073df mcp: optimize memory re-use and add thread safety to Server.sendResponse 2026-03-03 14:50:13 +09:00
Adrià Arrufat
34999f12ca mcp: migrate tests to expectJson 2026-03-03 14:40:20 +09:00
Adrià Arrufat
c8d5665653 mcp: use testing allocator in tests 2026-03-03 14:32:29 +09:00
Adrià Arrufat
ddebaf87d0 markdown: namespace tests 2026-03-03 14:22:45 +09:00
Adrià Arrufat
6b80cd6109 mcp: namespace tests 2026-03-03 14:19:36 +09:00
Karl Seguin
7635d8d2a5 Remove unused file and unused .gitignore paths 2026-03-03 12:08:53 +08:00
Adrià Arrufat
634e3e35a0 mcp: re-enable tests 2026-03-02 23:12:16 +09:00
Adrià Arrufat
da3dc58199 Merge branch 'main' into mcp 2026-03-02 23:01:55 +09:00
Adrià Arrufat
4f99df694b mcp: simplify minify and remove eval quota 2026-03-02 22:46:20 +09:00
Adrià Arrufat
982b8e2d72 mcp: remove redundant mcp from test references 2026-03-02 22:24:17 +09:00
Adrià Arrufat
6e7c8d7ae2 mcp: consolidate tests and streamline parameter parsing 2026-03-02 22:18:02 +09:00
Adrià Arrufat
3c858f522b mcp: simplify minify function 2026-03-02 22:04:55 +09:00
Adrià Arrufat
f2a30f8cdd mcp: don't forget to flush 2026-03-02 21:46:49 +09:00
Adrià Arrufat
43785bfab4 mcp: simplify handleList implementations 2026-03-02 21:30:47 +09:00
Adrià Arrufat
78edf6d324 mcp: simplify I/O architecture and remove test harness 2026-03-02 21:25:07 +09:00
Adrià Arrufat
73565c4493 mcp: optimize dispatching and simplify test harness
- Use StaticStringMap and enums for method, tool, and resource lookups.
- Implement comptime JSON minification for tool schemas.
- Refactor router and harness to use more efficient buffered polling.
- Consolidate integration tests and add synchronous unit tests.
2026-03-02 20:53:14 +09:00
Karl Seguin
8c37cac957 Merge pull request #1694 from lightpanda-io/client_abort_frame
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Allow frame-specific HTTP abort
2026-03-02 18:11:33 +08:00
Karl Seguin
eceab76b6f Merge pull request #1693 from lightpanda-io/nikneym/arena-pool-test
`ArenaPool`: make init configurable + add tests
2026-03-02 18:11:13 +08:00
Karl Seguin
1f81b6ddc4 Allow frame-specific HTTP abort
Needed for frame navigation. Implemented using some ugly comptime to avoid
duplication and avoid an runtime frame check when doing a full abort.
2026-03-02 18:00:55 +08:00
Halil Durak
52c3aadd24 ArenaPool: add tests 2026-03-02 12:56:10 +03:00
Halil Durak
ad87573d09 ArenaPool: make init configurable 2026-03-02 12:55:55 +03:00
Karl Seguin
20fbfc8544 Merge pull request #1689 from lightpanda-io/protect_xhr_abort_during_callback
Protect against transfer.abort() being called during callback
2026-03-02 17:42:07 +08:00
Karl Seguin
7695c8403f Merge pull request #1692 from lightpanda-io/rename_page_id_to_frame_id
Rename page.id to page._frame_id
2026-03-02 17:40:43 +08:00
Karl Seguin
421983d06e Merge pull request #1690 from lightpanda-io/event_dispatch_cleanup
Attempt to improve non-DOM EventTarget dispatching
2026-03-02 17:40:29 +08:00
Karl Seguin
328c681a8f Add transfer-specific "performing" flag
In the previous commits, two separte crash resolution conspired to introduce
1 tick delay in request handling.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

I was initially going to hook into the v8::Object's internal fields to store
the referencing v8::Object. So the v8::Object representing the Iterator
would store the v8::Object representing the NodeList inside of its internal
field - which the GC would trace/detect/respect. And that is probably the
fastest and most v8-ish solution, but I couldn't come up with an elegant
solution. The best I had was having a "afterCreate" callback which passed
the v8 object (this is similar to the old postAttach callback we had, but
used for a different purpose). However, since "acquireRef" was recently
added to events, re-using that was much simpler and worked well.
2026-02-27 10:29:46 +08:00
Karl Seguin
a14ad6f700 Merge pull request #1659 from lightpanda-io/nodelist_enumerable
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Make NodeList enumerable
2026-02-27 08:26:59 +08:00
Karl Seguin
76dcdfb98c Use github repo for curl source
Since we already rely on github for builds, this removes a point of failure.
Also curl.se consistently fails from a VPS machine for me - not sure if
they're blocking IP ranges, but it works fine on github.
2026-02-27 07:02:06 +08:00
Karl Seguin
99c09ba8a1 Merge pull request #1657 from lightpanda-io/FileReader
Add FileReader
2026-02-27 06:58:49 +08:00
Karl Seguin
0f18b76813 Merge pull request #1665 from lightpanda-io/cookies-limit
add limit for cookie and jar size
2026-02-27 06:54:39 +08:00
Pierre Tachoire
8504e4cd22 add limit for cookie and jar size 2026-02-26 18:33:18 +01:00
Karl Seguin
ebe793e0e7 Merge pull request #1663 from lightpanda-io/more_header_callback_debugging
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add more properties to ScriptManager.Header recall
2026-02-26 19:15:59 +08:00
Pierre Tachoire
965c6cf4d9 Merge pull request #1662 from lightpanda-io/non_keyboard_keydown_event
Don't assume that a 'keydown' event is a KeyboardEvent
2026-02-26 11:55:54 +01:00
Pierre Tachoire
2b1ab3184e Merge pull request #1640 from lightpanda-io/nikneym/load-events-after-doc-complete
Dispatch `load` events that're attached after `documentIsComplete`
2026-02-26 11:01:46 +01:00
Karl Seguin
e7d21c2dbe Add more properties to ScriptManager.Header recall
This bug continues elusive. The latest crash logs show us that, somehow, 1
script is being resolved from 2 transfer objects. This doesn't seem possible,
so I'm adding more properties to log the state of both transfers to try and
figure out how this is happening.
2026-02-26 17:52:33 +08:00
Nikolay Govorov
11906d9d71 Merge pull request #1650 from lightpanda-io/wp/mrdimidium/update-deps
Updates dependencies and their build
2026-02-26 09:19:34 +00:00
Nikolay Govorov
ac5a64d77a Fix typo in build.zig
Co-authored-by: Halil Durak <halildrk@gmail.com>
2026-02-26 08:41:01 +00:00
Halil Durak
c86c851c60 move *addedCallbacks to respective types 2026-02-26 10:27:35 +03:00
Halil Durak
721cf98486 update Image and Style tests 2026-02-26 10:27:35 +03:00
Halil Durak
84bbb6efd4 replacement w/ imageAddedCallback 2026-02-26 10:27:35 +03:00
Halil Durak
f897cda6cd dispatch Style element's load event from nodeIsReady 2026-02-26 10:27:34 +03:00
Halil Durak
2da8b25b09 add LinkLoadError to CloneError 2026-02-26 10:27:34 +03:00
Halil Durak
3f94fd90dd dispatch a load event when href set for Link element
Also add `lazy-href-set` test.
2026-02-26 10:27:34 +03:00
Halil Durak
bc6be22cb4 update test 2026-02-26 10:27:34 +03:00
Halil Durak
e23604e08d introduce dispatchLoad and move load dispatching to Session._wait 2026-02-26 10:27:33 +03:00
Halil Durak
be858ac9ce add load event related tests to link.html 2026-02-26 10:27:33 +03:00
Halil Durak
137ab4a557 dispatch load events that're attached after documentIsComplete 2026-02-26 10:27:33 +03:00
Karl Seguin
bad0fc386d Don't assume that a 'keydown' event is a KeyboardEvent 2026-02-26 15:26:34 +08:00
Karl Seguin
641c7b2c89 Merge pull request #1661 from lightpanda-io/escape_navigate_url
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Callers to page.navigate ensure URL is properly encoded.
2026-02-26 15:19:11 +08:00
Karl Seguin
21be3db51f Callers to page.navigate ensure URL is properly encoded.
Follow up to https://github.com/lightpanda-io/browser/pull/1646

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

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

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

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

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

We still only drain the microtasks when executing v8 callbacks.

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

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

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

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

a.href = "over 9000!"

Then:

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

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

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

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

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

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

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

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

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

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

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

So much hassle.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

to be turned into:

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

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

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

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

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

This commit introduces a number of changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

DOMRect is now off the heap. This avoids _a lot_ of allocation when a DOMRect
is only needed for internal calculation, e.g. in Document.elementFromPoint.
2026-02-19 09:45:56 +08:00
Karl Seguin
5fd95788f9 Merge pull request #1585 from egrs/focusin-focusout-events
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
dispatch focusin/focusout events with relatedTarget
2026-02-19 08:15:54 +08:00
Karl Seguin
bd29f168e0 Merge pull request #1590 from egrs/range-tostring-fix
fix Range.toString() for cross-container and element ranges
2026-02-19 08:08:25 +08:00
Karl Seguin
dc97e33cd6 Merge pull request #1591 from lightpanda-io/input_and_window_test
Remove duplicate window test
2026-02-19 07:59:31 +08:00
Karl Seguin
caf7cb07cd Remove duplicate window test
Re-enable some commented out input tests
2026-02-19 07:47:00 +08:00
Karl Seguin
ad5df53ee7 Merge pull request #1583 from egrs/window-htmlelement-input-props
add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
2026-02-19 07:44:36 +08:00
Halil Durak
95920bf207 ArrayBufferRef(...).Global: consistent, persisted typed arrays 2026-02-18 21:43:19 +03:00
egrs
6700166841 fix Range.toString() for cross-container and element ranges
implement proper tree-walking in writeTextContent to handle all cases:
same-element containers, cross-container ranges, and comment exclusion.
uncomment ~800 lines of Range tests and add 5 new toString tests.
2026-02-18 16:25:34 +01:00
Karl Seguin
b8196cd06e Merge pull request #1588 from egrs/click-to-focus
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
click on focusable elements calls focus() with events
2026-02-18 23:12:21 +08:00
egrs
c28afbf193 address review feedback: move stubs test, inline bridge functions, catch unreachable 2026-02-18 15:40:59 +01:00
egrs
84ffffb3f3 dispatch select event from input.select() and textarea.select() 2026-02-18 15:31:52 +01:00
egrs
b2c030140c click on focusable elements calls focus() with events
handleClick() was setting _active_element directly, bypassing
Element.focus() — so no blur/focus events fired on click.
Now calls focus() for input, button, select, textarea, anchor.
2026-02-18 15:08:40 +01:00
Muki Kiboigo
90138ed574 use applyModify generally 2026-02-18 06:07:06 -08:00
Adrià Arrufat
92f131bbe4 Inline writeIndentation helper 2026-02-18 23:02:10 +09:00
Karl Seguin
338580087e Add HTMLLabelElement.control getter 2026-02-18 21:59:15 +08:00
Pierre Tachoire
deda53a842 Merge pull request #1353 from lightpanda-io/wp/mrdimidium/multicontext
Use thread per connection
2026-02-18 14:59:15 +01:00
Pierre Tachoire
5391854c82 Merge pull request #1586 from lightpanda-io/makefile-typo-fix
typo fix
2026-02-18 14:51:46 +01:00
Pierre Tachoire
e288bfbec4 typo fix 2026-02-18 14:50:43 +01:00
egrs
377fe5bc40 add comment on _active_element ordering constraint 2026-02-18 14:50:20 +01:00
Adrià Arrufat
d264ff2801 Use attributes for checkbox rendering 2026-02-18 22:48:46 +09:00
egrs
a21bb6b02d dispatch focusin/focusout events with relatedTarget
focus() and blur() now dispatch all four spec-required FocusEvents:
blur (no bubble) → focusout (bubbles) → focus (no bubble) → focusin (bubbles)

Each event carries the correct relatedTarget: the element gaining focus
for blur/focusout, and the element losing focus for focus/focusin.
All four events are composed per W3C spec.

Relates to #1161
2026-02-18 14:31:11 +01:00
egrs
37ecd5cdef use textarea.innerInsert for selection-aware text insertion
Both insertText() and handleKeydown() were manually concatenating text
to the textarea value, ignoring the current selection range. This uses
the existing innerInsert() method which correctly handles full, partial,
and no-selection states — matching the Input element behavior.
2026-02-18 13:47:09 +01:00
egrs
07a87dfba7 fix tabIndex default for interactive elements per spec
interactive elements (input, button, a, select, textarea, iframe)
default to 0 when tabindex attribute is absent; others default to -1.
also add TODO for hidden "until-found" tristate.
2026-02-18 13:13:12 +01:00
egrs
9e4db89521 add window stubs, HTMLElement hidden/tabIndex, input attribute reflections
- Window: alert/confirm/prompt (no-op stubs), devicePixelRatio
- HTMLElement: hidden (boolean), tabIndex (integer)
- Input: placeholder, min, max, step, multiple, autocomplete
2026-02-18 13:04:12 +01:00
Karl Seguin
536d394e41 Merge pull request #1579 from egrs/style-element-sheet
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
return CSSStyleSheet from HTMLStyleElement.sheet
2026-02-18 19:59:38 +08:00
Karl Seguin
c0580c7ad0 Merge pull request #1581 from egrs/element-attribute-reflections
add attribute reflections for 8 HTML element types
2026-02-18 19:50:23 +08:00
egrs
488e72ef4e wire up CSSStyleSheet.ownerNode to the style element 2026-02-18 12:41:31 +01:00
Karl Seguin
01c224d301 Merge pull request #1580 from egrs/image-complete
add HTMLImageElement.complete property
2026-02-18 19:02:07 +08:00
egrs
eaf95a85a8 fix OL.type default and TableCell span clamping per spec
OL.type returns "1" (not "") when attribute absent.
TableCell.colSpan clamps to 1-1000, rowSpan to 0-65534.
2026-02-18 12:00:44 +01:00
Pierre Tachoire
ba1d084660 Merge pull request #1570 from egrs/media-play-pause-events
Dispatch play, pause, and emptied events from HTMLMediaElement
2026-02-18 11:46:28 +01:00
egrs
2e64c461c3 add attribute reflections for 8 HTML element types
Wire up missing IDL properties for HTMLTimeElement (dateTime),
HTMLLIElement (value), HTMLOListElement (start, reversed, type),
HTMLOptGroupElement (disabled, label), HTMLQuoteElement (cite),
HTMLTableCellElement (colSpan, rowSpan), HTMLLabelElement (htmlFor),
and HTMLFieldSetElement (disabled, name).
2026-02-18 11:36:58 +01:00
Adrià Arrufat
ce5dad722f Make --dump format optional and improve markdown rendering 2026-02-18 19:33:32 +09:00
egrs
7675feca91 revert _playing flag: playing event fires on every resume
Tested in Chrome (headless, --autoplay-policy=no-user-gesture-required):
playing fires on every pause-to-play transition, not just the first
time. The _playing flag was incorrectly suppressing this. Removed it
and updated tests to match verified Chrome behavior.
2026-02-18 10:49:27 +01:00
egrs
c66d74e135 clarify comment: images are in broken state, not fully fetched 2026-02-18 10:42:01 +01:00
egrs
54d6eed740 fix type check: case-insensitive, accept empty string, clear on disconnect
Per spec, valid type values are absent, empty string, or a
case-insensitive match for "text/css". Also clear cached sheet
when the element is disconnected or type becomes invalid.
2026-02-18 10:40:42 +01:00
Nikolay Govorov
dc4b75070d Increases the memory limit on CI :/ 2026-02-18 09:23:37 +00:00
egrs
830eb74725 track playing state: only dispatch playing on first start
Per review: playing event should only fire on first start, not
on every resume from pause. Add _playing field, reset on load().
2026-02-18 10:22:49 +01:00
Nikolay Govorov
4f21d8d7a8 Implement multi-cdp architecture 2026-02-18 09:22:31 +00:00
Nikolay Govorov
424deb8faf Run cdp client on dedicated thread 2026-02-18 09:22:29 +00:00
Nikolay Govorov
b4a40f1257 Move IO loops from Server to cdp Client 2026-02-18 09:22:27 +00:00
Nikolay Govorov
9296c10ca4 Use per-cdp connection HttpClient 2026-02-18 09:22:26 +00:00
Nikolay Govorov
fbe65cd542 Use std.Atomic.Value in Server instead of direct atomic operations 2026-02-18 09:22:24 +00:00
Nikolay Govorov
ccbb6e4789 Make ArenaPool, Robots and Env thread safety 2026-02-18 09:22:23 +00:00
Nikolay Govorov
d70f436304 Fix use-after-free in Fetch 2026-02-18 09:22:21 +00:00
Nikolay Govorov
16aaa8201c Drop unused config opts 2026-02-18 09:22:18 +00:00
Karl Seguin
acc1f2f3d7 Merge pull request #1578 from lightpanda-io/detach_attached_parsed_node
Detach attached nodes on appendBeforeSibling callback
2026-02-18 15:42:13 +08:00
egrs
433d254c70 add HTMLImageElement.complete property
Per spec, complete returns true when the image has no src, is fully
fetched, or is broken. Since this is a headless browser that does
not fetch images, complete always returns true.
2026-02-18 08:18:54 +01:00
egrs
ea4eebd2d6 return CSSStyleSheet from HTMLStyleElement.sheet
Per spec, connected style elements with type text/css should
return a CSSStyleSheet object. Previously always returned null.
The sheet is lazily created and cached on first access.
2026-02-18 08:11:08 +01:00
Karl Seguin
3c00a527dd Merge pull request #1574 from egrs/performance-observer-buffered
Support buffered option in PerformanceObserver.observe()
2026-02-18 14:48:15 +08:00
egrs
f72a354066 address review: clear marks before test, assert exactly 2 2026-02-18 07:15:17 +01:00
egrs
7c92e0e9ce address review: fix doc comment, skip buffered if already queued 2026-02-18 07:14:12 +01:00
Karl Seguin
4f6868728d Detach attached nodes on appendBeforeSibling callback
html5ever generally makes guarantees about nodes being parentless when
appending, but we've already seen 1 case where appendCallback receives a
connected node.

We're now seeing something in appendBeforeSiblingCallback, but we have a clearer
picture of how this is happening. In this case, it's via custom element
upgrading and the custom element constructor has already placed the node in
the document.

It's worth pointing, html5ever just has an opaque reference to our node. While
it guarantees that it will give us parent-less nodes, it doesn't actually know
anything about our nodes, or our node._parent. The guarantee is only from its
own point of view. There's nothing stopping us from giving a node a default
parent as soon as html5ever asks us to create a new node, in which case, the
node _will_ have a parent.
2026-02-18 10:52:51 +08:00
Adrià Arrufat
0ec4522f9e Update test with new --dump flag 2026-02-18 11:39:14 +09:00
Adrià Arrufat
c6e0c6d096 Simplify tests in markdown 2026-02-18 11:20:22 +09:00
Adrià Arrufat
dc0fb9ed8a Remove unused import in markdown 2026-02-18 11:04:12 +09:00
Adrià Arrufat
66d9eaee78 Simplify block element rendering in Markdown 2026-02-18 11:01:00 +09:00
Karl Seguin
3797272faf Merge pull request #1576 from lightpanda-io/navigation-is-event-target
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Navigation is the EventTarget
2026-02-18 07:33:48 +08:00
Karl Seguin
682b302d04 Merge pull request #1573 from egrs/response-statustext
Populate Response.statusText for network responses
2026-02-18 07:28:55 +08:00
Karl Seguin
1de10f9b05 Merge pull request #1572 from egrs/intersection-observer-timestamp
Use performance.now() for IntersectionObserverEntry.time
2026-02-18 07:21:57 +08:00
Muki Kiboigo
c4391ff058 refactor modifyBy implementations 2026-02-17 12:11:29 -08:00
Muki Kiboigo
3822e3f8d9 pass selection/modify-extend-word-trailing-inline-block.tentative.html 2026-02-17 12:02:34 -08:00
Muki Kiboigo
f8f99f3878 pass selection/modify.tentative.html 2026-02-17 11:58:13 -08:00
Muki Kiboigo
e77e4acea9 add tests for Selection.modify 2026-02-17 11:50:38 -08:00
Muki Kiboigo
c6de444d0b add support for Selection.modify 2026-02-17 11:50:30 -08:00
Muki Kiboigo
89e38c34b8 remove NavigationEventTarget 2026-02-17 11:13:39 -08:00
Muki Kiboigo
246d17972c add standaloneEventTarget to the Factory 2026-02-17 11:13:27 -08:00
Pierre Tachoire
55a8b37ef8 Merge pull request #1575 from lightpanda-io/remove-merge-marker
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
remove merge marker from test file
2026-02-17 18:29:12 +01:00
Pierre Tachoire
445183001b remove merge marker from test file 2026-02-17 18:27:54 +01:00
egrs
ca9e2200da use async delivery for buffered performance observer entries
Per spec, buffered entries should be delivered via a queued task,
not synchronously. Extract scheduling logic into
schedulePerformanceObserverDelivery() and use it from both
notifyPerformanceObservers and the buffered observe path.
2026-02-17 18:16:42 +01:00
Pierre Tachoire
eba3f84c04 Merge pull request #1569 from egrs/cdp-cookie-size
add cookie size to CDP response
2026-02-17 17:57:47 +01:00
Pierre Tachoire
867e6a8f4b Merge pull request #1566 from egrs/mouseevent-buttons-property
implement MouseEvent.buttons property
2026-02-17 17:57:30 +01:00
egrs
df9779ec59 restrict buffered option to type-based observation per spec
The buffered option is only valid with the type option, not entryTypes,
per the Performance Observer specification.
2026-02-17 16:12:20 +01:00
egrs
1b71d1e46d fix playing event: only dispatch on paused-to-playing transition
Per MDN, playing fires "after playback is first started, and whenever
it is restarted." A second play() while already playing should be a
no-op. Both play and playing now only fire on the paused -> playing
transition.
2026-02-17 16:10:49 +01:00
egrs
0a58918f47 address review: conditional event dispatch and explicit options
- play event only fires when transitioning from paused to playing
- pause event only fires when transitioning from playing to paused
- playing event always fires on play() per spec
- explicitly set bubbles: false, cancelable: false on events
- updated tests to verify no duplicate events on repeated calls
2026-02-17 16:03:00 +01:00
egrs
afbd927fc0 support buffered option in PerformanceObserver.observe()
When buffered is true, deliver existing performance entries that match
the observer's interest immediately, per the Performance Observer spec.
2026-02-17 15:58:38 +01:00
egrs
2aa09ae18d populate Response.statusText for network responses
Fetch responses from the network now have their statusText set using
the HTTP status phrase (e.g. "OK", "Not Found"), matching the Fetch
spec and the existing XMLHttpRequest behavior.
2026-02-17 15:49:20 +01:00
egrs
09789b0b72 use performance.now() for IntersectionObserverEntry.time
The time field was hardcoded to 0.0. Now uses the Performance API
to provide a real DOMHighResTimeStamp, matching the spec.
2026-02-17 15:38:02 +01:00
Halil Durak
2426abd17a introduce persisted typed arrays 2026-02-17 16:35:42 +03:00
Pierre Tachoire
db4a97743f Merge pull request #1562 from lightpanda-io/robots-cdp-failure
dispatch .page_navigated event on page error callback and create HTML page
2026-02-17 14:17:44 +01:00
Pierre Tachoire
7ca98ed344 Merge pull request #1568 from lightpanda-io/invalid_cookie_samesite
protect against long invalid samesite cookie values
2026-02-17 14:10:24 +01:00
egrs
c9d3d17999 dispatch play, playing, pause, and emptied events from HTMLMediaElement
play(), pause(), and load() now fire the corresponding DOM events,
matching the HTMLMediaElement spec behavior.
2026-02-17 13:22:32 +01:00
egrs
628049cfd7 add cookie size to CDP response
Compute and include the cookie size field (name.len + value.len)
in Storage.getCookies and Network.getCookies CDP responses,
matching Chrome's behavior.
2026-02-17 13:08:02 +01:00
egrs
ae9a11da53 implement MouseEvent.buttons property
Add the `buttons` read-only property to MouseEvent as specified by
the W3C UI Events spec (unsigned short bitmask of currently pressed
buttons). Propagate the field through PointerEvent and WheelEvent
constructors which inherit from MouseEvent.
2026-02-17 12:52:44 +01:00
Karl Seguin
7e097482bc protect against long invalid samesite cookie values 2026-02-17 19:09:29 +08:00
Pierre Tachoire
df1b151587 Merge pull request #1567 from lightpanda-io/http_linefeed_only_ending
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Support HTTP headers which are \n terminated (as opposed to \r\n).
2026-02-17 12:09:05 +01:00
Karl Seguin
45eb59a5aa Support HTTP headers which are \n terminated (as opposed to \r\n).
Looks like curl will accept these as valid headers, and won't normalize the
header, so we have to deal with either a 2-byte or 1-byte terminated header
2026-02-17 18:55:50 +08:00
Karl Seguin
687c17bbe2 Merge pull request #1557 from lightpanda-io/internal_field_caching
Add internal field caching (for window.document and window.console)
2026-02-17 18:25:51 +08:00
Pierre Tachoire
7505aec706 generate always an HTML on pageDoneCallback
Add also image support
2026-02-17 10:31:47 +01:00
Pierre Tachoire
c7b414492d add image content type detection into Mime 2026-02-17 10:30:47 +01:00
Pierre Tachoire
14b0095822 move page error HTML creation into pageDoneCallback
Now pageErrCllaback call pageDoneCallback to finalize the page.
2026-02-17 09:47:45 +01:00
Karl Seguin
a1256b46c8 Merge pull request #1553 from lightpanda-io/nikneym/image-data
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Basic support for `ImageData`
2026-02-17 08:57:31 +08:00
Halil Durak
094270dff7 prefer snake case in enums 2026-02-17 02:48:30 +03:00
Halil Durak
d4e24dabc2 internal -> handle 2026-02-17 02:47:51 +03:00
Halil Durak
842df0d112 extern struct -> struct 2026-02-17 02:46:36 +03:00
Halil Durak
cfa9427d7c ImageData: make sure that width and height are not 0 2026-02-17 02:18:43 +03:00
Halil Durak
3c01e24f02 ImageData: remove unnecessary comments 2026-02-17 02:18:13 +03:00
Karl Seguin
22dbf63ff9 Merge pull request #1563 from lightpanda-io/wp/mrdimidium/fix-sighandler-rc
Fix race condition in sighandler
2026-02-17 07:08:39 +08:00
Karl Seguin
814f7394a0 Merge pull request #1556 from lightpanda-io/robots-perf
`robots.txt` performance improvements
2026-02-17 06:58:28 +08:00
Halil Durak
9a4cebaa1b ImageData: prefer new typed array type 2026-02-17 01:45:40 +03:00
Halil Durak
c30207ac63 introduce js.createTypedArray
A new way to create typed arrays that allows using the same memory.
2026-02-17 01:45:19 +03:00
Nikolay Govorov
77afbddb91 Fix race condition in sighandler 2026-02-16 21:28:29 +00:00
Nikolay Govorov
18feeabe15 Merge pull request #1561 from lightpanda-io/wp/mrdimidium/drop-logs-interceptors
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Remove logs interceptors feature
2026-02-16 17:45:58 +00:00
Pierre Tachoire
c3811d3a14 Merge pull request #1559 from lightpanda-io/handle_undefined_symbol
handle undefined symbol in debug log
2026-02-16 18:32:50 +01:00
Pierre Tachoire
f20d6b551d Merge pull request #1558 from lightpanda-io/empty_array_buffer
don't try to create empty backing store data
2026-02-16 18:31:51 +01:00
Pierre Tachoire
311bcadacb create HTML page error on page error callback. 2026-02-16 18:17:07 +01:00
Pierre Tachoire
2189c8cd82 dispatch .page_navigated event on page error callback
When a CDP client navigates to a page and the page generates an error,
it blocks waiting for the .page_navigated event.

It currently happens w/ robots.txt denied page.
Example: https://httpbin.io/deny
2026-02-16 17:46:37 +01:00
Nikolay Govorov
6553bb8147 Remove los interceptors feature 2026-02-16 15:48:18 +00:00
Adrià Arrufat
dea492fd64 Unify dump flags into --dump <format> 2026-02-17 00:42:06 +09:00
Karl Seguin
00ab7f04fa handle undefined symbol in debug log 2026-02-16 23:41:04 +08:00
Adrià Arrufat
d3ba714aba Rename --dump flag to --html
--dump is still supported but deprecated
2026-02-17 00:35:15 +09:00
Adrià Arrufat
748b37f1d6 Rename --dump-markdown to --markdown 2026-02-17 00:21:10 +09:00
Karl Seguin
b83b188aff don't try to create empty backing store data 2026-02-16 23:18:25 +08:00
Karl Seguin
cfefa32603 Merge pull request #1555 from lightpanda-io/link_crossorigin
add link crossOrigin accessor
2026-02-16 22:56:50 +08:00
Karl Seguin
85d8db3ef9 Merge pull request #1554 from lightpanda-io/crypto_digest
Add SubtleCrypto.digest (used in nytimes)
2026-02-16 22:56:34 +08:00
Adrià Arrufat
3c14dbe382 Trim trailing whitespace in pre blocks in Markdown 2026-02-16 21:34:46 +09:00
Adrià Arrufat
b49b2af11f Stop escaping periods in markdown 2026-02-16 21:29:57 +09:00
Adrià Arrufat
425a36aa51 Add support for table rendering in Markdown 2026-02-16 21:24:24 +09:00
Adrià Arrufat
ec0b9de713 Add support for ordered lists in Markdown 2026-02-16 21:04:06 +09:00
Adrià Arrufat
9f13b14f6d Add support strikethrough and task lists in Markdown 2026-02-16 20:55:51 +09:00
Karl Seguin
01e83b45b5 Add internal field caching (for window.document and window.console)
This expands the caching capabilities which were first added in
https://github.com/lightpanda-io/browser/pull/1552

Internal field caching requires up-front memory, but is faster. It is currently
enabled for window.document and window.console - two very frequently accessed
values.

Implementations must correctly provide an internal field index, with
consideration for index 0 which may or may not be reserved for the type (it
depends on the type). comptime checks run to make sure this is correct, but it
would probably be nice to at least let them be declared in any order.

This commit also removes the special handling for loading the window. This used
to rely on the window not having any internal fields, but it now has them for
caching so it can't be detected that way. Instead, the window is loaded like any
other object. (But now we have to special case the initial window TAO creation
to make it behave like any other Zig instance).
2026-02-16 19:16:39 +08:00
Halil Durak
f80566e0cb ImageData: add a type entry in bridge.zig 2026-02-16 11:21:33 +03:00
Karl Seguin
42afacf0af add link crossOrigin accessor 2026-02-16 08:32:42 +08:00
Karl Seguin
2e61e7e682 Add SubtleCrypto.digest (used in nytimes) 2026-02-16 08:10:06 +08:00
Karl Seguin
3de9267ea7 Merge pull request #1552 from lightpanda-io/v8_private_cache
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add v8 private object cache + Node.childNodes caching
2026-02-16 06:58:14 +08:00
Karl Seguin
8c99d4fcd2 Merge pull request #1547 from lightpanda-io/more_events
add FocusEvent, TextEvent and WheelEvent
2026-02-16 06:58:03 +08:00
Adrià Arrufat
be4e6e5ba5 Escape special characters and handle whitespace in Markdown 2026-02-16 00:10:00 +09:00
Adrià Arrufat
1b5efea6eb Add --dump-markdown flag
Add a new module to handle HTML-to-Markdown conversion and
integrate it into the fetch command via a new CLI flag.
2026-02-15 23:18:01 +09:00
Halil Durak
6554f80fad ImageData: constructor can throw DOM exceptions 2026-02-15 16:59:02 +03:00
Halil Durak
2e8a9f809e ImageData: add test 2026-02-15 16:52:50 +03:00
Halil Durak
dc66032720 ImageData: initial support
We're missing fancy HDR range currently, though, most websites stick to sRGB.
2026-02-15 16:52:35 +03:00
Karl Seguin
c9433782d8 Merge pull request #1551 from lightpanda-io/nikneym/u8-clamped-array
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
`simpleZigValueToJs`: support `Uint8ClampedArray`
2026-02-15 19:55:11 +08:00
Karl Seguin
fef5586ff5 add FocusEvent, TextEvent and WheelEvent 2026-02-15 19:45:00 +08:00
Pierre Tachoire
1f4a2fd654 Merge pull request #1549 from lightpanda-io/script_header_assertion
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add more granular assertions
2026-02-15 11:52:26 +01:00
Pierre Tachoire
8243385af6 Merge pull request #1546 from lightpanda-io/document_createEvent
add more types to document.createEvent
2026-02-15 11:51:13 +01:00
Halil Durak
26ce9b2d4a Uint8ClampedArray: implement .copy variant 2026-02-15 12:12:45 +03:00
Halil Durak
119f3169e2 Uint8ClampedArray: update comment + change enum literals
I feel `ref` and `copy` is on-point on what's being actually happening.
2026-02-15 12:11:44 +03:00
Karl Seguin
16bd22ee01 improve comments 2026-02-15 17:05:37 +08:00
Karl Seguin
f4a5f73ab2 Add v8 private object cache + Node.childNodes caching
Very simply, this PR ensures that:

  div.childNodes === div.childNodes

Previously, each invocation of childNodes would return a distinct object. Not
just inefficient, but incorrect.

Where this gets more complicated is the how.

The simple way to do this would be to have an optional `_child_nodes` field in
Node. When it's called the first time, we load and return it and, on subsequent
calls we can return it from the field directly.

But we generally avoid this pattern for data that we don't expect to be called
often relative to the number of instances. A page with 20K nodes _might_ see
.childNodes called on 1% of those, so storing a pointer in Nodes which isn't
going to be used isn't particularly memory efficient.

Instead, we have (historically) opted to store this in a page-level map/lookup.
This is used extensively for various element properties, e.g. the page
_element_class_lists lookup.

I recently abandoned work on v8 property caching
(https://github.com/lightpanda-io/browser/pull/1511). But then I looked into the
performance on a specific website with _a lot_ of DOMRect creation and I started
to think about both caching and pure-v8 DOM objects. So this PR became a
two-birds with one stone kind of deal. It re-introduces caching as a means to
solve the childNodes correctness. This uses 1 specific type of caching mechanism,
hooking into a v8::object's Private data map, but the code should be easily
extendable to support a faster (but less memory efficient, depending on the use
case) option: internal fields.
2026-02-15 11:34:01 +08:00
Karl Seguin
e61a4564ea Merge pull request #1545 from lightpanda-io/node_list_index
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
NodeList index fix + Node.childNode fix
2026-02-15 09:32:28 +08:00
Karl Seguin
e72edee1f2 Merge pull request #1548 from lightpanda-io/HTMLDListElement
Add HTMLDListElement
2026-02-15 07:45:38 +08:00
Karl Seguin
e8c150fcac Merge pull request #1544 from lightpanda-io/caller_function
Re-arrange Caller
2026-02-15 07:45:25 +08:00
Halil Durak
52418932b1 simpleZigValueToJs: support Uint8ClampedArray
Needed this to implement `ImageData#data` getter. This works differently than other typed arrays since returned object can be mutated from both Zig and JS ends.
2026-02-15 02:03:11 +03:00
Karl Seguin
4f81cb9333 Add more granular assertions
Trying to see how the "ScriptManager.Header buffer" assertion is failing. Either
`headerCallback` is being called multiple times, or the script is corrupt. By
adding a similar assertion in various places, we can hopefully narrow (a) what's
going on and (b) what code is involved.

Also, switched the BufferPool from DoublyLinkedList to SinglyLinkedList. Was
just reviewing this code (to see if the buffer could possibly become corrupt)
and realized this could be switched.
2026-02-14 20:01:09 +08:00
Karl Seguin
db46f47b96 Add HTMLDListElement
Fix names for ol and ul elements
2026-02-14 17:11:29 +08:00
Karl Seguin
edfe5594ba add more types to document.createEvent 2026-02-14 15:09:46 +08:00
Karl Seguin
f25e972594 NodeList index fix + Node.childNode fix
An index out of range request to a nodelist , e.g. childNodes[1000] now properly
returns a error.NotHandled error, which is given to v8 as a non-intercepted
property.

When a ChildNode node list is created from Node.childNode, we store the *Node
rather than its children. ChildNode is meant to be live, so if the node's
children changes, we should capture that.
2026-02-14 14:51:33 +08:00
Karl Seguin
d5488bdd42 Re-arrange Caller
This is preparatory work for re-introducing property caching and pure v8 WebAPIs

It does 3 things:

1 - It removes the duplication of method calling we had in Accessors and
    Functions type briges.

2 - It flattens the method-call chain. It used to be some code in bridge, then
    method, then _method. Most of the code is now in the same place. This will
    be important since caching requires the raw js_value, which we previously
    didn't expose from _method. Now, it's just there.

3 - Caller used to do everything. Then we introduced Local and a lot of Caller
    methods didn't need caller itself, they just needed &self.local. While those
    methods remain in Caller.zig, they now take a *const Local directly and thus
    can be called without Caller, making them usable without a Caller.
2026-02-14 14:22:46 +08:00
Karl Seguin
bbff64bc96 Merge pull request #1543 from lightpanda-io/zig_fmt
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
zig fmt
2026-02-14 14:20:43 +08:00
Karl Seguin
635afefdeb Merge pull request #1540 from lightpanda-io/tao_v8_storage
Optimize tao storage in v8 object.
2026-02-14 14:14:51 +08:00
Karl Seguin
fd3e67a0b4 zig fmt 2026-02-14 14:06:25 +08:00
Karl Seguin
729a6021ee update v8 dep 2026-02-14 14:06:03 +08:00
Karl Seguin
309f254c2c Optimize toa storage in v8 object.
We're currently using Get/SetInternalField to store our toa instance in v8. This
appears to be meant for v8 data itself, as it participates in the GC's
referencing counting. This is a bit obvious by the fact that it expects a
v8::Data, we we're able to do by wrapping our toa into a v8::External.

The Get/SetAlignedPointerFromInternalField seem specifically designed for toa,
as it takes a (void *) (thus, not requiring the external wrapper) and, from what
I understand is more efficient (presumably because the GC ignores it).

Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/149
2026-02-14 14:04:25 +08:00
Karl Seguin
5c37f04d64 Merge pull request #1539 from lightpanda-io/range_set_validation
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add missing validation to range setStart and setEnd
2026-02-14 07:03:27 +08:00
Karl Seguin
7c3dd8e852 Merge pull request #1538 from lightpanda-io/browser_arenas
Remove custom-arenas, use ArenaPool instead
2026-02-14 07:03:14 +08:00
Karl Seguin
66ddedbaf3 Merge pull request #1537 from lightpanda-io/input_click
Default behavior for input click (radio / checkbox).
2026-02-14 07:02:05 +08:00
Karl Seguin
7981b17897 Merge pull request #1541 from lightpanda-io/range_compare_boundary_points
Fix Range.compareBoundaryPoint
2026-02-14 07:01:52 +08:00
Pierre Tachoire
62137d47c8 Merge pull request #1533 from lightpanda-io/element_positions
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Add various element position properties
2026-02-13 15:30:21 +01:00
Karl Seguin
e3b5437f61 fix test to match swapped (correct) implementation 2026-02-13 16:28:53 +08:00
Karl Seguin
934693924e Fix Range.compareBoundaryPoint
START_TO_END and END_TO_START had their logic swapped. This fixes the remaining
862 failing cases in dom/ranges/Range-compareBoundaryPoints.html
2026-02-13 15:06:17 +08:00
Muki Kiboigo
308fd92a46 chunking robot store hashing 2026-02-12 22:55:40 -08:00
Karl Seguin
da1eb71ad0 Add missing validation to Range.comparePoint
Also re-order the validation to match what  WPT expects. Fixes all cases in
dom/ranges/Range-comparePoint.html
2026-02-13 14:54:43 +08:00
Muki Kiboigo
576dbb7ce6 switch to iterative match solving 2026-02-12 22:54:35 -08:00
Muki Kiboigo
d0c381b3df partial compilation of robot rules 2026-02-12 22:32:29 -08:00
Muki Kiboigo
55178a81c6 skip empty disallow rules during robots parsing 2026-02-12 22:08:16 -08:00
Muki Kiboigo
249308380b no more longest match tracking in robots 2026-02-12 22:07:04 -08:00
Karl Seguin
d91bec08c3 Add missing validation to range setStart and setEnd
Fixes the remaining failing cases (400!) of WPT dom/ranges/Range-set.html
2026-02-13 13:47:26 +08:00
Karl Seguin
e23ef4b0be Remove custom-arenas, use ArenaPool instead
This removes the browser-specific arenas (session, transfer, page, call) in
favor of the arena pool.

This is a bit of a win-lose commit. It exists as (the last?) step before I can
really start working on frames. Frames will require their own "page" and "call"
arenas, so there isn't just 1 per browser now, but rather N, where N is the
number of frames + 1 page. This change was already done for Contexts when
ExecutionWorld was removed, and the idea is the same: making these units more
self contained so to support cases where we break out of the "1" model we
currently have (1 browser, 1 session, 1 page, 1 context, ...).

But it's a bit of a step backwards because the ArenaPool is dumb and just resets
everything to a single hard-coded (for now) value: 16KB. But in my mind, an
arena that's used for 1 thing (e.g. the page or call arenas) is more likely to
be well-sized for that specific role in the future, even on a different
page/navigate.

I think ultimately, we'll move to an ArenaPool that has different levels, e.g.
acquire() and acquireLarge() which can reset to different sizes, so that a page
arena can use acquireLarge() and retain a larger amount of memory between use.
2026-02-13 12:34:27 +08:00
Karl Seguin
6037521c49 Default behavior for input click (radio / checkbox).
This wasn't 100% intuitive to me. At the start of the event, the input is
immediately toggled. But at any point during dispatching, the default behavior
can be suppressed. So the state of the input's check during dispatching captures
the "intent" of the click. But it's possible for one listener to see that
input.checked == true even though, by the end of dispatching, input.checked ==
false because some other listener called preventDefault().

To support this, we need to capture the "current" state so that, if we need to
rollback, we can. For radio buttons, this "current" state includes capturing
the currently checked radio (if any).
2026-02-13 11:06:46 +08:00
Pierre Tachoire
a27fac3677 Merge pull request #1535 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
ci: fix path
2026-02-12 16:04:40 +01:00
Pierre Tachoire
21f2eb664e ci: fix path 2026-02-12 15:44:28 +01:00
Pierre Tachoire
81546ef4b0 Merge pull request #1534 from lightpanda-io/wp/mrdimidium/ram-e2e-tests 2026-02-12 13:32:15 +01:00
Karl Seguin
4b90c8fd45 Add various element position properties
clientTop, clientLeft, scrollTop, scrollLeft, scrollHeight, scrollWidth,
offsetTop, offsetLeft, offsetWidth, offsetHeight.

These are all dummy implementation that hook, as much as possible, into what
layout information we have.

Explicitly set scroll information is stored on the page.
2026-02-12 19:30:29 +08:00
Pierre Tachoire
c643fb8aac ci: fix var name 2026-02-12 12:07:15 +01:00
Pierre Tachoire
0cae6ceca3 Merge pull request #1510 from lightpanda-io/wp/mrdimidium/ram-e2e-tests
Use cgroups for RAM mesurement
2026-02-12 11:55:02 +01:00
Karl Seguin
5cde59b53c Merge pull request #1528 from lightpanda-io/remove_recurise_curl_calls
Remove potential recursive abort call in curl
2026-02-12 18:42:09 +08:00
Pierre Tachoire
7df67630af ci: add cg_mem_peak into the tracked results 2026-02-12 11:33:52 +01:00
Karl Seguin
0c89dca261 When _not_ in a libcurl callback, deinit the transfer normally 2026-02-12 18:29:35 +08:00
Pierre Tachoire
6b953b8793 ci: keep both vm and cg memory regression tests 2026-02-12 11:24:59 +01:00
Karl Seguin
0d1defcf27 Merge pull request #1522 from lightpanda-io/remove_page_reset
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove Page.reset
2026-02-12 17:47:13 +08:00
Pierre Tachoire
c1db9c19b3 Merge pull request #1532 from lightpanda-io/document_scrollingElement
Add document.scrollingElement read-only property
2026-02-12 09:49:16 +01:00
Pierre Tachoire
95487755ed Merge pull request #1531 from lightpanda-io/dom_token_list_compat
Improve compliance of DOMTokenList
2026-02-12 09:49:09 +01:00
Pierre Tachoire
4813469659 Merge pull request #1530 from lightpanda-io/node_contains_null
Allow node.contains(null) (false), per spec
2026-02-12 09:48:14 +01:00
Karl Seguin
4dfd357c0b Merge pull request #1529 from lightpanda-io/get_elements_by_tag_name_ns
Get elements by tag name ns
2026-02-12 16:41:56 +08:00
Karl Seguin
4ca0486518 Add document.scrollingElement read-only property
From MDN: In standards mode, this is the root element of the document,
document.documentElement.

Easy enough.
2026-02-12 14:10:38 +08:00
Karl Seguin
b139c05960 Improve compliance of DOMTokenList
1 - Make element.classList settable
2 - On replace, validate in expected order
3 - On replace, fire mutation observer even if new == old
4 - On replace, handle duplicate values
2026-02-12 14:07:31 +08:00
Karl Seguin
3d32759030 Allow node.contains(null) (false), per spec 2026-02-12 12:39:07 +08:00
Karl Seguin
badfe39a3d Refactor common Document and Element methods into Node 2026-02-12 12:31:05 +08:00
Karl Seguin
060e2db351 Add getElementsByTagNameNS
Do what we can based on the ns and names that we currently have.

Also also, allow element names with non-ascii characters.

both changes are driven by WPT dom/nodes/case.html
2026-02-12 12:25:21 +08:00
Karl Seguin
ed802c0404 Remove potential recursive abort call in curl
Curl doesn't like recursive calls. For example, you can't call
curl_multi_remove_handle from within a dataCallback.

This specifically means that, as-is, transfer.abort() calls aren't safe to be
called during a libcurl callback. Consider this code:

```
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
  req.abort();
}
req.send();
```

onreadystatechange is triggered by network events, i.e. it executes in libcurl
callback. Thus, the above code fails to truly "abort" the request with
`curl_multi_remove_handle` error, saying it's a recursive call.

To solve this, transfer.abort() now sets an `aborted = true` flag. Callbacks can
now use this flag to signal to libcurl to stop the transfer.

A test was added which reproduced this issue, but this comes from:
https://github.com/lightpanda-io/browser/issues/1527  which I wasn't able to
reliably reproduce. I did see it happen regularly, just not always. It seems
like this commit fixes that issue.
2026-02-12 11:29:47 +08:00
Karl Seguin
5d8739bfb2 Merge pull request #1524 from lightpanda-io/trigger_inline_handlers
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
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
Trigger inline handlers
2026-02-12 07:51:55 +08:00
Karl Seguin
086faf44fc Trigger inline handlers
This is a follow up / fix to https://github.com/lightpanda-io/browser/pull/1487

In that PR we triggered a "load" event for special elements, and as part of that
we triggered both the "onload" attribute via dispatchWithFunction and normal
bubbling with dispatch.

This PR applies this change generically and holistically. For example, if an
"abort" event is raised, the "onabort" attribute will be generated for that
element. Importantly, this gets executed in the correct dispatch order and
respect event cancellation (stopPropagation and stopImmediatePropagation).
2026-02-12 07:38:12 +08:00
Karl Seguin
e5eaa90c61 Merge pull request #1523 from lightpanda-io/query_selector_edge
Support a few more selector edge cases
2026-02-12 07:37:36 +08:00
Karl Seguin
b24807ea29 Merge pull request #1525 from lightpanda-io/global_iterability
Ability to remove a type from the global's iterable list.
2026-02-12 07:36:25 +08:00
Karl Seguin
d68bae9bc2 Merge pull request #1526 from lightpanda-io/node_properties
Passes all test for WPT dom/nodes/Node-properties.htm
2026-02-12 07:36:13 +08:00
Halil Durak
b891fb4502 Merge pull request #1486 from lightpanda-io/nikneym/hash-map-change
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
Change hash generation of global event handlers
2026-02-11 16:55:07 +03:00
Halil Durak
ea69b3b4e3 wrap assertions with comptime if 2026-02-11 16:45:31 +03:00
Karl Seguin
23c8616ba5 Passes all test for WPT dom/nodes/Node-properties.htm
Documents created via DOMImplementation should not inherit the page.URL.

Add textContent for DocumentFragment

Fix creteElement for XML namespace when not explicitly specified
2026-02-11 21:20:16 +08:00
Karl Seguin
b25c91affd Fix a few more cases
From dom/nodes/ParentNode-querySelector-escapes.html
2026-02-11 21:11:27 +08:00
Karl Seguin
151cefe0ec Ability to remove a type from the global's iterable list.
Some types, like AbortController, shouldn't be iterable on the window. This
commit (a) adds the ability to control this in the snapshot, and sets the
iterability based on the dom/interface-objects.html wpt test list.
2026-02-11 20:53:13 +08:00
Karl Seguin
3412ff94bc Support a few more selector edge cases
Trailing escape sequence (https://github.com/lightpanda-io/browser/issues/1515)
and tags started with non-ascii letters.
2026-02-11 14:37:20 +08:00
Karl Seguin
14112ed294 Remove Page.reset
Page.reset exists for 1 use case: multiple calls to the Page.navigate CDP
method. At an extreme, something like this in puppeteer:

```
await page.goto(baseURL + '/campfire-commerce/');
await page.goto(baseURL + '/campfire-commerce/');
```

Rather than handling this generically in Page, we now handle this case
specifically at the CDP layer. If the page isn't in its initial load state,
i.e. page._load_state != .waiting, then we reload the page from the session.

For reloading, my initial inclination was to do session.removePage then
session.createPage(). This behavior still seems potentially correct to me, but
compared to our `reset`, this would trigger extra notifications, namely:

self.notification.dispatch(.page_remove, .{});

and

self.notification.dispatch(.page_created, page);

Bacause of https://github.com/lightpanda-io/browser/pull/1265/ I guess that
could have side effects. So, to keep the behavior as close to the current
"reset", a new `session.replacePage()` has been added which behaves a lot like
removePage + createPage, but without the notifications being sent.

While I generally think this is just cleaner, this was largely driven by some
planning for frame support. The entity for a Frame will share a lot with the
Page (we'll extract that logic), so simplifying the Page, especially around
initialization, helps simplify frame support.
2026-02-11 13:53:49 +08:00
Pierre Tachoire
3e1909b645 ci: use cgroups with user's permissions 2026-02-10 18:43:31 +01:00
Nikolay Govorov
a4b1fbd6ee Use cgroups for RAM mesurement 2026-02-10 01:34:17 +00:00
Halil Durak
6d2ef9be5d change hash generation of global event handlers
Small change inspired from #1475.
2026-02-06 22:35:35 +03:00
286 changed files with 18185 additions and 6733 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8: zig-v8:
description: 'zig v8 version to install' description: 'zig v8 version to install'
required: false required: false
default: 'v0.2.8' default: 'v0.3.1'
v8: v8:
description: 'v8 version to install' description: 'v8 version to install'
required: false required: false

View File

@@ -63,6 +63,6 @@ jobs:
- name: run end to end integration tests - name: run end to end integration tests
run: | run: |
./lightpanda serve & echo $! > LPD.pid ./lightpanda serve --log_level error & echo $! > LPD.pid
go run integration/main.go go run integration/main.go
kill `cat LPD.pid` kill `cat LPD.pid`

View File

@@ -122,10 +122,19 @@ jobs:
needs: zig-build-release needs: zig-build-release
env: env:
MAX_MEMORY: 26000 MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17 MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools
# $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
# $ sudo mkdir -p /sys/fs/cgroup/actions-runner
# $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
CG_ROOT: /sys/fs/cgroup
CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
# use a self host runner. # use a self host runner.
runs-on: lpd-bench-hetzner runs-on: lpd-bench-hetzner
timeout-minutes: 15 timeout-minutes: 15
@@ -150,22 +159,53 @@ jobs:
go run ws/main.go & echo $! > WS.pid go run ws/main.go & echo $! > WS.pid
sleep 2 sleep 2
- name: run lightpanda in cgroup
run: |
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing"
exit 1
fi
mkdir -p $CG_ROOT/$CG
cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid
sleep 2
- name: run puppeteer - name: run puppeteer
run: | run: |
./lightpanda serve & echo $! > LPD.pid
sleep 2
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1 RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
kill `cat LPD.pid` kill `cat LPD.pid`
PID=$(cat LPD.pid)
while kill -0 $PID 2>/dev/null; do
sleep 1
done
if [ ! -f $CG_ROOT/$CG/memory.peak ]; then
echo "memory.peak not available in $CG"
exit 1
fi
cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak
- name: puppeteer result - name: puppeteer result
run: cat puppeteer.out run: cat puppeteer.out
- name: memory regression - name: cgroup memory regression
run: |
PEAK_BYTES=$(cat LPD.cg_mem_peak)
PEAK_KB=$((PEAK_BYTES / 1024))
echo "memory.peak_bytes=$PEAK_BYTES"
echo "memory.peak_kb=$PEAK_KB"
test "$PEAK_KB" -le "$MAX_CG_PEAK"
- name: virtual memory regression
run: | run: |
export LPD_VmHWM=`cat LPD.VmHWM` export LPD_VmHWM=`cat LPD.VmHWM`
echo "Peak resident set size: $LPD_VmHWM" echo "Peak resident set size: $LPD_VmHWM"
test "$LPD_VmHWM" -le "$MAX_MEMORY" test "$LPD_VmHWM" -le "$MAX_VmHWM"
- name: cleanup cgroup
run: rmdir $CG_ROOT/$CG
- name: duration regression - name: duration regression
run: | run: |
@@ -178,7 +218,8 @@ jobs:
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'` export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'` export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'`
export LPD_VmHWM=`cat LPD.VmHWM` export LPD_VmHWM=`cat LPD.VmHWM`
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM}}" > bench.json export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 ))
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json
cat bench.json cat bench.json
- name: run hyperfine - name: run hyperfine

View File

@@ -15,11 +15,11 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
wpt: wpt-build-release:
name: web platform tests json output name: zig build release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -30,11 +30,85 @@ jobs:
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: build wpt - name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -- version 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
with:
name: lightpanda-build-release
path: |
zig-out/bin/lightpanda
retention-days: 1
wpt-build-runner:
name: build wpt runner
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptrunner
CGO_ENABLED=0 go build
- name: upload artifact
uses: actions/upload-artifact@v4
with:
name: wptrunner
path: |
wptrunner/wptrunner
retention-days: 1
run-wpt:
name: web platform tests json output
needs:
- wpt-build-release
- wpt-build-runner
# use a self host runner.
runs-on: lpd-bench-hetzner
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
with:
ref: fork
repository: 'lightpanda-io/wpt'
fetch-depth: 0
# The hosts are configured manually on the self host runner.
# - name: create custom hosts
# run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
- name: generate manifest
run: ./wpt manifest
- name: download lightpanda release
uses: actions/download-artifact@v4
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
uses: actions/download-artifact@v4
with:
name: wptrunner
- run: chmod a+x ./wptrunner
- name: run test with json output - name: run test with json output
run: zig-out/bin/lightpanda-wpt --json > wpt.json run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 1 > wpt.json
kill `cat WPT.pid`
- name: write commit - name: write commit
run: | run: |
@@ -51,7 +125,7 @@ jobs:
perf-fmt: perf-fmt:
name: perf-fmt name: perf-fmt
needs: wpt needs: run-wpt
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15

5
.gitignore vendored
View File

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

15
.gitmodules vendored
View File

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

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12 ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4 ARG V8=14.0.365.4
ARG ZIG_V8=v0.2.8 ARG ZIG_V8=v0.3.1
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update -yq && \ RUN apt-get update -yq && \

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands # $(ZIG) commands
# ------------ # ------------
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end .PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
## Build v8 snapshot ## Build v8 snapshot
build-v8-snapshot: build-v8-snapshot:
@@ -57,7 +57,7 @@ build-v8-snapshot:
## Build in release-fast mode ## Build in release-fast mode
build: build-v8-snapshot build: build-v8-snapshot
@printf "\033[36mBuilding (release safe)...\033[0m\n" @printf "\033[36mBuilding (release fast)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n" @printf "\033[33mBuild OK\033[0m\n"
@@ -82,15 +82,6 @@ shell:
@printf "\033[36mBuilding shell...\033[0m\n" @printf "\033[36mBuilding shell...\033[0m\n"
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Run WPT tests
wpt:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
wpt-summary:
@printf "\033[36mBuilding wpt...\033[0m\n"
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
## Test - `grep` is used to filter out the huge compile command on build ## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos) ifeq ($(OS), macos)
test: test:
@@ -111,13 +102,8 @@ end2end:
# ------------ # ------------
.PHONY: install .PHONY: install
## Install and build dependencies for release install: build
install: install-submodule
data: data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
## Init and update git submodule
install-submodule:
@git submodule init && \
git submodule update

View File

@@ -220,18 +220,6 @@ For **MacOS**, you need cmake and [Rust](https://rust-lang.org/tools/install/).
brew install cmake brew install cmake
``` ```
### Install Git submodules
The project uses git submodules for dependencies.
To init or update the submodules in the `vendor/` directory:
```
make install-submodule
```
This is an alias for `git submodule init && git submodule update`.
### Build and run ### Build and run
You an build the entire browser with `make build` or `make build-dev` for debug You an build the entire browser with `make build` or `make build-dev` for debug
@@ -281,35 +269,75 @@ make end2end
Lightpanda is tested against the standardized [Web Platform Lightpanda is tested against the standardized [Web Platform
Tests](https://web-platform-tests.org/). Tests](https://web-platform-tests.org/).
The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command. 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).
All the tests cases executed are located in the `tests/wpt` sub-directory.
For reference, you can easily execute a WPT test case with your browser via For reference, you can easily execute a WPT test case with your browser via
[wpt.live](https://wpt.live). [wpt.live](https://wpt.live).
#### 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`
```
./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 #### Run WPT test suite
To run all the tests: 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.
First start the WPT's HTTP server from your `wpt/` clone dir.
```
./wpt serve
```
Run a Lightpanda browser
``` ```
make wpt zig build run -- --insecure_disable_tls_host_verification
```
Then you can start the wptrunner from the Demo's clone dir:
```
cd wptrunner && go run .
``` ```
Or one specific test: Or one specific test:
``` ```
make wpt Node-childNodes.html cd wptrunner && go run . Node-childNodes.html
``` ```
#### Add a new WPT test case `wptrunner` command accepts `--summary` and `--json` options modifying output.
Also `--concurrency` define the concurrency limit.
We add new relevant tests cases files when we implemented changes in Lightpanda. :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.
To add a new test, copy the file you want from the [WPT ```
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory. zig build -Doptimize=ReleaseFast run
```
:warning: Please keep the original directory tree structure of `tests/wpt`.
## Contributing ## Contributing

1055
build.zig

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -66,7 +66,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
app.telemetry = try Telemetry.init(app, config.mode); app.telemetry = try Telemetry.init(app, config.mode);
errdefer app.telemetry.deinit(); errdefer app.telemetry.deinit();
app.arena_pool = ArenaPool.init(allocator); app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
errdefer app.arena_pool.deinit(); errdefer app.arena_pool.deinit();
return app; return app;

View File

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

View File

@@ -28,8 +28,16 @@ pub const RunMode = enum {
fetch, fetch,
serve, serve,
version, version,
mcp,
}; };
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
// max message size
// +14 for max websocket payload overhead
// +140 for the max control packet that might be interleaved in a message
pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
mode: Mode, mode: Mode,
exec_name: []const u8, exec_name: []const u8,
http_headers: HttpHeaders, http_headers: HttpHeaders,
@@ -52,56 +60,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void {
pub fn tlsVerifyHost(self: *const Config) bool { pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.tls_verify_host, inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
else => unreachable, else => unreachable,
}; };
} }
pub fn obeyRobots(self: *const Config) bool { pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.obey_robots, inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_proxy, inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
else => unreachable, else => unreachable,
}; };
} }
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.proxy_bearer_token, inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
.help, .version => null, .help, .version => null,
}; };
} }
pub fn httpMaxConcurrent(self: *const Config) u8 { pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_concurrent orelse 10, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpMaxHostOpen(self: *const Config) u8 { pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_host_open orelse 4, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpConnectTimeout(self: *const Config) u31 { pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_connect_timeout orelse 0, inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable, else => unreachable,
}; };
} }
pub fn httpTimeout(self: *const Config) u31 { pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_timeout orelse 5000, inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
else => unreachable, else => unreachable,
}; };
} }
@@ -112,62 +120,86 @@ pub fn httpMaxRedirects(_: *const Config) u8 {
pub fn httpMaxResponseSize(self: *const Config) ?usize { pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.http_max_response_size, inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
else => unreachable, else => unreachable,
}; };
} }
pub fn logLevel(self: *const Config) ?log.Level { pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_level, inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
else => unreachable, else => unreachable,
}; };
} }
pub fn logFormat(self: *const Config) ?log.Format { pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_format, inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
else => unreachable, else => unreachable,
}; };
} }
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope { pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.log_filter_scopes, inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
else => unreachable, else => unreachable,
}; };
} }
pub fn userAgentSuffix(self: *const Config) ?[]const u8 { pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch => |opts| opts.common.user_agent_suffix, inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
.help, .version => null, .help, .version => null,
}; };
} }
pub fn maxConnections(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_connections,
else => unreachable,
};
}
pub fn maxPendingConnections(self: *const Config) u31 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_pending_connections,
else => unreachable,
};
}
pub const Mode = union(RunMode) { pub const Mode = union(RunMode) {
help: bool, // false when being printed because of an error help: bool, // false when being printed because of an error
fetch: Fetch, fetch: Fetch,
serve: Serve, serve: Serve,
version: void, version: void,
mcp: Mcp,
}; };
pub const Serve = struct { pub const Serve = struct {
host: []const u8 = "127.0.0.1", host: []const u8 = "127.0.0.1",
port: u16 = 9222, port: u16 = 9222,
timeout: u31 = 10, timeout: u31 = 10,
max_connections: u16 = 16, cdp_max_connections: u16 = 16,
max_tabs_per_connection: u16 = 8, cdp_max_pending_connections: u16 = 128,
max_memory_per_tab: u64 = 512 * 1024 * 1024,
max_pending_connections: u16 = 128,
common: Common = .{}, common: Common = .{},
}; };
pub const Mcp = struct {
common: Common = .{},
};
pub const DumpFormat = enum {
html,
markdown,
wpt,
};
pub const Fetch = struct { pub const Fetch = struct {
url: [:0]const u8, url: [:0]const u8,
dump: bool = false, dump_mode: ?DumpFormat = null,
common: Common = .{}, common: Common = .{},
withbase: bool = false, with_base: bool = false,
with_frames: bool = false,
strip: dump.Opts.Strip = .{}, strip: dump.Opts.Strip = .{},
}; };
@@ -298,15 +330,16 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
const usage = const usage =
\\usage: {s} command [options] [URL] \\usage: {s} command [options] [URL]
\\ \\
\\Command can be either 'fetch', 'serve' or 'help' \\Command can be either 'fetch', 'serve', 'mcp' or 'help'
\\ \\
\\fetch command \\fetch command
\\Fetches the specified URL \\Fetches the specified URL
\\Example: {s} fetch --dump https://lightpanda.io/ \\Example: {s} fetch --dump html https://lightpanda.io/
\\ \\
\\Options: \\Options:
\\--dump Dumps document to stdout. \\--dump Dumps document to stdout.
\\ Defaults to false. \\ Argument must be 'html' or 'markdown'.
\\ Defaults to no dump.
\\ \\
\\--strip_mode Comma separated list of tag groups to remove from dump \\--strip_mode Comma separated list of tag groups to remove from dump
\\ the dump. e.g. --strip_mode js,css \\ the dump. e.g. --strip_mode js,css
@@ -317,6 +350,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ \\
\\--with_base Add a <base> tag in dump. Defaults to false. \\--with_base Add a <base> tag in dump. Defaults to false.
\\ \\
\\--with_frames Includes the contents of iframes. Defaults to false.
\\
++ common_options ++ ++ common_options ++
\\ \\
\\serve command \\serve command
@@ -333,21 +368,20 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--timeout Inactivity timeout in seconds before disconnecting clients \\--timeout Inactivity timeout in seconds before disconnecting clients
\\ Defaults to 10 (seconds). Limited to 604800 (1 week). \\ Defaults to 10 (seconds). Limited to 604800 (1 week).
\\ \\
\\--max_connections \\--cdp_max_connections
\\ Maximum number of simultaneous CDP connections. \\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16. \\ Defaults to 16.
\\ \\
\\--max_tabs Maximum number of tabs per CDP connection. \\--cdp_max_pending_connections
\\ Defaults to 8.
\\
\\--max_tab_memory
\\ Maximum memory per tab in bytes.
\\ Defaults to 536870912 (512 MB).
\\
\\--max_pending_connections
\\ Maximum pending connections in the accept queue. \\ Maximum pending connections in the accept queue.
\\ Defaults to 128. \\ Defaults to 128.
\\ \\
++ common_options ++
\\
\\mcp command
\\Starts an MCP (Model Context Protocol) server over stdio
\\Example: {s} mcp
\\
++ common_options ++ ++ common_options ++
\\ \\
\\version command \\version command
@@ -357,7 +391,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Displays this message \\Displays this message
\\ \\
; ;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name }); std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) { if (success) {
return std.process.cleanExit(); return std.process.cleanExit();
} }
@@ -392,6 +426,8 @@ pub fn parseArgs(allocator: Allocator) !Config {
return init(allocator, exec_name, .{ .help = false }) }, return init(allocator, exec_name, .{ .help = false }) },
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch .fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) }, return init(allocator, exec_name, .{ .help = false }) },
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} }, .version => .{ .version = {} },
}; };
return init(allocator, exec_name, mode); return init(allocator, exec_name, mode);
@@ -422,6 +458,10 @@ fn inferMode(opt: []const u8) ?RunMode {
return .fetch; return .fetch;
} }
if (std.mem.eql(u8, opt, "--with_frames")) {
return .fetch;
}
if (std.mem.eql(u8, opt, "--host")) { if (std.mem.eql(u8, opt, "--host")) {
return .serve; return .serve;
} }
@@ -479,53 +519,27 @@ fn parseServeArgs(
continue; continue;
} }
if (std.mem.eql(u8, "--max_connections", opt)) { if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_connections" }); log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
return error.InvalidArgument; return error.InvalidArgument;
}; };
serve.max_connections = std.fmt.parseInt(u16, str, 10) catch |err| { serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_connections", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
continue; continue;
} }
if (std.mem.eql(u8, "--max_tabs", opt)) { if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tabs" }); log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
return error.InvalidArgument; return error.InvalidArgument;
}; };
serve.max_tabs_per_connection = std.fmt.parseInt(u16, str, 10) catch |err| { serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tabs", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_pending_connections", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_tab_memory", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_tab_memory" });
return error.InvalidArgument;
};
serve.max_memory_per_tab = std.fmt.parseInt(u64, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_tab_memory", .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--max_pending_connections", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--max_pending_connections" });
return error.InvalidArgument;
};
serve.max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--max_pending_connections", .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
continue; continue;
@@ -542,19 +556,48 @@ fn parseServeArgs(
return serve; return serve;
} }
fn parseMcpArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Mcp {
var mcp: Mcp = .{};
while (args.next()) |opt| {
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
continue;
}
log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt });
return error.UnkownOption;
}
return mcp;
}
fn parseFetchArgs( fn parseFetchArgs(
allocator: Allocator, allocator: Allocator,
args: *std.process.ArgIterator, args: *std.process.ArgIterator,
) !Fetch { ) !Fetch {
var fetch_dump: bool = false; var dump_mode: ?DumpFormat = null;
var withbase: bool = false; var with_base: bool = false;
var with_frames: bool = false;
var url: ?[:0]const u8 = null; var url: ?[:0]const u8 = null;
var common: Common = .{}; var common: Common = .{};
var strip: dump.Opts.Strip = .{}; var strip: dump.Opts.Strip = .{};
while (args.next()) |opt| { while (args.next()) |opt| {
if (std.mem.eql(u8, "--dump", opt)) { if (std.mem.eql(u8, "--dump", opt)) {
fetch_dump = true; var peek_args = args.*;
if (peek_args.next()) |next_arg| {
if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| {
dump_mode = mode;
_ = args.next();
} else {
dump_mode = .html;
}
} else {
dump_mode = .html;
}
continue; continue;
} }
@@ -568,7 +611,12 @@ fn parseFetchArgs(
} }
if (std.mem.eql(u8, "--with_base", opt)) { if (std.mem.eql(u8, "--with_base", opt)) {
withbase = true; with_base = true;
continue;
}
if (std.mem.eql(u8, "--with_frames", opt)) {
with_frames = true;
continue; continue;
} }
@@ -621,10 +669,11 @@ fn parseFetchArgs(
return .{ return .{
.url = url.?, .url = url.?,
.dump = fetch_dump, .dump_mode = dump_mode,
.strip = strip, .strip = strip,
.common = common, .common = common,
.withbase = withbase, .with_base = with_base,
.with_frames = with_frames,
}; };
} }

1375
src/Net.zig Normal file

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,9 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig"); const js = @import("js/js.zig");
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const HttpClient = @import("../http/Client.zig");
const ArenaPool = App.ArenaPool; const ArenaPool = App.ArenaPool;
const HttpClient = App.Http.Client;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -44,13 +44,10 @@ session: ?Session,
allocator: Allocator, allocator: Allocator,
arena_pool: *ArenaPool, arena_pool: *ArenaPool,
http_client: *HttpClient, http_client: *HttpClient,
call_arena: ArenaAllocator,
page_arena: ArenaAllocator,
session_arena: ArenaAllocator,
transfer_arena: ArenaAllocator,
const InitOpts = struct { const InitOpts = struct {
env: js.Env.InitOpts = .{}, env: js.Env.InitOpts = .{},
http_client: *HttpClient,
}; };
pub fn init(app: *App, opts: InitOpts) !Browser { pub fn init(app: *App, opts: InitOpts) !Browser {
@@ -65,21 +62,13 @@ pub fn init(app: *App, opts: InitOpts) !Browser {
.session = null, .session = null,
.allocator = allocator, .allocator = allocator,
.arena_pool = &app.arena_pool, .arena_pool = &app.arena_pool,
.http_client = app.http.client, .http_client = opts.http_client,
.call_arena = ArenaAllocator.init(allocator),
.page_arena = ArenaAllocator.init(allocator),
.session_arena = ArenaAllocator.init(allocator),
.transfer_arena = ArenaAllocator.init(allocator),
}; };
} }
pub fn deinit(self: *Browser) void { pub fn deinit(self: *Browser) void {
self.closeSession(); self.closeSession();
self.env.deinit(); self.env.deinit();
self.call_arena.deinit();
self.page_arena.deinit();
self.session_arena.deinit();
self.transfer_arena.deinit();
} }
pub fn newSession(self: *Browser, notification: *Notification) !*Session { pub fn newSession(self: *Browser, notification: *Notification) !*Session {
@@ -94,24 +83,33 @@ pub fn closeSession(self: *Browser) void {
if (self.session) |*session| { if (self.session) |*session| {
session.deinit(); session.deinit();
self.session = null; self.session = null;
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.env.memoryPressureNotification(.critical); self.env.memoryPressureNotification(.critical);
} }
} }
pub fn runMicrotasks(self: *const Browser) void { pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks(); self.env.runMicrotasks();
} }
pub fn runMacrotasks(self: *Browser) !?u64 { pub fn runMacrotasks(self: *Browser) !?u64 {
return try self.env.runMacrotasks(); const env = &self.env;
const time_to_next = try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
return time_to_next;
} }
pub fn runMessageLoop(self: *const Browser) void { pub fn hasBackgroundTasks(self: *Browser) bool {
while (self.env.pumpMessageLoop()) { return self.env.hasBackgroundTasks();
if (comptime IS_DEBUG) { }
log.debug(.browser, "pumpMessageLoop", .{}); pub fn waitForBackgroundTasks(self: *Browser) void {
} self.env.waitForBackgroundTasks();
} }
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks(); self.env.runIdleTasks();
} }

View File

@@ -28,6 +28,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig"); const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@@ -55,7 +56,12 @@ pub const EventManager = @This();
page: *Page, page: *Page,
arena: Allocator, arena: Allocator,
// Used as an optimization in Page._documentIsComplete. If we know there are no
// 'load' listeners in the document, we can skip dispatching the per-resource
// 'load' event (e.g. amazon product page has no listener and ~350 resources)
has_dom_load_listener: bool,
listener_pool: std.heap.MemoryPool(Listener), listener_pool: std.heap.MemoryPool(Listener),
ignore_list: std.ArrayList(*Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList), list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.HashMapUnmanaged( lookup: std.HashMapUnmanaged(
EventKey, EventKey,
@@ -66,15 +72,17 @@ lookup: std.HashMapUnmanaged(
dispatch_depth: usize, dispatch_depth: usize,
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }), deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
pub fn init(page: *Page) EventManager { pub fn init(arena: Allocator, page: *Page) EventManager {
return .{ return .{
.page = page, .page = page,
.lookup = .{}, .lookup = .{},
.arena = page.arena, .arena = arena,
.list_pool = std.heap.MemoryPool(std.DoublyLinkedList).init(page.arena), .ignore_list = .{},
.listener_pool = std.heap.MemoryPool(Listener).init(page.arena), .list_pool = .init(arena),
.listener_pool = .init(arena),
.dispatch_depth = 0, .dispatch_depth = 0,
.deferred_removals = .{}, .deferred_removals = .{},
.has_dom_load_listener = false,
}; };
} }
@@ -105,6 +113,10 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
// Allocate the type string we'll use in both listener and key // Allocate the type string we'll use in both listener and key
const type_string = try String.init(self.arena, typ, .{}); const type_string = try String.init(self.arena, typ, .{});
if (type_string.eql(comptime .wrap("load")) and target._type == .node) {
self.has_dom_load_listener = true;
}
const gop = try self.lookup.getOrPut(self.arena, .{ const gop = try self.lookup.getOrPut(self.arena, .{
.type_string = type_string, .type_string = type_string,
.event_target = @intFromPtr(target), .event_target = @intFromPtr(target),
@@ -145,6 +157,11 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}; };
// append the listener to the list of listeners for this target // append the listener to the list of listeners for this target
gop.value_ptr.*.append(&listener.node); gop.value_ptr.*.append(&listener.node);
// Track load listeners for script execution ignore list
if (type_string.eql(comptime .wrap("load"))) {
try self.ignore_list.append(self.arena, listener);
}
} }
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void { pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
@@ -157,6 +174,10 @@ pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callba
} }
} }
pub fn clearIgnoreList(self: *EventManager) void {
self.ignore_list.clearRetainingCapacity();
}
// Dispatching can be recursive from the compiler's point of view, so we need to // Dispatching can be recursive from the compiler's point of view, so we need to
// give it an explicit error set so that other parts of the code can use and // give it an explicit error set so that other parts of the code can use and
// inferred error. // inferred error.
@@ -168,42 +189,31 @@ const DispatchError = error{
ExecutionError, ExecutionError,
JsException, JsException,
}; };
pub const DispatchOpts = struct {
// A "load" event triggered by a script (in ScriptManager) should not trigger
// a "load" listener added within that script. Therefore, any "load" listener
// that we add go into an ignore list until after the script finishes executing.
// The ignore list is only checked when apply_ignore == true, which is only
// set by the ScriptManager when raising the script's "load" event.
apply_ignore: bool = false,
};
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void { pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
return self.dispatchOpts(target, event, .{});
}
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer event.deinit(false, self.page);
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles }); log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
} }
event._target = target;
event._dispatch_target = target; // Store original target for composedPath()
var was_handled = false;
defer if (was_handled) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
switch (target._type) { switch (target._type) {
.node => |node| try self.dispatchNode(node, event, &was_handled), .node => |node| try self.dispatchNode(node, event, opts),
.xhr, else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
.window,
.abort_signal,
.media_query_list,
.message_port,
.text_track_cue,
.navigation,
.screen,
.screen_orientation,
.visual_viewport,
.generic,
=> {
const list = self.lookup.get(.{
.event_target = @intFromPtr(target),
.type_string = event._type_string,
}) orelse return;
try self.dispatchAll(list, target, event, &was_handled);
},
} }
} }
@@ -212,13 +222,22 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
// property is just a shortcut for calling addEventListener, but they are distinct. // property is just a shortcut for calling addEventListener, but they are distinct.
// An event set via property cannot be removed by removeEventListener. If you // An event set via property cannot be removed by removeEventListener. If you
// set both the property and add a listener, they both execute. // set both the property and add a listener, they both execute.
const DispatchWithFunctionOptions = struct { const DispatchDirectOptions = struct {
context: []const u8, context: []const u8,
inject_target: bool = true, inject_target: bool = true,
}; };
pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void {
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
// property handlers. No propagation - just calls the handler and registered listeners.
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const page = self.page;
event.acquireRef();
defer event.deinit(false, page);
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null }); log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
} }
if (comptime opts.inject_target) { if (comptime opts.inject_target) {
@@ -227,14 +246,15 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
} }
var was_dispatched = false; var was_dispatched = false;
defer if (was_dispatched) {
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
if (function_) |func| { var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer {
ls.local.runMicrotasks();
ls.deinit();
}
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target; event._current_target = target;
if (func.callWithThis(void, target, .{event})) { if (func.callWithThis(void, target, .{event})) {
was_dispatched = true; was_dispatched = true;
@@ -244,121 +264,15 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
} }
} }
// listeners reigstered via addEventListener
const list = self.lookup.get(.{ const list = self.lookup.get(.{
.event_target = @intFromPtr(target), .event_target = @intFromPtr(target),
.type_string = event._type_string, .type_string = event._type_string,
}) orelse return; }) orelse return;
try self.dispatchAll(list, target, event, &was_dispatched);
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void { // This is a slightly simplified version of what you'll find in dispatchPhase
const ShadowRoot = @import("webapi/ShadowRoot.zig"); // It is simpler because, for direct dispatching, we know there's no ancestors
// and only the single target phase.
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
// Execute default action if not prevented
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eqlSlice("click")) {
self.page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err });
};
} else if (event._type_string.eqlSlice("keydown")) {
self.page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = err });
};
}
}
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var node: ?*Node = target;
while (node) |n| {
if (path_len >= path_buffer.len) break;
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n.is(ShadowRoot)) |shadow| {
event._needs_retargeting = true;
// If event is not composed, stop at shadow boundary
if (!event._composed) {
break;
}
// Otherwise, jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
node = n._parent;
}
// Even though the window isn't part of the DOM, events always propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) {
path_buffer[path_len] = self.page.window.asEventTarget();
path_len += 1;
}
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)
// This happens for all events, regardless of bubbling
event._event_phase = .capturing_phase;
var i: usize = path_len;
while (i > 1) {
i -= 1;
const current_target = path[i];
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, true);
if (event._stop_propagation) {
return;
}
}
}
// Phase 2: At target
event._event_phase = .at_target;
const target_et = target.asEventTarget();
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
if (event._stop_propagation) {
return;
}
}
// Phase 3: Bubbling phase (target → root, excluding target)
// This only happens if the event bubbles
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, was_handled, false);
if (event._stop_propagation) {
break;
}
}
}
}
}
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
const page = self.page;
// Track dispatch depth for deferred removal // Track dispatch depth for deferred removal
self.dispatch_depth += 1; self.dispatch_depth += 1;
@@ -392,8 +306,267 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
is_done = (listener == last_listener); is_done = (listener == last_listener);
node = n.next; node = n.next;
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
self.removeListener(list, listener);
continue;
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
was_dispatched = true;
event._current_target = target;
switch (listener.function) {
.value => |value| try ls.toLocal(value).callWithThis(void, target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try ls.local.eval(str, null);
},
.object => |obj_global| {
const obj = ls.toLocal(obj_global);
if (try obj.getFunction("handleEvent")) |handleEvent| {
try handleEvent.callWithThis(void, obj, .{event});
}
},
}
if (event._stop_immediate_propagation) {
return;
}
}
}
fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
const T = @TypeOf(handler);
const ti = @typeInfo(T);
if (ti == .null) {
return null;
}
if (ti == .optional) {
return getFunction(handler orelse return null, local);
}
return switch (T) {
js.Function => handler,
js.Function.Temp => local.toLocal(handler),
js.Function.Global => local.toLocal(handler),
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
};
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
{
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
}
const page = self.page;
var was_handled = false;
defer if (was_handled) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
ls.local.runMicrotasks();
};
const activation_state = ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
// Handle checkbox/radio activation rollback or commit
if (activation_state) |state| {
state.restore(event, page);
}
// Execute default action if not prevented
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eql(comptime .wrap("click"))) {
page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err });
};
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = err });
};
}
}
var path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var node: ?*Node = target;
while (node) |n| {
if (path_len >= path_buffer.len) break;
path_buffer[path_len] = n.asEventTarget();
path_len += 1;
// Check if this node is a shadow root
if (n.is(ShadowRoot)) |shadow| {
event._needs_retargeting = true;
// If event is not composed, stop at shadow boundary
if (!event._composed) {
break;
}
// Otherwise, jump to the shadow host and continue
node = shadow._host.asNode();
continue;
}
node = n._parent;
}
// Even though the window isn't part of the DOM, most events propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
// The only explicit exception is "load"
if (event._type_string.eql(comptime .wrap("load")) == false) {
if (path_len < path_buffer.len) {
path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
}
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)
// This happens for all events, regardless of bubbling
event._event_phase = .capturing_phase;
var i: usize = path_len;
while (i > 1) {
i -= 1;
if (event._stop_propagation) return;
const current_target = path[i];
if (self.lookup.get(.{
.event_target = @intFromPtr(current_target),
.type_string = event._type_string,
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(true, opts));
}
}
// Phase 2: At target
if (event._stop_propagation) return;
event._event_phase = .at_target;
const target_et = target.asEventTarget();
blk: {
// Get inline handler (e.g., onclick property) for this target
if (self.getInlineHandler(target_et, event)) |inline_handler| {
was_handled = true;
event._current_target = target_et;
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
if (event._stop_propagation) {
return;
}
if (event._stop_immediate_propagation) {
break :blk;
}
}
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, &was_handled, comptime .init(null, opts));
if (event._stop_propagation) {
return;
}
}
}
// Phase 3: Bubbling phase (target → root, excluding target)
// This only happens if the event bubbles
if (event._bubbles) {
event._event_phase = .bubbling_phase;
for (path[1..]) |current_target| {
if (event._stop_propagation) break;
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(current_target),
})) |list| {
try self.dispatchPhase(list, current_target, event, &was_handled, comptime .init(false, opts));
}
}
}
}
const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts {
return .{
.capture_only = capture_only,
.apply_ignore = opts.apply_ignore,
};
}
};
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime opts: DispatchPhaseOpts) !void {
const page = self.page;
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
node_loop: while (node) |n| {
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip non-matching listeners // Skip non-matching listeners
if (comptime capture_only) |capture| { if (comptime opts.capture_only) |capture| {
if (listener.capture != capture) { if (listener.capture != capture) {
continue; continue;
} }
@@ -412,6 +585,14 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
} }
} }
if (comptime opts.apply_ignore) {
for (self.ignore_list.items) |ignored| {
if (ignored == listener) {
continue :node_loop;
}
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them // Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) { if (listener.once) {
self.removeListener(list, listener); self.removeListener(list, listener);
@@ -455,9 +636,20 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
} }
} }
// Non-Node dispatching (XHR, Window without propagation) fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void { const global_event_handlers = @import("webapi/global_event_handlers.zig");
return self.dispatchPhase(list, current_target, event, was_handled, null); const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
else => return null,
};
return html_element.getAttributeFunction(handler_type, self.page) catch |err| {
log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err });
return null;
};
} }
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
@@ -575,3 +767,144 @@ fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
return false; return false;
} }
// Handles the default action for clicking on input checked/radio. Maybe this
// could be generalized if needed, but I'm not sure. This wasn't obvious to me
// but when an input is clicked, it's important to think about both the intent
// and the actual result. Imagine you have an unchecked checkbox. When clicked,
// the checkbox immediately becomes checked, and event handlers see this "checked"
// intent. But a listener can preventDefault() in which case the check we did at
// the start will be undone.
// This is a bit more complicated for radio buttons, as the checking/unchecking
// and the rollback can impact a different radio input. So if you "check" a radio
// the intent is that it becomes checked and whatever was checked before becomes
// unchecked, so that if you have to rollback (because of a preventDefault())
// then both inputs have to revert to their original values.
const ActivationState = struct {
old_checked: bool,
input: *Element.Html.Input,
previously_checked_radio: ?*Input,
const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) {
return null;
}
const input = target.is(Element.Html.Input) orelse return null;
if (input._input_type != .checkbox and input._input_type != .radio) {
return null;
}
const old_checked = input._checked;
var previously_checked_radio: ?*Element.Html.Input = null;
// For radio buttons, find the currently checked radio in the group
if (input._input_type == .radio and !old_checked) {
previously_checked_radio = try findCheckedRadioInGroup(input, page);
}
// Toggle checkbox or check radio (which unchecks others in group)
const new_checked = if (input._input_type == .checkbox) !old_checked else true;
try input.setChecked(new_checked, page);
return .{
.input = input,
.old_checked = old_checked,
.previously_checked_radio = previously_checked_radio,
};
}
fn restore(self: *const ActivationState, event: *const Event, page: *Page) void {
const input = self.input;
if (event._prevent_default) {
// Rollback: restore previous state
input._checked = self.old_checked;
input._checked_dirty = true;
if (self.previously_checked_radio) |prev_radio| {
prev_radio._checked = true;
prev_radio._checked_dirty = true;
}
return;
}
// Commit: fire input and change events only if state actually changed
// and the element is connected to a document (detached elements don't fire).
// For checkboxes, state always changes. For radios, only if was unchecked.
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
if (state_changed and input.asElement().asNode().isConnected()) {
fireEvent(page, input, "input") catch |err| {
log.warn(.event, "input event", .{ .err = err });
};
fireEvent(page, input, "change") catch |err| {
log.warn(.event, "change event", .{ .err = err });
};
}
}
fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input {
const elem = input.asElement();
const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null;
if (name.len == 0) {
return null;
}
const form = input.getForm(page);
// Walk from the root of the tree containing this element
// This handles both document-attached and orphaned elements
const root = elem.asNode().getRootNode(null);
const TreeWalker = @import("webapi/TreeWalker.zig");
var walker = TreeWalker.Full.init(root, .{});
while (walker.next()) |node| {
const other_element = node.is(Element) orelse continue;
const other_input = other_element.is(Input) orelse continue;
if (other_input._input_type != .radio) {
continue;
}
// Skip the input we're checking from
if (other_input == input) {
continue;
}
const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
if (!std.mem.eql(u8, name, other_name)) {
continue;
}
// Check if same form context
const other_form = other_input.getForm(page);
if (form) |f| {
const of = other_form orelse continue;
if (f != of) {
continue; // Different forms
}
} else if (other_form != null) {
continue; // form is null but other has a form
}
if (other_input._checked) {
return other_input;
}
}
return null;
}
// Fire input or change event
fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void {
const event = try Event.initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = false,
}, page);
const target = input.asElement().asEventTarget();
try page._event_manager.dispatch(target, event);
}
};

View File

@@ -42,10 +42,96 @@ const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
const assert = std.debug.assert; const assert = std.debug.assert;
// Shared across all frames of a Page.
const Factory = @This(); const Factory = @This();
_page: *Page,
_arena: Allocator,
_slab: SlabAllocator, _slab: SlabAllocator,
pub fn init(arena: Allocator) !*Factory {
const self = try arena.create(Factory);
self.* = .{
._arena = arena,
._slab = SlabAllocator.init(arena, 128),
};
return self;
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return self.eventTargetWithAllocator(self._slab.allocator(), child);
}
pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
const allocator = self._slab.allocator();
const et = try allocator.create(EventTarget);
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
return et;
}
// this is a root object
pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn PrototypeChain(comptime types: []const type) type { fn PrototypeChain(comptime types: []const type) type {
return struct { return struct {
const Self = @This(); const Self = @This();
@@ -149,86 +235,14 @@ fn AutoPrototypeChain(comptime types: []const type) type {
}; };
} }
pub fn init(page: *Page) Factory { fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
return .{
._page = page,
._slab = SlabAllocator.init(page.arena, 128),
};
}
// this is a root object
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const chain = try PrototypeChain(
&.{ EventTarget, @TypeOf(child) },
).allocate(allocator);
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(EventTarget.Type, chain.get(1)),
};
chain.setLeaf(1, child);
return chain.get(1);
}
// this is a root object
pub fn event(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setLeaf(1, child);
return chain.get(1);
}
pub fn uiEvent(self: *Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);
return chain.get(2);
}
pub fn mouseEvent(self: *Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
).allocate(arena);
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = try self.eventInit(arena, typ, chain.get(1));
chain.setMiddle(1, UIEvent.Type);
// Set MouseEvent with all its fields
const mouse_ptr = chain.get(2);
mouse_ptr.* = mouse;
mouse_ptr._proto = chain.get(1);
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
chain.setLeaf(3, child);
return chain.get(3);
}
fn eventInit(self: *const Factory, arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this) // Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic); const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2; const time_stamp = (raw_timestamp / 2) * 2;
return .{ return .{
._rc = 0,
._arena = arena, ._arena = arena,
._page = self._page,
._type = unionInit(Event.Type, value), ._type = unionInit(Event.Type, value),
._type_string = typ, ._type_string = typ,
._time_stamp = time_stamp, ._time_stamp = time_stamp,
@@ -329,7 +343,7 @@ pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeO
chain.setMiddle(2, Element.Type); chain.setMiddle(2, Element.Type);
// will never allocate, can't fail // will never allocate, can't fail
const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable; const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable;
// Manually set Element.Svg with the tag_name // Manually set Element.Svg with the tag_name
chain.set(3, .{ chain.set(3, .{
@@ -374,7 +388,7 @@ pub fn destroy(self: *Factory, value: anytype) void {
} }
if (comptime @hasField(S, "_proto")) { if (comptime @hasField(S, "_proto")) {
self.destroyChain(value, true, 0, std.mem.Alignment.@"1"); self.destroyChain(value, 0, std.mem.Alignment.@"1");
} else { } else {
self.destroyStandalone(value); self.destroyStandalone(value);
} }
@@ -388,7 +402,6 @@ pub fn destroyStandalone(self: *Factory, value: anytype) void {
fn destroyChain( fn destroyChain(
self: *Factory, self: *Factory,
value: anytype, value: anytype,
comptime first: bool,
old_size: usize, old_size: usize,
old_align: std.mem.Alignment, old_align: std.mem.Alignment,
) void { ) void {
@@ -400,23 +413,8 @@ fn destroyChain(
const new_size = current_size + @sizeOf(S); const new_size = current_size + @sizeOf(S);
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S)); const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
// This is initially called from a deinit. We don't want to call that
// same deinit. So when this is the first time destroyChain is called
// we don't call deinit (because we're in that deinit)
if (!comptime first) {
// But if it isn't the first time
if (@hasDecl(S, "deinit")) {
// And it has a deinit, we'll call it
switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
1 => value.deinit(),
2 => value.deinit(self._page),
else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
}
}
}
if (@hasField(S, "_proto")) { if (@hasField(S, "_proto")) {
self.destroyChain(value._proto, false, new_size, new_align); self.destroyChain(value._proto, new_size, new_align);
} else { } else {
// no proto so this is the head of the chain. // no proto so this is the head of the chain.
// we use this as the ptr to the start of the chain. // we use this as the ptr to the start of the chain.

View File

@@ -24,10 +24,11 @@ params: []const u8 = "",
// IANA defines max. charset value length as 40. // IANA defines max. charset value length as 40.
// We keep 41 for null-termination since HTML parser expects in this format. // We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset, charset: [41]u8 = default_charset,
charset_len: usize = 5, charset_len: usize = default_charset_len,
/// String "UTF-8" continued by null characters. /// String "UTF-8" continued by null characters.
pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
const default_charset_len = 5;
/// Mime with unknown Content-Type, empty params and empty charset. /// Mime with unknown Content-Type, empty params and empty charset.
pub const unknown = Mime{ .content_type = .{ .unknown = {} } }; pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
@@ -38,6 +39,10 @@ pub const ContentTypeEnum = enum {
text_javascript, text_javascript,
text_plain, text_plain,
text_css, text_css,
image_jpeg,
image_gif,
image_png,
image_webp,
application_json, application_json,
unknown, unknown,
other, other,
@@ -49,6 +54,10 @@ pub const ContentType = union(ContentTypeEnum) {
text_javascript: void, text_javascript: void,
text_plain: void, text_plain: void,
text_css: void, text_css: void,
image_jpeg: void,
image_gif: void,
image_png: void,
image_webp: void,
application_json: void, application_json: void,
unknown: void, unknown: void,
other: struct { type: []const u8, sub_type: []const u8 }, other: struct { type: []const u8, sub_type: []const u8 },
@@ -61,6 +70,10 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
.text_javascript => "application/javascript", .text_javascript => "application/javascript",
.text_plain => "text/plain", .text_plain => "text/plain",
.text_css => "text/css", .text_css => "text/css",
.image_jpeg => "image/jpeg",
.image_png => "image/png",
.image_gif => "image/gif",
.image_webp => "image/webp",
.application_json => "application/json", .application_json => "application/json",
else => "", else => "",
}; };
@@ -115,17 +128,17 @@ pub fn parse(input: []u8) !Mime {
const params = trimLeft(normalized[type_len..]); const params = trimLeft(normalized[type_len..]);
var charset: [41]u8 = undefined; var charset: [41]u8 = default_charset;
var charset_len: usize = undefined; var charset_len: usize = default_charset_len;
var it = std.mem.splitScalar(u8, params, ';'); var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| { while (it.next()) |attr| {
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid; const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
const name = trimLeft(attr[0..i]); const name = trimLeft(attr[0..i]);
const value = trimRight(attr[i + 1 ..]); const value = trimRight(attr[i + 1 ..]);
if (value.len == 0) { if (value.len == 0) {
return error.Invalid; continue;
} }
const attribute_name = std.meta.stringToEnum(enum { const attribute_name = std.meta.stringToEnum(enum {
@@ -138,7 +151,7 @@ pub fn parse(input: []u8) !Mime {
break; break;
} }
const attribute_value = try parseCharset(value); const attribute_value = parseCharset(value) catch continue;
@memcpy(charset[0..attribute_value.len], attribute_value); @memcpy(charset[0..attribute_value.len], attribute_value);
// Null-terminate right after attribute value. // Null-terminate right after attribute value.
charset[attribute_value.len] = 0; charset[attribute_value.len] = 0;
@@ -243,6 +256,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
@"application/javascript", @"application/javascript",
@"application/x-javascript", @"application/x-javascript",
@"image/jpeg",
@"image/png",
@"image/gif",
@"image/webp",
@"application/json", @"application/json",
}, type_name)) |known_type| { }, type_name)) |known_type| {
const ct: ContentType = switch (known_type) { const ct: ContentType = switch (known_type) {
@@ -251,6 +269,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
.@"text/plain" => .{ .text_plain = {} }, .@"text/plain" => .{ .text_plain = {} },
.@"text/css" => .{ .text_css = {} }, .@"text/css" => .{ .text_css = {} },
.@"image/jpeg" => .{ .image_jpeg = {} },
.@"image/png" => .{ .image_png = {} },
.@"image/gif" => .{ .image_gif = {} },
.@"image/webp" => .{ .image_webp = {} },
.@"application/json" => .{ .application_json = {} }, .@"application/json" => .{ .application_json = {} },
}; };
return .{ ct, attribute_start }; return .{ ct, attribute_start };
@@ -313,6 +335,19 @@ test "Mime: invalid" {
"text/ html", "text/ html",
"text / html", "text / html",
"text/html other", "text/html other",
};
for (invalids) |invalid| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
}
}
test "Mime: malformed parameters are ignored" {
defer testing.reset();
// These should all parse successfully as text/html with malformed params ignored
const valid_with_malformed_params = [_][]const u8{
"text/html; x", "text/html; x",
"text/html; x=", "text/html; x=",
"text/html; x= ", "text/html; x= ",
@@ -321,11 +356,13 @@ test "Mime: invalid" {
"text/html; charset=\"\"", "text/html; charset=\"\"",
"text/html; charset=\"", "text/html; charset=\"",
"text/html; charset=\"\\", "text/html; charset=\"\\",
"text/html;\"",
}; };
for (invalids) |invalid| { for (valid_with_malformed_params) |input| {
const mutable_input = try testing.arena_allocator.dupe(u8, invalid); const mutable_input = try testing.arena_allocator.dupe(u8, input);
try testing.expectError(error.Invalid, Mime.parse(mutable_input)); const mime = try Mime.parse(mutable_input);
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
} }
} }
@@ -358,6 +395,11 @@ test "Mime: parse common" {
try expect(.{ .content_type = .{ .application_json = {} } }, "application/json"); try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
try expect(.{ .content_type = .{ .text_css = {} } }, "text/css"); try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg");
try expect(.{ .content_type = .{ .image_png = {} } }, "image/png");
try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif");
try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp");
} }
test "Mime: parse uncommon" { test "Mime: parse uncommon" {
@@ -409,6 +451,12 @@ test "Mime: parse charset" {
.charset = "custom-non-standard-charset-value", .charset = "custom-non-standard-charset-value",
.params = "charset=\"custom-non-standard-charset-value\"", .params = "charset=\"custom-non-standard-charset-value\"",
}, "text/xml;charset=\"custom-non-standard-charset-value\""); }, "text/xml;charset=\"custom-non-standard-charset-value\"");
try expect(.{
.content_type = .{ .text_html = {} },
.charset = "UTF-8",
.params = "x=\"",
}, "text/html;x=\"");
} }
test "Mime: isHTML" { test "Mime: isHTML" {

File diff suppressed because it is too large Load Diff

View File

@@ -16,12 +16,54 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const builtin = @import("builtin");
const std = @import("std"); const std = @import("std");
const log = @import("../log.zig"); const log = @import("../log.zig");
pub const CompiledPattern = struct {
pattern: []const u8,
ty: enum {
prefix, // "/admin/" - prefix match
exact, // "/admin$" - exact match
wildcard, // any pattern that contains *
},
fn compile(pattern: []const u8) CompiledPattern {
if (pattern.len == 0) {
return .{
.pattern = pattern,
.ty = .prefix,
};
}
const is_wildcard = std.mem.indexOfScalar(u8, pattern, '*') != null;
if (is_wildcard) {
return .{
.pattern = pattern,
.ty = .wildcard,
};
}
const has_end_anchor = pattern[pattern.len - 1] == '$';
return .{
.pattern = pattern,
.ty = if (has_end_anchor) .exact else .prefix,
};
}
};
pub const Rule = union(enum) { pub const Rule = union(enum) {
allow: []const u8, allow: CompiledPattern,
disallow: []const u8, disallow: CompiledPattern,
fn allowRule(pattern: []const u8) Rule {
return .{ .allow = CompiledPattern.compile(pattern) };
}
fn disallowRule(pattern: []const u8) Rule {
return .{ .disallow = CompiledPattern.compile(pattern) };
}
}; };
pub const Key = enum { pub const Key = enum {
@@ -44,11 +86,22 @@ pub const RobotStore = struct {
const Context = @This(); const Context = @This();
pub fn hash(_: Context, value: []const u8) u32 { pub fn hash(_: Context, value: []const u8) u32 {
var hasher = std.hash.Wyhash.init(value.len); var key = value;
for (value) |c| { var buf: [128]u8 = undefined;
std.hash.autoHash(&hasher, std.ascii.toLower(c)); var h = std.hash.Wyhash.init(value.len);
while (key.len >= 128) {
const lower = std.ascii.lowerString(buf[0..], key[0..128]);
h.update(lower);
key = key[128..];
} }
return @truncate(hasher.final());
if (key.len > 0) {
const lower = std.ascii.lowerString(buf[0..key.len], key);
h.update(lower);
}
return @truncate(h.final());
} }
pub fn eql(_: Context, a: []const u8, b: []const u8) bool { pub fn eql(_: Context, a: []const u8, b: []const u8) bool {
@@ -58,12 +111,16 @@ pub const RobotStore = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
map: RobotsMap, map: RobotsMap,
mutex: std.Thread.Mutex = .{},
pub fn init(allocator: std.mem.Allocator) RobotStore { pub fn init(allocator: std.mem.Allocator) RobotStore {
return .{ .allocator = allocator, .map = .empty }; return .{ .allocator = allocator, .map = .empty };
} }
pub fn deinit(self: *RobotStore) void { pub fn deinit(self: *RobotStore) void {
self.mutex.lock();
defer self.mutex.unlock();
var iter = self.map.iterator(); var iter = self.map.iterator();
while (iter.next()) |entry| { while (iter.next()) |entry| {
@@ -79,6 +136,9 @@ pub const RobotStore = struct {
} }
pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry { pub fn get(self: *RobotStore, url: []const u8) ?RobotsEntry {
self.mutex.lock();
defer self.mutex.unlock();
return self.map.get(url); return self.map.get(url);
} }
@@ -87,11 +147,17 @@ pub const RobotStore = struct {
} }
pub fn put(self: *RobotStore, url: []const u8, robots: Robots) !void { pub fn put(self: *RobotStore, url: []const u8, robots: Robots) !void {
self.mutex.lock();
defer self.mutex.unlock();
const duped = try self.allocator.dupe(u8, url); const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .{ .present = robots }); try self.map.put(self.allocator, duped, .{ .present = robots });
} }
pub fn putAbsent(self: *RobotStore, url: []const u8) !void { pub fn putAbsent(self: *RobotStore, url: []const u8) !void {
self.mutex.lock();
defer self.mutex.unlock();
const duped = try self.allocator.dupe(u8, url); const duped = try self.allocator.dupe(u8, url);
try self.map.put(self.allocator, duped, .absent); try self.map.put(self.allocator, duped, .absent);
} }
@@ -112,8 +178,8 @@ const State = struct {
fn freeRulesInList(allocator: std.mem.Allocator, rules: []const Rule) void { fn freeRulesInList(allocator: std.mem.Allocator, rules: []const Rule) void {
for (rules) |rule| { for (rules) |rule| {
switch (rule) { switch (rule) {
.allow => |value| allocator.free(value), .allow => |compiled| allocator.free(compiled.pattern),
.disallow => |value| allocator.free(value), .disallow => |compiled| allocator.free(compiled.pattern),
} }
} }
} }
@@ -122,7 +188,7 @@ fn parseRulesWithUserAgent(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
user_agent: []const u8, user_agent: []const u8,
raw_bytes: []const u8, raw_bytes: []const u8,
) ![]const Rule { ) ![]Rule {
var rules: std.ArrayList(Rule) = .empty; var rules: std.ArrayList(Rule) = .empty;
defer rules.deinit(allocator); defer rules.deinit(allocator);
@@ -201,13 +267,13 @@ fn parseRulesWithUserAgent(
.in_our_entry => { .in_our_entry => {
const duped_value = try allocator.dupe(u8, value); const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value); errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .allow = duped_value }); try rules.append(allocator, Rule.allowRule(duped_value));
}, },
.in_other_entry => {}, .in_other_entry => {},
.in_wildcard_entry => { .in_wildcard_entry => {
const duped_value = try allocator.dupe(u8, value); const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value); errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .allow = duped_value }); try wildcard_rules.append(allocator, Rule.allowRule(duped_value));
}, },
.not_in_entry => { .not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "allow" }); log.warn(.browser, "robots unexpected rule", .{ .rule = "allow" });
@@ -220,15 +286,19 @@ fn parseRulesWithUserAgent(
switch (state.entry) { switch (state.entry) {
.in_our_entry => { .in_our_entry => {
if (value.len == 0) continue;
const duped_value = try allocator.dupe(u8, value); const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value); errdefer allocator.free(duped_value);
try rules.append(allocator, .{ .disallow = duped_value }); try rules.append(allocator, Rule.disallowRule(duped_value));
}, },
.in_other_entry => {}, .in_other_entry => {},
.in_wildcard_entry => { .in_wildcard_entry => {
if (value.len == 0) continue;
const duped_value = try allocator.dupe(u8, value); const duped_value = try allocator.dupe(u8, value);
errdefer allocator.free(duped_value); errdefer allocator.free(duped_value);
try wildcard_rules.append(allocator, .{ .disallow = duped_value }); try wildcard_rules.append(allocator, Rule.disallowRule(duped_value));
}, },
.not_in_entry => { .not_in_entry => {
log.warn(.browser, "robots unexpected rule", .{ .rule = "disallow" }); log.warn(.browser, "robots unexpected rule", .{ .rule = "disallow" });
@@ -252,6 +322,39 @@ fn parseRulesWithUserAgent(
pub fn fromBytes(allocator: std.mem.Allocator, user_agent: []const u8, bytes: []const u8) !Robots { pub fn fromBytes(allocator: std.mem.Allocator, user_agent: []const u8, bytes: []const u8) !Robots {
const rules = try parseRulesWithUserAgent(allocator, user_agent, bytes); const rules = try parseRulesWithUserAgent(allocator, user_agent, bytes);
// sort by order once.
std.mem.sort(Rule, rules, {}, struct {
fn lessThan(_: void, a: Rule, b: Rule) bool {
const a_len = switch (a) {
.allow => |p| p.pattern.len,
.disallow => |p| p.pattern.len,
};
const b_len = switch (b) {
.allow => |p| p.pattern.len,
.disallow => |p| p.pattern.len,
};
// Sort by length first.
if (a_len != b_len) {
return a_len > b_len;
}
// Otherwise, allow should beat disallow.
const a_is_allow = switch (a) {
.allow => true,
.disallow => false,
};
const b_is_allow = switch (b) {
.allow => true,
.disallow => false,
};
return a_is_allow and !b_is_allow;
}
}.lessThan);
return .{ .rules = rules }; return .{ .rules = rules };
} }
@@ -260,86 +363,102 @@ pub fn deinit(self: *Robots, allocator: std.mem.Allocator) void {
allocator.free(self.rules); allocator.free(self.rules);
} }
fn matchPatternRecursive(pattern: []const u8, path: []const u8, exact_match: bool) bool {
if (pattern.len == 0) return true;
const star_pos = std.mem.indexOfScalar(u8, pattern, '*') orelse {
if (exact_match) {
// If we end in '$', we must be exactly equal.
return std.mem.eql(u8, path, pattern);
} else {
// Otherwise, we are just a prefix.
return std.mem.startsWith(u8, path, pattern);
}
};
// Ensure the prefix before the '*' matches.
if (!std.mem.startsWith(u8, path, pattern[0..star_pos])) {
return false;
}
const suffix_pattern = pattern[star_pos + 1 ..];
if (suffix_pattern.len == 0) return true;
var i: usize = star_pos;
while (i <= path.len) : (i += 1) {
if (matchPatternRecursive(suffix_pattern, path[i..], exact_match)) {
return true;
}
}
return false;
}
/// There are rules for how the pattern in robots.txt should be matched. /// There are rules for how the pattern in robots.txt should be matched.
/// ///
/// * should match 0 or more of any character. /// * should match 0 or more of any character.
/// $ should signify the end of a path, making it exact. /// $ should signify the end of a path, making it exact.
/// otherwise, it is a prefix path. /// otherwise, it is a prefix path.
fn matchPattern(pattern: []const u8, path: []const u8) ?usize { fn matchPattern(compiled: CompiledPattern, path: []const u8) bool {
if (pattern.len == 0) return 0; switch (compiled.ty) {
const exact_match = pattern[pattern.len - 1] == '$'; .prefix => return std.mem.startsWith(u8, path, compiled.pattern),
const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern; .exact => {
const pattern = compiled.pattern;
return std.mem.eql(u8, path, pattern[0 .. pattern.len - 1]);
},
.wildcard => {
const pattern = compiled.pattern;
const exact_match = pattern[pattern.len - 1] == '$';
const inner_pattern = if (exact_match) pattern[0 .. pattern.len - 1] else pattern;
return matchInnerPattern(inner_pattern, path, exact_match);
},
}
}
if (matchPatternRecursive( fn matchInnerPattern(pattern: []const u8, path: []const u8, exact_match: bool) bool {
inner_pattern, var pattern_idx: usize = 0;
path, var path_idx: usize = 0;
exact_match,
)) return pattern.len else return null; var star_pattern_idx: ?usize = null;
var star_path_idx: ?usize = null;
while (pattern_idx < pattern.len or path_idx < path.len) {
// 1: If pattern is consumed and we are doing prefix match, we matched.
if (pattern_idx >= pattern.len and !exact_match) {
return true;
}
// 2: Current character is a wildcard
if (pattern_idx < pattern.len and pattern[pattern_idx] == '*') {
star_pattern_idx = pattern_idx;
star_path_idx = path_idx;
pattern_idx += 1;
continue;
}
// 3: Characters match, advance both heads.
if (pattern_idx < pattern.len and path_idx < path.len and pattern[pattern_idx] == path[path_idx]) {
pattern_idx += 1;
path_idx += 1;
continue;
}
// 4: we have a previous wildcard, backtrack and try matching more.
if (star_pattern_idx) |star_p_idx| {
// if we have exhausted the path,
// we know we haven't matched.
if (star_path_idx.? > path.len) {
return false;
}
pattern_idx = star_p_idx + 1;
path_idx = star_path_idx.?;
star_path_idx.? += 1;
continue;
}
// Fallthrough: No match and no backtracking.
return false;
}
// Handle trailing widlcards that can match 0 characters.
while (pattern_idx < pattern.len and pattern[pattern_idx] == '*') {
pattern_idx += 1;
}
if (exact_match) {
// Both must be fully consumed.
return pattern_idx == pattern.len and path_idx == path.len;
}
// For prefix match, pattern must be completed.
return pattern_idx == pattern.len;
} }
pub fn isAllowed(self: *const Robots, path: []const u8) bool { pub fn isAllowed(self: *const Robots, path: []const u8) bool {
const rules = self.rules; for (self.rules) |rule| {
var longest_match_len: usize = 0;
var is_allowed_result = true;
for (rules) |rule| {
switch (rule) { switch (rule) {
.allow => |pattern| { .allow => |compiled| if (matchPattern(compiled, path)) return true,
if (matchPattern(pattern, path)) |len| { .disallow => |compiled| if (matchPattern(compiled, path)) return false,
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = true;
}
}
},
.disallow => |pattern| {
if (pattern.len == 0) continue;
if (matchPattern(pattern, path)) |len| {
// Longest or Last Wins.
if (len >= longest_match_len) {
longest_match_len = len;
is_allowed_result = false;
}
}
},
} }
} }
return is_allowed_result; return true;
}
fn testMatch(pattern: []const u8, path: []const u8) bool {
comptime if (!builtin.is_test) unreachable;
return matchPattern(CompiledPattern.compile(pattern), path);
} }
test "Robots: simple robots.txt" { test "Robots: simple robots.txt" {
@@ -362,77 +481,77 @@ test "Robots: simple robots.txt" {
} }
try std.testing.expectEqual(1, rules.len); try std.testing.expectEqual(1, rules.len);
try std.testing.expectEqualStrings("/admin/", rules[0].disallow); try std.testing.expectEqualStrings("/admin/", rules[0].disallow.pattern);
} }
test "Robots: matchPattern - simple prefix" { test "Robots: matchPattern - simple prefix" {
try std.testing.expect(matchPattern("/admin", "/admin/page") != null); try std.testing.expect(testMatch("/admin", "/admin/page"));
try std.testing.expect(matchPattern("/admin", "/admin") != null); try std.testing.expect(testMatch("/admin", "/admin"));
try std.testing.expect(matchPattern("/admin", "/other") == null); try std.testing.expect(!testMatch("/admin", "/other"));
try std.testing.expect(matchPattern("/admin/page", "/admin") == null); try std.testing.expect(!testMatch("/admin/page", "/admin"));
} }
test "Robots: matchPattern - single wildcard" { test "Robots: matchPattern - single wildcard" {
try std.testing.expect(matchPattern("/admin/*", "/admin/") != null); try std.testing.expect(testMatch("/admin/*", "/admin/"));
try std.testing.expect(matchPattern("/admin/*", "/admin/page") != null); try std.testing.expect(testMatch("/admin/*", "/admin/page"));
try std.testing.expect(matchPattern("/admin/*", "/admin/page/subpage") != null); try std.testing.expect(testMatch("/admin/*", "/admin/page/subpage"));
try std.testing.expect(matchPattern("/admin/*", "/other/page") == null); try std.testing.expect(!testMatch("/admin/*", "/other/page"));
} }
test "Robots: matchPattern - wildcard in middle" { test "Robots: matchPattern - wildcard in middle" {
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/xyz") != null); try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/xyz"));
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def/ghi/xyz") != null); try std.testing.expect(testMatch("/abc/*/xyz", "/abc/def/ghi/xyz"));
try std.testing.expect(matchPattern("/abc/*/xyz", "/abc/def") == null); try std.testing.expect(!testMatch("/abc/*/xyz", "/abc/def"));
try std.testing.expect(matchPattern("/abc/*/xyz", "/other/def/xyz") == null); try std.testing.expect(!testMatch("/abc/*/xyz", "/other/def/xyz"));
} }
test "Robots: matchPattern - complex wildcard case" { test "Robots: matchPattern - complex wildcard case" {
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/def/def/xyz") != null); try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/def/def/xyz"));
try std.testing.expect(matchPattern("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz") != null); try std.testing.expect(testMatch("/abc/*/def/xyz", "/abc/ANYTHING/def/xyz"));
} }
test "Robots: matchPattern - multiple wildcards" { test "Robots: matchPattern - multiple wildcards" {
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/b/y/c") != null); try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/b/y/c"));
try std.testing.expect(matchPattern("/a/*/b/*/c", "/a/x/y/b/z/w/c") != null); try std.testing.expect(testMatch("/a/*/b/*/c", "/a/x/y/b/z/w/c"));
try std.testing.expect(matchPattern("/*.php", "/index.php") != null); try std.testing.expect(testMatch("/*.php", "/index.php"));
try std.testing.expect(matchPattern("/*.php", "/admin/index.php") != null); try std.testing.expect(testMatch("/*.php", "/admin/index.php"));
} }
test "Robots: matchPattern - end anchor" { test "Robots: matchPattern - end anchor" {
try std.testing.expect(matchPattern("/*.php$", "/index.php") != null); try std.testing.expect(testMatch("/*.php$", "/index.php"));
try std.testing.expect(matchPattern("/*.php$", "/index.php?param=value") == null); try std.testing.expect(!testMatch("/*.php$", "/index.php?param=value"));
try std.testing.expect(matchPattern("/admin$", "/admin") != null); try std.testing.expect(testMatch("/admin$", "/admin"));
try std.testing.expect(matchPattern("/admin$", "/admin/") == null); try std.testing.expect(!testMatch("/admin$", "/admin/"));
try std.testing.expect(matchPattern("/fish$", "/fish") != null); try std.testing.expect(testMatch("/fish$", "/fish"));
try std.testing.expect(matchPattern("/fish$", "/fishheads") == null); try std.testing.expect(!testMatch("/fish$", "/fishheads"));
} }
test "Robots: matchPattern - wildcard with extension" { test "Robots: matchPattern - wildcard with extension" {
try std.testing.expect(matchPattern("/fish*.php", "/fish.php") != null); try std.testing.expect(testMatch("/fish*.php", "/fish.php"));
try std.testing.expect(matchPattern("/fish*.php", "/fishheads.php") != null); try std.testing.expect(testMatch("/fish*.php", "/fishheads.php"));
try std.testing.expect(matchPattern("/fish*.php", "/fish/salmon.php") != null); try std.testing.expect(testMatch("/fish*.php", "/fish/salmon.php"));
try std.testing.expect(matchPattern("/fish*.php", "/fish.asp") == null); try std.testing.expect(!testMatch("/fish*.php", "/fish.asp"));
} }
test "Robots: matchPattern - empty and edge cases" { test "Robots: matchPattern - empty and edge cases" {
try std.testing.expect(matchPattern("", "/anything") != null); try std.testing.expect(testMatch("", "/anything"));
try std.testing.expect(matchPattern("/", "/") != null); try std.testing.expect(testMatch("/", "/"));
try std.testing.expect(matchPattern("*", "/anything") != null); try std.testing.expect(testMatch("*", "/anything"));
try std.testing.expect(matchPattern("/*", "/anything") != null); try std.testing.expect(testMatch("/*", "/anything"));
try std.testing.expect(matchPattern("$", "") != null); try std.testing.expect(testMatch("$", ""));
} }
test "Robots: matchPattern - real world examples" { test "Robots: matchPattern - real world examples" {
try std.testing.expect(matchPattern("/", "/anything") != null); try std.testing.expect(testMatch("/", "/anything"));
try std.testing.expect(matchPattern("/admin/", "/admin/page") != null); try std.testing.expect(testMatch("/admin/", "/admin/page"));
try std.testing.expect(matchPattern("/admin/", "/public/page") == null); try std.testing.expect(!testMatch("/admin/", "/public/page"));
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf") != null); try std.testing.expect(testMatch("/*.pdf$", "/document.pdf"));
try std.testing.expect(matchPattern("/*.pdf$", "/document.pdf.bak") == null); try std.testing.expect(!testMatch("/*.pdf$", "/document.pdf.bak"));
try std.testing.expect(matchPattern("/*?", "/page?param=value") != null); try std.testing.expect(testMatch("/*?", "/page?param=value"));
try std.testing.expect(matchPattern("/*?", "/page") == null); try std.testing.expect(!testMatch("/*?", "/page"));
} }
test "Robots: isAllowed - basic allow/disallow" { test "Robots: isAllowed - basic allow/disallow" {
@@ -675,7 +794,7 @@ test "Robots: isAllowed - complex real-world example" {
try std.testing.expect(robots.isAllowed("/cgi-bin/script.sh") == true); try std.testing.expect(robots.isAllowed("/cgi-bin/script.sh") == true);
} }
test "Robots: isAllowed - order doesn't matter for same length" { test "Robots: isAllowed - order doesn't matter + allow wins" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var robots = try Robots.fromBytes(allocator, "Bot", var robots = try Robots.fromBytes(allocator, "Bot",
@@ -687,7 +806,7 @@ test "Robots: isAllowed - order doesn't matter for same length" {
); );
defer robots.deinit(allocator); defer robots.deinit(allocator);
try std.testing.expect(robots.isAllowed("/page") == false); try std.testing.expect(robots.isAllowed("/page") == true);
} }
test "Robots: isAllowed - empty file uses wildcard defaults" { test "Robots: isAllowed - empty file uses wildcard defaults" {

View File

@@ -20,13 +20,14 @@ const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const builtin = @import("builtin"); const builtin = @import("builtin");
const js = @import("js/js.zig");
const log = @import("../log.zig"); const log = @import("../log.zig");
const Http = @import("../http/Http.zig");
const String = @import("../string.zig").String;
const js = @import("js/js.zig");
const URL = @import("URL.zig"); const URL = @import("URL.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Browser = @import("Browser.zig"); const Browser = @import("Browser.zig");
const Http = @import("../http/Http.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
@@ -83,10 +84,11 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// importmap contains resolved urls. // importmap contains resolved urls.
importmap: std.StringHashMapUnmanaged([:0]const u8), importmap: std.StringHashMapUnmanaged([:0]const u8),
pub fn init(page: *Page) ScriptManager { // have we notified the page that all scripts are loaded (used to fire the "load"
// page isn't fully initialized, we can setup our reference, but that's it. // event).
const browser = page._session.browser; page_notified_of_completion: bool,
const allocator = browser.allocator;
pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager {
return .{ return .{
.page = page, .page = page,
.async_scripts = .{}, .async_scripts = .{},
@@ -96,9 +98,10 @@ pub fn init(page: *Page) ScriptManager {
.is_evaluating = false, .is_evaluating = false,
.allocator = allocator, .allocator = allocator,
.imported_modules = .empty, .imported_modules = .empty,
.client = browser.http_client, .client = http_client,
.static_scripts_done = false, .static_scripts_done = false,
.buffer_pool = BufferPool.init(allocator, 5), .buffer_pool = BufferPool.init(allocator, 5),
.page_notified_of_completion = false,
.script_pool = std.heap.MemoryPool(Script).init(allocator), .script_pool = std.heap.MemoryPool(Script).init(allocator),
}; };
} }
@@ -262,6 +265,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.url = url, .url = url,
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(url),
.blocking = is_blocking, .blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
@@ -361,9 +365,11 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.manager = self, .manager = self,
}; };
const page = self.page;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls); page.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
log.debug(.http, "script queue", .{ log.debug(.http, "script queue", .{
@@ -378,10 +384,11 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.url = url, .url = url,
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(url),
.cookie_jar = &self.page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.resource_type = .script, .resource_type = .script,
.notification = self.page._session.notification, .notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback, .header_callback = Script.headerCallback,
.data_callback = Script.dataCallback, .data_callback = Script.dataCallback,
@@ -454,9 +461,10 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} }, } },
}; };
const page = self.page;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls); page.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
log.debug(.http, "script queue", .{ log.debug(.http, "script queue", .{
@@ -479,11 +487,12 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
try self.client.request(.{ try self.client.request(.{
.url = url, .url = url,
.method = .GET, .method = .GET,
.frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(url),
.ctx = script, .ctx = script,
.resource_type = .script, .resource_type = .script,
.cookie_jar = &self.page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.notification = self.page._session.notification, .notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback, .header_callback = Script.headerCallback,
.data_callback = Script.dataCallback, .data_callback = Script.dataCallback,
@@ -567,19 +576,12 @@ fn evaluate(self: *ScriptManager) void {
// Page makes this safe to call multiple times. // Page makes this safe to call multiple times.
page.documentIsLoaded(); page.documentIsLoaded();
if (self.async_scripts.first == null) { if (self.async_scripts.first == null and self.page_notified_of_completion == false) {
// Looks like all async scripts are done too! self.page_notified_of_completion = true;
// Page makes this safe to call multiple times. page.scriptsCompletedLoading();
page.documentIsComplete();
} }
} }
pub fn isDone(self: *const ScriptManager) bool {
return self.static_scripts_done and // page is done processing initial html
self.defer_scripts.first == null and // no deferred scripts
self.async_scripts.first == null; // no async scripts
}
fn parseImportmap(self: *ScriptManager, script: *const Script) !void { fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
const content = script.source.content(); const content = script.source.content();
@@ -621,6 +623,18 @@ pub const Script = struct {
script_element: ?*Element.Html.Script, script_element: ?*Element.Html.Script,
manager: *ScriptManager, manager: *ScriptManager,
// for debugging a rare production issue
header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0,
const Kind = enum { const Kind = enum {
module, module,
javascript, javascript,
@@ -684,7 +698,38 @@ pub const Script = struct {
}); });
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.HeaderCallback", .{ .capacity = self.source.remote.capacity }); {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state),
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer = self.manager.buffer_pool.get(); var buffer = self.manager.buffer_pool.get();
if (transfer.getContentLength()) |cl| { if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl); try buffer.ensureTotalCapacity(self.manager.allocator, cl);
@@ -814,13 +859,15 @@ pub const Script = struct {
.kind = self.kind, .kind = self.kind,
.cacheable = cacheable, .cacheable = cacheable,
}); });
self.executeCallback("error", local.toLocal(script_element._on_error), page); self.executeCallback(comptime .wrap("error"), page);
return; return;
}; };
self.executeCallback("load", local.toLocal(script_element._on_load), page); self.executeCallback(comptime .wrap("load"), page);
return; return;
} }
defer page._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined; var try_catch: js.TryCatch = undefined;
try_catch.init(local); try_catch.init(local);
defer try_catch.deinit(); defer try_catch.deinit();
@@ -839,19 +886,18 @@ pub const Script = struct {
}; };
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success, .on_load = script_element._on_load != null }); log.debug(.browser, "executed script", .{ .src = url, .success = success });
} }
defer { defer {
// We should run microtasks even if script execution fails. local.runMacrotasks(); // also runs microtasks
local.runMicrotasks();
_ = page.js.scheduler.run() catch |err| { _ = page.js.scheduler.run() catch |err| {
log.err(.page, "scheduler", .{ .err = err }); log.err(.page, "scheduler", .{ .err = err });
}; };
} }
if (success) { if (success) {
self.executeCallback("load", local.toLocal(script_element._on_load), page); self.executeCallback(comptime .wrap("load"), page);
return; return;
} }
@@ -862,14 +908,12 @@ pub const Script = struct {
.cacheable = cacheable, .cacheable = cacheable,
}); });
self.executeCallback("error", local.toLocal(script_element._on_error), page); self.executeCallback(comptime .wrap("error"), page);
} }
fn executeCallback(self: *const Script, comptime typ: []const u8, cb_: ?js.Function, page: *Page) void { fn executeCallback(self: *const Script, typ: String, page: *Page) void {
const cb = cb_ orelse return;
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(comptime .wrap(typ), .{}, page) catch |err| { const event = Event.initTrusted(typ, .{}, page) catch |err| {
log.warn(.js, "script internal callback", .{ log.warn(.js, "script internal callback", .{
.url = self.url, .url = self.url,
.type = typ, .type = typ,
@@ -877,14 +921,11 @@ pub const Script = struct {
}); });
return; return;
}; };
defer if (!event._v8_handoff) event.deinit(false); page._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
var caught: js.TryCatch.Caught = undefined;
cb.tryCall(void, .{event}, &caught) catch {
log.warn(.js, "script callback", .{ log.warn(.js, "script callback", .{
.url = self.url, .url = self.url,
.type = typ, .type = typ,
.caught = caught, .err = err,
}); });
}; };
} }
@@ -897,7 +938,7 @@ const BufferPool = struct {
max_concurrent_transfers: u8, max_concurrent_transfers: u8,
mem_pool: std.heap.MemoryPool(Container), mem_pool: std.heap.MemoryPool(Container),
const List = std.DoublyLinkedList; const List = std.SinglyLinkedList;
const Container = struct { const Container = struct {
node: List.Node, node: List.Node,
@@ -956,7 +997,7 @@ const BufferPool = struct {
b.clearRetainingCapacity(); b.clearRetainingCapacity();
container.* = .{ .buf = b, .node = .{} }; container.* = .{ .buf = b, .node = .{} };
self.count += 1; self.count += 1;
self.available.append(&container.node); self.available.prepend(&container.node);
} }
}; };
@@ -1004,23 +1045,35 @@ fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
const uri = src[5..]; const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null; const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
const data = uri[data_starts + 1 ..];
var data = uri[data_starts + 1 ..]; const unescaped = try URL.unescape(allocator, data);
// Extract the encoding.
const metadata = uri[0..data_starts]; const metadata = uri[0..data_starts];
if (std.mem.endsWith(u8, metadata, ";base64")) { if (std.mem.endsWith(u8, metadata, ";base64") == false) {
const decoder = std.base64.standard.Decoder; return unescaped;
const decoded_size = try decoder.calcSizeForSlice(data);
const buffer = try allocator.alloc(u8, decoded_size);
errdefer allocator.free(buffer);
try decoder.decode(buffer, data);
data = buffer;
} }
return data; // Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Step 1: Remove all ASCII whitespace
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
for (unescaped) |c| {
if (!std.ascii.isWhitespace(c)) {
stripped.appendAssumeCapacity(c);
}
}
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
// Length % 4 == 1 is invalid
if (trimmed.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const buffer = try allocator.alloc(u8, decoded_size);
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
return buffer;
} }
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");

View File

@@ -18,6 +18,7 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig"); const log = @import("../log.zig");
@@ -31,7 +32,7 @@ const Browser = @import("Browser.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
// Session is like a browser's tab. // Session is like a browser's tab.
// It owns the js env and the loader for all the pages of the session. // It owns the js env and the loader for all the pages of the session.
@@ -45,16 +46,6 @@ notification: *Notification,
// Used to create our Inspector and in the BrowserContext. // Used to create our Inspector and in the BrowserContext.
arena: Allocator, arena: Allocator,
// The page's arena is unsuitable for data that has to existing while
// navigating from one page to another. For example, if we're clicking
// on an HREF, the URL exists in the original page (where the click
// originated) but also has to exist in the new page.
// While we could use the Session's arena, this could accumulate a lot of
// memory if we do many navigation events. The `transfer_arena` is meant to
// bridge the gap: existing long enough to store any data needed to end one
// page and start another.
transfer_arena: Allocator,
cookie_jar: storage.Cookie.Jar, cookie_jar: storage.Cookie.Jar,
storage_shed: storage.Shed, storage_shed: storage.Shed,
@@ -63,20 +54,24 @@ navigation: Navigation,
page: ?Page, page: ?Page,
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void { pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator; const allocator = browser.app.allocator;
const session_allocator = browser.session_arena.allocator(); const arena = try browser.arena_pool.acquire();
errdefer browser.arena_pool.release(arena);
self.* = .{ self.* = .{
.page = null, .page = null,
.arena = arena,
.history = .{}, .history = .{},
.navigation = .{}, .frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{}, .storage_shed = .{},
.browser = browser, .browser = browser,
.notification = notification, .notification = notification,
.arena = session_allocator,
.cookie_jar = storage.Cookie.Jar.init(allocator), .cookie_jar = storage.Cookie.Jar.init(allocator),
.transfer_arena = browser.transfer_arena.allocator(),
}; };
} }
@@ -84,8 +79,11 @@ pub fn deinit(self: *Session) void {
if (self.page != null) { if (self.page != null) {
self.removePage(); self.removePage();
} }
const browser = self.browser;
self.cookie_jar.deinit(); self.cookie_jar.deinit();
self.storage_shed.deinit(self.browser.app.allocator); self.storage_shed.deinit(browser.app.allocator);
browser.arena_pool.release(self.arena);
} }
// NOTE: the caller is not the owner of the returned value, // NOTE: the caller is not the owner of the returned value,
@@ -93,11 +91,9 @@ pub fn deinit(self: *Session) void {
pub fn createPage(self: *Session) !*Page { pub fn createPage(self: *Session) !*Page {
lp.assert(self.page == null, "Session.createPage - page not null", .{}); lp.assert(self.page == null, "Session.createPage - page not null", .{});
_ = self.browser.page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
self.page = @as(Page, undefined); self.page = @as(Page, undefined);
const page = &self.page.?; const page = &self.page.?;
try Page.init(page, self); try Page.init(page, self.nextFrameId(), self, null);
// Creates a new NavigationEventTarget for this page. // Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page); try self.navigation.onNewPage(page);
@@ -127,6 +123,26 @@ pub fn removePage(self: *Session) void {
} }
} }
pub fn replacePage(self: *Session) !*Page {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace page", .{});
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
var current = self.page.?;
const frame_id = current._frame_id;
const parent = current.parent;
current.deinit();
self.browser.env.memoryPressureNotification(.moderate);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, frame_id, self, parent);
return page;
}
pub fn currentPage(self: *Session) ?*Page { pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null); return &(self.page orelse return null);
} }
@@ -137,53 +153,232 @@ pub const WaitResult = enum {
cdp_socket, cdp_socket,
}; };
pub fn findPage(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return if (page._frame_id == frame_id) page else null;
}
pub fn wait(self: *Session, wait_ms: u32) WaitResult { pub fn wait(self: *Session, wait_ms: u32) WaitResult {
var page = &(self.page orelse return .no_page);
while (true) { while (true) {
if (self.page) |*page| { const wait_result = self._wait(page, wait_ms) catch |err| {
switch (page.wait(wait_ms)) { switch (err) {
.done => { error.JsError => {}, // already logged (with hopefully more context)
if (page._queued_navigation == null) { else => log.err(.browser, "session wait", .{
return .done; .err = err,
} .url = page.url,
self.processScheduledNavigation() catch return .done; }),
},
else => |result| return result,
} }
} else { return .done;
return .no_page; };
switch (wait_result) {
.done => {
if (page._queued_navigation == null) {
return .done;
}
page = self.processScheduledNavigation(page) catch return .done;
},
else => |result| return result,
} }
// if we've successfull navigated, we'll give the new page another
// page.wait(wait_ms)
} }
} }
fn processScheduledNavigation(self: *Session) !void { fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
defer _ = self.browser.transfer_arena.reset(.{ .retain_with_limit = 8 * 1024 }); var timer = try std.time.Timer.start();
const url, const opts = blk: { var ms_remaining = wait_ms;
const qn = self.page.?._queued_navigation.?;
// qn might not be safe to use after self.removePage is called, hence
// this block;
const url = qn.url;
const opts = qn.opts;
// This was already aborted on the page, but it would be pretty const browser = self.browser;
// bad if old requests went to the new page, so let's make double sure var http_client = browser.http_client;
self.browser.http_client.abort();
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
// fact is that the behavior of wait changes depending on whether or
// not we're using CDP.
// If we aren't using CDP, as soon as we think there's nothing left
// to do, we can exit - we'de done.
// But if we are using CDP, we should wait for the whole `wait_ms`
// because the http_click.tick() also monitors the CDP socket. And while
// we could let CDP poll http (like it does for HTTP requests), the fact
// is that we know more about the timing of stuff (e.g. how long to
// poll/sleep) in the page.
const exit_when_done = http_client.cdp_client == null;
while (true) {
switch (page._parse_state) {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and exit_when_done) {
// haven't started navigating, I guess.
return .done;
}
// Either we have active http connections, or we're in CDP
// mode with an extra socket. Either way, we're waiting
// for http traffic
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
// exit_when_done is explicitly set when there isn't
// an extra socket, so it should not be possibl to
// get an cdp_socket message when exit_when_done
// is true.
if (IS_DEBUG) {
std.debug.assert(exit_when_done == false);
}
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
},
.html, .complete => {
if (page._queued_navigation != null) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
const ms_to_next_task = try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
const 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;
}
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
// No http transfers, no cdp extra socket, no
// scheduled tasks, we're done.
return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
return .done;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run 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);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
}
fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page {
const browser = self.browser;
const qn = current_page._queued_navigation.?;
// take ownership of the page's queued navigation
current_page._queued_navigation = null;
defer browser.arena_pool.release(qn.arena);
const frame_id, const parent = blk: {
const page = &self.page.?;
const frame_id = page._frame_id;
const parent = page.parent;
browser.http_client.abort();
self.removePage(); self.removePage();
break :blk .{ url, opts }; break :blk .{ frame_id, parent };
}; };
const page = self.createPage() catch |err| { self.page = @as(Page, undefined);
log.err(.browser, "queued navigation page error", .{ const page = &self.page.?;
.err = err, try Page.init(page, frame_id, self, parent);
.url = url,
}); // Creates a new NavigationEventTarget for this page.
try self.navigation.onNewPage(page);
// start JS env
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
self.notification.dispatch(.page_created, page);
page.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url });
return err; return err;
}; };
page.navigate(url, opts) catch |err| { return page;
log.err(.browser, "queued navigation error", .{ .err = err, .url = url }); }
return err;
}; pub fn nextFrameId(self: *Session) u32 {
const id = self.frame_id_gen +% 1;
self.frame_id_gen = id;
return id;
} }

View File

@@ -20,44 +20,61 @@ const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ResolveOpts = struct { const ResolveOpts = struct {
encode: bool = false,
always_dupe: bool = false, always_dupe: bool = false,
}; };
// path is anytype, so that it can be used with both []const u8 and [:0]const u8 // path is anytype, so that it can be used with both []const u8 and [:0]const u8
pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 { pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime opts: ResolveOpts) ![:0]const u8 {
const PT = @TypeOf(path); const PT = @TypeOf(path);
if (base.len == 0 or isCompleteHTTPUrl(path)) { if (base.len == 0 or isCompleteHTTPUrl(path)) {
if (comptime opts.always_dupe or !isNullTerminated(PT)) { if (comptime opts.always_dupe or !isNullTerminated(PT)) {
return allocator.dupeZ(u8, path); const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
} }
return path; return path;
} }
if (path.len == 0) { if (path.len == 0) {
if (comptime opts.always_dupe) { if (comptime opts.always_dupe) {
return allocator.dupeZ(u8, base); const duped = try allocator.dupeZ(u8, base);
return processResolved(allocator, duped, opts);
}
if (comptime opts.encode) {
return processResolved(allocator, base, opts);
} }
return base; return base;
} }
if (path[0] == '?') { if (path[0] == '?') {
const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len; const base_path_end = std.mem.indexOfAny(u8, base, "?#") orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_path_end], path });
return processResolved(allocator, result, opts);
} }
if (path[0] == '#') { if (path[0] == '#') {
const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len; const base_fragment_start = std.mem.indexOfScalar(u8, base, '#') orelse base.len;
return std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..base_fragment_start], path });
return processResolved(allocator, result, opts);
} }
if (std.mem.startsWith(u8, path, "//")) { if (std.mem.startsWith(u8, path, "//")) {
// network-path reference // network-path reference
const index = std.mem.indexOfScalar(u8, base, ':') orelse { const index = std.mem.indexOfScalar(u8, base, ':') orelse {
if (comptime isNullTerminated(PT)) { if (comptime isNullTerminated(PT)) {
if (comptime opts.encode) {
return processResolved(allocator, path, opts);
}
return path; return path;
} }
return allocator.dupeZ(u8, path); const duped = try allocator.dupeZ(u8, path);
return processResolved(allocator, duped, opts);
}; };
const protocol = base[0 .. index + 1]; const protocol = base[0 .. index + 1];
return std.mem.joinZ(allocator, "", &.{ protocol, path }); const result = try std.mem.joinZ(allocator, "", &.{ protocol, path });
return processResolved(allocator, result, opts);
} }
const scheme_end = std.mem.indexOf(u8, base, "://"); const scheme_end = std.mem.indexOf(u8, base, "://");
@@ -65,7 +82,8 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len; const path_start = std.mem.indexOfScalarPos(u8, base, authority_start, '/') orelse base.len;
if (path[0] == '/') { if (path[0] == '/') {
return std.mem.joinZ(allocator, "", &.{ base[0..path_start], path }); const result = try std.mem.joinZ(allocator, "", &.{ base[0..path_start], path });
return processResolved(allocator, result, opts);
} }
var normalized_base: []const u8 = base[0..path_start]; var normalized_base: []const u8 = base[0..path_start];
@@ -127,7 +145,119 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, path: anytype, comptime
// we always have an extra space // we always have an extra space
out[out_i] = 0; out[out_i] = 0;
return out[0..out_i :0]; return processResolved(allocator, out[0..out_i :0], opts);
}
fn processResolved(allocator: Allocator, url: [:0]const u8, comptime opts: ResolveOpts) ![:0]const u8 {
if (!comptime opts.encode) {
return url;
}
return ensureEncoded(allocator, url);
}
pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const scheme_end = std.mem.indexOf(u8, url, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;
const query_start = std.mem.indexOfScalarPos(u8, url, path_start, '?');
const fragment_start = std.mem.indexOfScalarPos(u8, url, query_start orelse path_start, '#');
const path_end = query_start orelse fragment_start orelse url.len;
const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..path_end];
const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true);
const encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end];
const encoded = try percentEncodeSegment(allocator, query_to_encode, false);
break :blk encoded;
} else null;
const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..];
const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false);
break :blk encoded;
} else null;
if (encoded_path.ptr == path_to_encode.ptr and
(encoded_query == null or encoded_query.?.ptr == url[query_start.? + 1 .. query_end].ptr) and
(encoded_fragment == null or encoded_fragment.?.ptr == url[fragment_start.? + 1 ..].ptr))
{
// nothing has changed
return url;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len + 20);
try buf.appendSlice(allocator, url[0..path_start]);
try buf.appendSlice(allocator, encoded_path);
if (encoded_query) |eq| {
try buf.append(allocator, '?');
try buf.appendSlice(allocator, eq);
}
if (encoded_fragment) |ef| {
try buf.append(allocator, '#');
try buf.appendSlice(allocator, ef);
}
try buf.append(allocator, 0);
return buf.items[0 .. buf.items.len - 1 :0];
}
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 {
// Check if encoding is needed
var needs_encoding = false;
for (segment) |c| {
if (shouldPercentEncode(c, is_path)) {
needs_encoding = true;
break;
}
}
if (!needs_encoding) {
return segment;
}
var buf = try std.ArrayList(u8).initCapacity(allocator, segment.len + 10);
var i: usize = 0;
while (i < segment.len) : (i += 1) {
const c = segment[i];
// Check if this is an already-encoded sequence (%XX)
if (c == '%' and i + 2 < segment.len) {
const end = i + 2;
const h1 = segment[i + 1];
const h2 = segment[end];
if (std.ascii.isHex(h1) and std.ascii.isHex(h2)) {
try buf.appendSlice(allocator, segment[i .. end + 1]);
i = end;
continue;
}
}
if (shouldPercentEncode(c, is_path)) {
try buf.writer(allocator).print("%{X:0>2}", .{c});
} else {
try buf.append(allocator, c);
}
}
return buf.items;
}
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool {
return switch (c) {
// Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in both path and query
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false,
// Separators allowed in both path and query
'/', ':', '@' => false,
// Query-specific: '?' is allowed in queries but not in paths
'?' => comptime is_path,
// Everything else needs encoding (including space)
else => true,
};
} }
fn isNullTerminated(comptime value: type) bool { fn isNullTerminated(comptime value: type) bool {
@@ -512,6 +642,33 @@ pub fn getRobotsUrl(arena: Allocator, url: [:0]const u8) ![:0]const u8 {
); );
} }
pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
if (std.mem.indexOfScalar(u8, input, '%') == null) {
return input;
}
var result = try std.ArrayList(u8).initCapacity(arena, input.len);
var i: usize = 0;
while (i < input.len) {
if (input[i] == '%' and i + 2 < input.len) {
const hex = input[i + 1 .. i + 3];
const byte = std.fmt.parseInt(u8, hex, 16) catch {
result.appendAssumeCapacity(input[i]);
i += 1;
continue;
};
result.appendAssumeCapacity(byte);
i += 3;
} else {
result.appendAssumeCapacity(input[i]);
i += 1;
}
}
return result.items;
}
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" { test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about")); try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -691,6 +848,293 @@ test "URL: resolve" {
} }
} }
test "URL: ensureEncoded" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
.{
.url = "https://example.com/over 9000!",
.expected = "https://example.com/over%209000!",
},
.{
.url = "http://example.com/hello world.html",
.expected = "http://example.com/hello%20world.html",
},
.{
.url = "https://example.com/file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.url = "https://example.com/page?query=hello world",
.expected = "https://example.com/page?query=hello%20world",
},
.{
.url = "https://example.com/page?a=1&b=value with spaces",
.expected = "https://example.com/page?a=1&b=value%20with%20spaces",
},
.{
.url = "https://example.com/page#section one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/my path?query=my value#my anchor",
.expected = "https://example.com/my%20path?query=my%20value#my%20anchor",
},
.{
.url = "https://example.com/already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.url = "https://example.com/file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.url = "https://example.com/caf%C3%A9",
.expected = "https://example.com/caf%C3%A9",
},
.{
.url = "https://example.com/page?query=already%20encoded",
.expected = "https://example.com/page?query=already%20encoded",
},
.{
.url = "https://example.com/page?a=1&b=value%20here",
.expected = "https://example.com/page?a=1&b=value%20here",
},
.{
.url = "https://example.com/page#section%20one",
.expected = "https://example.com/page#section%20one",
},
.{
.url = "https://example.com/part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
.{
.url = "https://example.com/page?a=encoded%20value&b=not encoded",
.expected = "https://example.com/page?a=encoded%20value&b=not%20encoded",
},
.{
.url = "https://example.com/my%20path?query=not encoded#encoded%20anchor",
.expected = "https://example.com/my%20path?query=not%20encoded#encoded%20anchor",
},
.{
.url = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
.expected = "https://example.com/fully%20encoded?query=also%20encoded#and%20this",
},
.{
.url = "https://example.com/path-with_under~tilde",
.expected = "https://example.com/path-with_under~tilde",
},
.{
.url = "https://example.com/sub-delims!$&'()*+,;=",
.expected = "https://example.com/sub-delims!$&'()*+,;=",
},
.{
.url = "https://example.com",
.expected = "https://example.com",
},
.{
.url = "https://example.com?query=value",
.expected = "https://example.com?query=value",
},
.{
.url = "https://example.com/clean/path",
.expected = "https://example.com/clean/path",
},
.{
.url = "https://example.com/path?clean=query#clean-fragment",
.expected = "https://example.com/path?clean=query#clean-fragment",
},
.{
.url = "https://example.com/100% complete",
.expected = "https://example.com/100%25%20complete",
},
.{
.url = "https://example.com/path?value=100% done",
.expected = "https://example.com/path?value=100%25%20done",
},
};
for (cases) |case| {
const result = try ensureEncoded(testing.arena_allocator, case.url);
try testing.expectString(case.expected, result);
}
}
test "URL: resolve with encoding" {
defer testing.reset();
const Case = struct {
base: [:0]const u8,
path: [:0]const u8,
expected: [:0]const u8,
};
const cases = [_]Case{
// Spaces should be encoded as %20, but ! is allowed
.{
.base = "https://example.com/dir/",
.path = "over 9000!",
.expected = "https://example.com/dir/over%209000!",
},
.{
.base = "https://example.com/",
.path = "hello world.html",
.expected = "https://example.com/hello%20world.html",
},
// Multiple spaces
.{
.base = "https://example.com/",
.path = "path with multiple spaces",
.expected = "https://example.com/path%20with%20%20multiple%20%20%20spaces",
},
// Special characters that need encoding
.{
.base = "https://example.com/",
.path = "file[1].html",
.expected = "https://example.com/file%5B1%5D.html",
},
.{
.base = "https://example.com/",
.path = "file{name}.html",
.expected = "https://example.com/file%7Bname%7D.html",
},
.{
.base = "https://example.com/",
.path = "file<test>.html",
.expected = "https://example.com/file%3Ctest%3E.html",
},
.{
.base = "https://example.com/",
.path = "file\"quote\".html",
.expected = "https://example.com/file%22quote%22.html",
},
.{
.base = "https://example.com/",
.path = "file|pipe.html",
.expected = "https://example.com/file%7Cpipe.html",
},
.{
.base = "https://example.com/",
.path = "file\\backslash.html",
.expected = "https://example.com/file%5Cbackslash.html",
},
.{
.base = "https://example.com/",
.path = "file^caret.html",
.expected = "https://example.com/file%5Ecaret.html",
},
.{
.base = "https://example.com/",
.path = "file`backtick`.html",
.expected = "https://example.com/file%60backtick%60.html",
},
// Characters that should NOT be encoded
.{
.base = "https://example.com/",
.path = "path-with_under~tilde.html",
.expected = "https://example.com/path-with_under~tilde.html",
},
.{
.base = "https://example.com/",
.path = "path/with/slashes",
.expected = "https://example.com/path/with/slashes",
},
.{
.base = "https://example.com/",
.path = "sub-delims!$&'()*+,;=.html",
.expected = "https://example.com/sub-delims!$&'()*+,;=.html",
},
// Already encoded characters should not be double-encoded
.{
.base = "https://example.com/",
.path = "already%20encoded",
.expected = "https://example.com/already%20encoded",
},
.{
.base = "https://example.com/",
.path = "file%5B1%5D.html",
.expected = "https://example.com/file%5B1%5D.html",
},
// Mix of encoded and unencoded
.{
.base = "https://example.com/",
.path = "part%20encoded and not",
.expected = "https://example.com/part%20encoded%20and%20not",
},
// Query strings and fragments ARE encoded
.{
.base = "https://example.com/",
.path = "file name.html?query=value with spaces",
.expected = "https://example.com/file%20name.html?query=value%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file name.html#anchor with spaces",
.expected = "https://example.com/file%20name.html#anchor%20with%20spaces",
},
.{
.base = "https://example.com/",
.path = "file.html?hello=world !",
.expected = "https://example.com/file.html?hello=world%20!",
},
// Query structural characters should NOT be encoded
.{
.base = "https://example.com/",
.path = "file.html?a=1&b=2",
.expected = "https://example.com/file.html?a=1&b=2",
},
// Relative paths with encoding
.{
.base = "https://example.com/dir/page.html",
.path = "../other dir/file.html",
.expected = "https://example.com/other%20dir/file.html",
},
.{
.base = "https://example.com/dir/",
.path = "./sub dir/file.html",
.expected = "https://example.com/dir/sub%20dir/file.html",
},
// Absolute paths with encoding
.{
.base = "https://example.com/some/path",
.path = "/absolute path/file.html",
.expected = "https://example.com/absolute%20path/file.html",
},
// Unicode/high bytes (though ideally these should be UTF-8 encoded first)
.{
.base = "https://example.com/",
.path = "café",
.expected = "https://example.com/caf%C3%A9",
},
// Empty path
.{
.base = "https://example.com/",
.path = "",
.expected = "https://example.com/",
},
// Complete URL as path (should not be encoded)
.{
.base = "https://example.com/",
.path = "https://other.com/path with spaces",
.expected = "https://other.com/path%20with%20spaces",
},
};
for (cases) |case| {
const result = try resolve(testing.arena_allocator, case.base, case.path, .{ .encode = true });
try testing.expectString(case.expected, result);
}
}
test "URL: eqlDocument" { test "URL: eqlDocument" {
defer testing.reset(); defer testing.reset();
{ {
@@ -816,3 +1260,68 @@ test "URL: getRobotsUrl" {
try testing.expectString("https://example.com/robots.txt", url); try testing.expectString("https://example.com/robots.txt", url);
} }
} }
test "URL: unescape" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const result = try unescape(arena, "hello world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "hello%20world");
try testing.expectEqual("hello world", result);
}
{
const result = try unescape(arena, "%48%65%6c%6c%6f");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "%48%65%6C%6C%6F");
try testing.expectEqual("Hello", result);
}
{
const result = try unescape(arena, "a%3Db");
try testing.expectEqual("a=b", result);
}
{
const result = try unescape(arena, "a%3DB");
try testing.expectEqual("a=B", result);
}
{
const result = try unescape(arena, "ZDIgPSAndHdvJzs%3D");
try testing.expectEqual("ZDIgPSAndHdvJzs=", result);
}
{
const result = try unescape(arena, "%5a%44%4d%67%50%53%41%6e%64%47%68%79%5a%57%55%6e%4f%77%3D%3D");
try testing.expectEqual("ZDMgPSAndGhyZWUnOw==", result);
}
{
const result = try unescape(arena, "hello%2world");
try testing.expectEqual("hello%2world", result);
}
{
const result = try unescape(arena, "hello%ZZworld");
try testing.expectEqual("hello%ZZworld", result);
}
{
const result = try unescape(arena, "hello%");
try testing.expectEqual("hello%", result);
}
{
const result = try unescape(arena, "hello%2");
try testing.expectEqual("hello%2", result);
}
}

View File

@@ -583,7 +583,7 @@ fn consumeNumeric(self: *Tokenizer) Token {
}; };
self.advance(2); self.advance(2);
} else if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(2))) { } else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
self.advance(1); self.advance(1);
} else { } else {
break :blk; break :blk;

View File

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

View File

@@ -23,6 +23,7 @@ const string = @import("../../string.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const js = @import("js.zig"); const js = @import("js.zig");
const Local = @import("Local.zig");
const Context = @import("Context.zig"); const Context = @import("Context.zig");
const TaggedOpaque = @import("TaggedOpaque.zig"); const TaggedOpaque = @import("TaggedOpaque.zig");
@@ -33,25 +34,24 @@ const CALL_ARENA_RETAIN = 1024 * 16;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
const Caller = @This(); const Caller = @This();
local: js.Local, local: Local,
prev_local: ?*const js.Local, prev_local: ?*const js.Local,
prev_context: *Context, prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it. // Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context_handle = v8.v8__Isolate__GetCurrentContext(v8_isolate); const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
initWithContext(self, Context.fromC(v8_context), v8_context);
const embedder_data = v8.v8__Context__GetEmbedderData(v8_context_handle, 1); }
var lossless: bool = undefined;
const ctx: *Context = @ptrFromInt(v8.v8__BigInt__Uint64Value(embedder_data, &lossless));
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
ctx.call_depth += 1; ctx.call_depth += 1;
self.* = Caller{ self.* = Caller{
.local = .{ .local = .{
.ctx = ctx, .ctx = ctx,
.handle = v8_context_handle.?, .handle = v8_context,
.call_arena = ctx.call_arena, .call_arena = ctx.call_arena,
.isolate = .{ .handle = v8_isolate }, .isolate = ctx.isolate,
}, },
.prev_local = ctx.local, .prev_local = ctx.local,
.prev_context = ctx.page.js, .prev_context = ctx.page.js,
@@ -92,25 +92,28 @@ pub const CallOpts = struct {
}; };
pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void { pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
const local = &self.local;
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
hs.init(self.local.isolate); hs.init(local.isolate);
defer hs.deinit(); defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle }; const info = FunctionCallbackInfo{ .handle = handle };
if (!info.isConstructCall()) { if (!info.isConstructCall()) {
self.handleError(T, @TypeOf(func), error.InvalidArgument, info, opts); handleError(T, @TypeOf(func), local, error.InvalidArgument, info, opts);
return; return;
} }
self._constructor(func, info) catch |err| { self._constructor(func, info) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts); handleError(T, @TypeOf(func), local, err, info, opts);
}; };
} }
fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void { fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void {
const F = @TypeOf(func); const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info); const local = &self.local;
const args = try getArgs(F, 0, local, info);
const res = @call(.auto, func, args); const res = @call(.auto, func, args);
const ReturnType = @typeInfo(F).@"fn".return_type orelse { const ReturnType = @typeInfo(F).@"fn".return_type orelse {
@@ -118,12 +121,12 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
}; };
const new_this_handle = info.getThis(); const new_this_handle = info.getThis();
var this = js.Object{ .local = &self.local, .handle = new_this_handle }; var this = js.Object{ .local = local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) { if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err; const non_error_res = res catch |err| return err;
this = try self.local.mapZigInstanceToJs(new_this_handle, non_error_res); this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else { } else {
this = try self.local.mapZigInstanceToJs(new_this_handle, res); this = try local.mapZigInstanceToJs(new_this_handle, res);
} }
// If we got back a different object (existing wrapper), copy the prototype // If we got back a different object (existing wrapper), copy the prototype
@@ -140,144 +143,141 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
info.getReturnValue().set(this.handle); info.getReturnValue().set(this.handle);
} }
pub fn method(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._method(T, func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _method(self: *Caller, comptime T: type, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
var args = try self.getArgs(F, 1, info);
const js_this = info.getThis();
@field(args, "0") = try TaggedOpaque.fromJS(*T, js_this);
const res = @call(.auto, func, args);
const mapped = try self.local.zigValueToJs(res, opts);
const return_value = info.getReturnValue();
return_value.set(mapped);
}
pub fn function(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
var hs: js.HandleScope = undefined;
hs.init(self.local.isolate);
defer hs.deinit();
const info = FunctionCallbackInfo{ .handle = handle };
self._function(func, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};
}
fn _function(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void {
const F = @TypeOf(func);
const args = try self.getArgs(F, 0, info);
const res = @call(.auto, func, args);
info.getReturnValue().set(try self.local.zigValueToJs(res, opts));
}
pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { pub fn getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
hs.init(self.local.isolate); hs.init(local.isolate);
defer hs.deinit(); defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle }; const info = PropertyCallbackInfo{ .handle = handle };
return self._getIndex(T, func, idx, info, opts) catch |err| { return _getIndex(T, local, func, idx, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts); handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted // not intercepted
return 0; return 0;
}; };
} }
fn _getIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func); const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info); var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx; @field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts); return handleIndexedReturn(T, F, true, local, ret, info, opts);
} }
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
hs.init(self.local.isolate); hs.init(local.isolate);
defer hs.deinit(); defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle }; const info = PropertyCallbackInfo{ .handle = handle };
return self._getNamedIndex(T, func, name, info, opts) catch |err| { return _getNamedIndex(T, local, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts); handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted // not intercepted
return 0; return 0;
}; };
} }
fn _getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func); const F = @TypeOf(func);
var args = try self.getArgs(F, 2, info); var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = local.ctx.page;
}
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, true, ret, info, opts); return handleIndexedReturn(T, F, true, local, ret, info, opts);
} }
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
hs.init(self.local.isolate); hs.init(local.isolate);
defer hs.deinit(); defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle }; const info = PropertyCallbackInfo{ .handle = handle };
return self._setNamedIndex(T, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| { return _setNamedIndex(T, local, func, name, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts); handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted // not intercepted
return 0; return 0;
}; };
} }
fn _setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func); const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined; var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
@field(args, "2") = try self.local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); @field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) { if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = self.local.ctx.page; @field(args, "3") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts); return handleIndexedReturn(T, F, false, local, ret, info, opts);
} }
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 { pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
hs.init(self.local.isolate); hs.init(local.isolate);
defer hs.deinit(); defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle }; const info = PropertyCallbackInfo{ .handle = handle };
return self._deleteNamedIndex(T, func, name, info, opts) catch |err| { return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts); handleError(T, @TypeOf(func), local, err, info, opts);
return 0; return 0;
}; };
} }
fn _deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func); const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined; var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = try self.nameToString(@TypeOf(args.@"1"), name); @field(args, "1") = try nameToString(local, @TypeOf(args.@"1"), name);
if (@typeInfo(F).@"fn".params.len == 3) { if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = self.local.ctx.page; @field(args, "2") = local.ctx.page;
} }
const ret = @call(.auto, func, args); const ret = @call(.auto, func, args);
return self.handleIndexedReturn(T, F, false, ret, info, opts); return handleIndexedReturn(T, F, false, local, ret, info, opts);
} }
fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, comptime getter: bool, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 { pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u8 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _getEnumerator(T, local, func, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info, opts);
// not intercepted
return 0;
};
}
fn _getEnumerator(comptime T: type, local: *const Local, func: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
if (@typeInfo(F).@"fn".params.len == 2) {
@field(args, "1") = local.ctx.page;
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: bool, local: *const Local, ret: anytype, info: PropertyCallbackInfo, comptime opts: CallOpts) !u8 {
// need to unwrap this error immediately for when opts.null_as_undefined == true // need to unwrap this error immediately for when opts.null_as_undefined == true
// and we need to compare it to null; // and we need to compare it to null;
const non_error_ret = switch (@typeInfo(@TypeOf(ret))) { const non_error_ret = switch (@typeInfo(@TypeOf(ret))) {
@@ -292,7 +292,7 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, compti
return 0; return 0;
} }
} }
self.handleError(T, F, err, info, opts); handleError(T, F, local, err, info, opts);
// not intercepted // not intercepted
return 0; return 0;
}; };
@@ -300,8 +300,8 @@ fn handleIndexedReturn(self: *Caller, comptime T: type, comptime F: type, compti
else => ret, else => ret,
}; };
if (comptime getter) { if (comptime with_value) {
info.getReturnValue().set(try self.local.zigValueToJs(non_error_ret, opts)); info.getReturnValue().set(try local.zigValueToJs(non_error_ret, opts));
} }
// intercepted // intercepted
return 1; return 1;
@@ -314,36 +314,41 @@ fn isInErrorSet(err: anyerror, comptime T: type) bool {
return false; return false;
} }
fn nameToString(self: *const Caller, comptime T: type, name: *const v8.Name) !T { fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T {
const handle = @as(*const v8.String, @ptrCast(name)); const handle = @as(*const v8.String, @ptrCast(name));
if (T == string.String) { if (T == string.String) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, false); return js.String.toSSO(.{ .local = local, .handle = handle }, false);
} }
if (T == string.Global) { if (T == string.Global) {
return js.String.toSSO(.{ .local = &self.local, .handle = handle }, true); return js.String.toSSO(.{ .local = local, .handle = handle }, true);
} }
return try js.String.toSlice(.{ .local = &self.local, .handle = handle }); return try js.String.toSlice(.{ .local = local, .handle = handle });
} }
fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror, info: anytype, comptime opts: CallOpts) void { fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype, comptime opts: CallOpts) void {
const isolate = self.local.isolate; const isolate = local.isolate;
if (comptime @import("builtin").mode == .Debug and @TypeOf(info) == FunctionCallbackInfo) { if (comptime IS_DEBUG and @TypeOf(info) == FunctionCallbackInfo) {
if (log.enabled(.js, .warn)) { if (log.enabled(.js, .debug)) {
self.logFunctionCallError(@typeName(T), @typeName(F), err, info); const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err) == null) {
// This isn't a DOMException, let's log it
logFunctionCallError(local, @typeName(T), @typeName(F), err, info);
}
} }
} }
const js_err: *const v8.Value = switch (err) { const js_err: *const v8.Value = switch (err) {
error.TryCatchRethrow => return, error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"), error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.OutOfMemory => isolate.createError("out of memory"), error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Contructor"), error.IllegalConstructor => isolate.createError("Illegal Contructor"),
else => blk: { else => blk: {
if (comptime opts.dom_exception) { if (comptime opts.dom_exception) {
const DOMException = @import("../webapi/DOMException.zig"); const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err)) |ex| { if (DOMException.fromError(err)) |ex| {
const value = self.local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error"); const value = local.zigValueToJs(ex, .{}) catch break :blk isolate.createError("internal error");
break :blk value.handle; break :blk value.handle;
} }
} }
@@ -355,120 +360,20 @@ fn handleError(self: *Caller, comptime T: type, comptime F: type, err: anyerror,
info.getReturnValue().setValueHandle(js_exception); info.getReturnValue().setValueHandle(js_exception);
} }
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(self: *const Caller, comptime F: type, comptime offset: usize, info: anytype) !ParameterTypes(F) {
const local = &self.local;
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}
// This is extracted to speed up compilation. When left inlined in handleError, // This is extracted to speed up compilation. When left inlined in handleError,
// this can add as much as 10 seconds of compilation time. // this can add as much as 10 seconds of compilation time.
fn logFunctionCallError(self: *Caller, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void { fn logFunctionCallError(local: *const Local, type_name: []const u8, func: []const u8, err: anyerror, info: FunctionCallbackInfo) void {
const args_dump = self.serializeFunctionArgs(info) catch "failed to serialize args"; const args_dump = serializeFunctionArgs(local, info) catch "failed to serialize args";
log.info(.js, "function call error", .{ log.debug(.js, "function call error", .{
.type = type_name, .type = type_name,
.func = func, .func = func,
.err = err, .err = err,
.args = args_dump, .args = args_dump,
.stack = self.local.stackTrace() catch |err1| @errorName(err1), .stack = local.stackTrace() catch |err1| @errorName(err1),
}); });
} }
fn serializeFunctionArgs(self: *Caller, info: FunctionCallbackInfo) ![]const u8 { fn serializeFunctionArgs(local: *const Local, info: FunctionCallbackInfo) ![]const u8 {
const local = &self.local;
var buf = std.Io.Writer.Allocating.init(local.call_arena); var buf = std.Io.Writer.Allocating.init(local.call_arena);
const separator = log.separator(); const separator = log.separator();
@@ -585,3 +490,275 @@ const ReturnValue = struct {
v8.v8__ReturnValue__Set(self.handle, handle); v8.v8__ReturnValue__Set(self.handle, handle);
} }
}; };
pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
cache: ?Caching = null,
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
// and a Struct.
// 1 - Using the object's internal fields. Think of this as
// adding a field to the struct. It's fast, but the space is reserved
// upfront for _every_ instance, whether we use it or not.
//
// 2 - Using the object's private state with a v8::Private key. Think of
// this as a HashMap. It takes no memory if the cache isn't used
// but has overhead when used.
//
// Consider `window.document`, (1) we have relatively few Window objects,
// (2) They all have a document and (3) The document is accessed _a lot_.
// An internal field makes sense.
//
// Consider `node.childNodes`, (1) we can have 20K+ node objects, (2)
// 95% of nodes will never have their .childNodes access by JavaScript.
// Private map lookup makes sense.
pub const Caching = union(enum) {
internal: u8,
private: []const u8,
};
};
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 info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined;
hs.initWithIsolateHandle(v8_isolate);
defer hs.deinit();
var cache_state: CacheState = undefined;
if (comptime opts.cache) |cache| {
// This API is a bit weird. On
if (respondFromCache(cache, ctx, v8_context, info, &cache_state)) {
// Value was fetched from the cache and returned already
return;
} else {
// Cache miss: cache_state will have been populated
}
}
var caller: Caller = undefined;
caller.initWithContext(ctx, v8_context);
defer caller.deinit();
const js_value = _call(T, &caller.local, info, func, opts) catch |err| {
handleError(T, @TypeOf(func), &caller.local, err, info, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
return;
};
if (comptime opts.cache) |cache| {
cache_state.save(cache, js_value);
}
}
fn _call(comptime T: type, local: *const Local, info: FunctionCallbackInfo, func: anytype, comptime opts: Opts) !js.Value {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
if (comptime opts.static) {
args = try getArgs(F, 0, local, info);
} else {
args = try getArgs(F, 1, local, info);
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
}
const res = @call(.auto, func, args);
const js_value = try local.zigValueToJs(res, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
info.getReturnValue().set(js_value);
return js_value;
}
// We can cache a value directly into the v8::Object so that our callback to fetch a property
// can be fast. Generally, think of it like this:
// fn callback(handle: *const v8.FunctionCallbackInfo) callconv(.c) void {
// const js_obj = info.getThis();
// const cached_value = js_obj.getFromCache("Nodes.childNodes");
// info.returnValue().set(cached_value);
// }
//
// That above pseudocode snippet is largely what this respondFromCache is doing.
// But on miss, it's also setting the `cache_state` with all of the data it
// got checking the cache, so that, once we get the value from our Zig code,
// it's quick to store in the v8::Object for subsequent calls.
fn respondFromCache(comptime cache: Opts.Caching, ctx: *Context, v8_context: *const v8.Context, info: FunctionCallbackInfo, cache_state: *CacheState) bool {
const js_this = info.getThis();
const return_value = info.getReturnValue();
switch (cache) {
.internal => |idx| {
if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| {
// means we can't cache undefined, since we can't tell the
// difference between "it isn't in the cache" and "it's
// in the cache with a valud of undefined"
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .internal = idx },
};
},
.private => |private_symbol| {
const global_handle = &@field(ctx.env.private_symbols, private_symbol).handle;
const private_key: *const v8.Private = v8.v8__Global__Get(global_handle, ctx.isolate.handle).?;
if (v8.v8__Object__GetPrivate(js_this, v8_context, private_key)) |cached| {
// This means we can't cache "undefined", since we can't tell
// the difference between a (a) undefined == not in the cache
// and (b) undefined == the cache value. If this becomes
// important, we can check HasPrivate first. But that requires
// calling HasPrivate then GetPrivate.
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .private = private_key },
};
},
}
// cache miss
return false;
}
const CacheState = struct {
js_this: *const v8.Object,
v8_context: *const v8.Context,
mode: union(enum) {
internal: u8,
private: *const v8.Private,
},
pub fn save(self: *const CacheState, comptime cache: Opts.Caching, js_value: js.Value) void {
if (comptime cache == .internal) {
v8.v8__Object__SetInternalField(self.js_this, self.mode.internal, js_value.handle);
} else {
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrivate(self.js_this, self.v8_context, self.mode.private, js_value.handle, &out);
}
}
};
};
// If we call a method in javascript: cat.lives('nine');
//
// Then we'd expect a Zig function with 2 parameters: a self and the string.
// In this case, offset == 1. Offset is always 1 for setters or methods.
//
// Offset is always 0 for constructors.
//
// For constructors, setters and methods, we can further increase offset + 1
// if the first parameter is an instance of Page.
//
// Finally, if the JS function is called with _more_ parameters and
// the last parameter in Zig is an array, we'll try to slurp the additional
// parameters into the array.
fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: FunctionCallbackInfo) !ParameterTypes(F) {
var args: ParameterTypes(F) = undefined;
const params = @typeInfo(F).@"fn".params[offset..];
// Except for the constructor, the first parameter is always `self`
// This isn't something we'll bind from JS, so skip it.
const params_to_map = blk: {
if (params.len == 0) {
return args;
}
// If the last parameter is the Page, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
if (comptime isPage(params[params.len - 1].type.?)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = local.ctx.page;
break :blk params[0 .. params.len - 1];
}
// we have neither a Page nor a JsObject. All params must be
// bound to a JavaScript value.
break :blk params;
};
if (params_to_map.len == 0) {
return args;
}
const js_parameter_count = info.length();
const last_js_parameter = params_to_map.len - 1;
var is_variadic = false;
{
// This is going to get complicated. If the last Zig parameter
// is a slice AND the corresponding javascript parameter is
// NOT an an array, then we'll treat it as a variadic.
const last_parameter_type = params_to_map[params_to_map.len - 1].type.?;
const last_parameter_type_info = @typeInfo(last_parameter_type);
if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local);
if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) {
is_variadic = true;
if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
} else if (js_parameter_count >= params_to_map.len) {
const arr = try local.call_arena.alloc(last_parameter_type_info.pointer.child, js_parameter_count - params_to_map.len + 1);
for (arr, last_js_parameter..) |*a, i| {
a.* = try local.jsValueToZig(slice_type, info.getArg(@intCast(i), local));
}
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = arr;
} else {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};
}
}
}
}
inline for (params_to_map, 0..) |param, i| {
const field_index = comptime i + offset;
if (comptime i == params_to_map.len - 1) {
if (is_variadic) {
break;
}
}
if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter (or 2nd last if there's a JsThis): " ++ @typeName(F));
} else if (i >= js_parameter_count) {
if (@typeInfo(param.type.?) != .optional) {
return error.InvalidArgument;
}
@field(args, tupleFieldName(field_index)) = null;
} else {
const js_val = info.getArg(@intCast(i), local);
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch {
return error.InvalidArgument;
};
}
}
return args;
}

View File

@@ -43,13 +43,13 @@ env: *Env,
page: *Page, page: *Page,
isolate: js.Isolate, isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
microtask_queue: *v8.MicrotaskQueue,
// The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>> // The v8::Global<v8::Context>. When necessary, we can create a v8::Local<<v8::Context>>
// from this, and we can free it when the context is done. // from this, and we can free it when the context is done.
handle: v8.Global, handle: v8.Global,
// True if the context is auto-entered,
entered: bool,
cpu_profiler: ?*v8.CpuProfiler = null, cpu_profiler: ?*v8.CpuProfiler = null,
heap_profiler: ?*v8.HeapProfiler = null, heap_profiler: ?*v8.HeapProfiler = null,
@@ -124,10 +124,6 @@ script_manager: ?*ScriptManager,
// Our macrotasks // Our macrotasks
scheduler: Scheduler, scheduler: Scheduler,
// Prevents us from enqueuing a microtask for this context while we're shutting
// down.
shutting_down: bool = false,
unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {}, unknown_properties: (if (IS_DEBUG) std.StringHashMapUnmanaged(UnknownPropertyStat) else void) = if (IS_DEBUG) .{} else {},
const ModuleEntry = struct { const ModuleEntry = struct {
@@ -148,17 +144,12 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null, resolver_promise: ?js.Promise.Global = null,
}; };
fn fromC(c_context: *const v8.Context) *Context { pub fn fromC(c_context: *const v8.Context) *Context {
const data = v8.v8__Context__GetEmbedderData(c_context, 1).?; return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
} }
pub fn fromIsolate(isolate: js.Isolate) *Context { pub fn fromIsolate(isolate: js.Isolate) *Context {
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?; return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?);
const data = v8.v8__Context__GetEmbedderData(v8_context, 1).?;
const big_int = js.BigInt{ .handle = @ptrCast(data) };
return @ptrFromInt(big_int.getUint64());
} }
pub fn deinit(self: *Context) void { pub fn deinit(self: *Context) void {
@@ -172,21 +163,15 @@ pub fn deinit(self: *Context) void {
}); });
} }
} }
defer self.env.app.arena_pool.release(self.arena);
const env = self.env;
defer env.app.arena_pool.release(self.arena);
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
const entered = self.enter(&hs); const entered = self.enter(&hs);
defer entered.exit(); defer entered.exit();
// We might have microtasks in the isolate that refence this context. The // this can release objects
// only option we have is to run them. But a microtask could queue another
// microtask, so we set the shutting_down flag, so that any such microtask
// will be a noop (this isn't automatic, when v8 calls our microtask callback
// the first thing we'll check is if self.shutting_down == true).
self.shutting_down = true;
self.env.runMicrotasks();
// can release objects
self.scheduler.deinit(); self.scheduler.deinit();
{ {
@@ -248,11 +233,12 @@ pub fn deinit(self: *Context) void {
} }
} }
if (self.entered) {
v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)));
}
v8.v8__Global__Reset(&self.handle); v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
// There can be other tasks associated with this context that we need to
// purge while the context is still alive.
_ = env.pumpMessageLoop();
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
} }
pub fn weakRef(self: *Context, obj: anytype) void { pub fn weakRef(self: *Context, obj: anytype) void {
@@ -333,11 +319,14 @@ pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate; const isolate = self.isolate;
js.HandleScope.init(&ls.handle_scope, isolate); js.HandleScope.init(&ls.handle_scope, isolate);
const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle));
v8.v8__Context__Enter(local_v8_context);
// TODO: add and init ls.hs for the handlescope // TODO: add and init ls.hs for the handlescope
ls.local = .{ ls.local = .{
.ctx = self, .ctx = self,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)),
.isolate = isolate, .isolate = isolate,
.handle = local_v8_context,
.call_arena = self.call_arena, .call_arena = self.call_arena,
}; };
} }
@@ -364,7 +353,6 @@ pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.G
extra = "(e)"; extra = "(e)";
} }
const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0); const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0);
const js_val = try ls.local.compileAndRun(full, null); const js_val = try ls.local.compileAndRun(full, null);
if (!js_val.isFunction()) { if (!js_val.isFunction()) {
return error.StringFunctionError; return error.StringFunctionError;
@@ -612,9 +600,18 @@ pub fn dynamicModuleCallback(
.isolate = self.isolate, .isolate = self.isolate,
}; };
const resource = js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| { const resource = blk: {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" }); const resource_value = js.Value{ .handle = resource_name.?, .local = &local };
return @constCast((local.rejectPromise("Out of memory") catch return null).handle); if (resource_value.isNullOrUndefined()) {
// will only be null / undefined in extreme cases (e.g. WPT tests)
// where you're
break :blk self.page.base();
}
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
};
}; };
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| { const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
@@ -998,13 +995,10 @@ pub fn queueSlotchangeDelivery(self: *Context) !void {
// But for these Context microtasks, we want to (a) make sure the context isn't // But for these Context microtasks, we want to (a) make sure the context isn't
// being shut down and (b) that it's entered. // being shut down and (b) that it's entered.
fn enqueueMicrotask(self: *Context, callback: anytype) void { fn enqueueMicrotask(self: *Context, callback: anytype) void {
self.isolate.enqueueMicrotask(struct { // Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotask(self.microtask_queue, self.isolate.handle, struct {
fn run(data: ?*anyopaque) callconv(.c) void { fn run(data: ?*anyopaque) callconv(.c) void {
const ctx: *Context = @ptrCast(@alignCast(data.?)); const ctx: *Context = @ptrCast(@alignCast(data.?));
if (ctx.shutting_down) {
return;
}
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs); const entered = ctx.enter(&hs);
defer entered.exit(); defer entered.exit();
@@ -1014,10 +1008,11 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
} }
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
self.isolate.enqueueMicrotaskFunc(cb); // Use context-specific microtask queue instead of isolate queue
v8.v8__MicrotaskQueue__EnqueueMicrotaskFunc(self.microtask_queue, self.isolate.handle, cb.handle);
} }
pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque) void) !*FinalizerCallback { pub fn createFinalizerCallback(self: *Context, global: v8.Global, ptr: *anyopaque, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void) !*FinalizerCallback {
const fc = try self.finalizer_callback_pool.create(); const fc = try self.finalizer_callback_pool.create();
fc.* = .{ fc.* = .{
.ctx = self, .ctx = self,
@@ -1037,10 +1032,10 @@ pub const FinalizerCallback = struct {
ctx: *Context, ctx: *Context,
ptr: *anyopaque, ptr: *anyopaque,
global: v8.Global, global: v8.Global,
finalizerFn: *const fn (ptr: *anyopaque) void, finalizerFn: *const fn (ptr: *anyopaque, page: *Page) void,
pub fn deinit(self: *FinalizerCallback) void { pub fn deinit(self: *FinalizerCallback) void {
self.finalizerFn(self.ptr); self.finalizerFn(self.ptr, self.ctx.page);
self.ctx.finalizer_callback_pool.destroy(self); self.ctx.finalizer_callback_pool.destroy(self);
} }
}; };

View File

@@ -39,6 +39,14 @@ const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
fn initClassIds() void {
inline for (JsApis, 0..) |JsApi, i| {
JsApi.Meta.class_id = i;
}
}
var class_id_once = std.once(initClassIds);
// The Env maps to a V8 isolate, which represents a isolated sandbox for // The Env maps to a V8 isolate, which represents a isolated sandbox for
// executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings, // executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings,
// and it's where we'll start ExecutionWorlds, which actually execute JavaScript. // and it's where we'll start ExecutionWorlds, which actually execute JavaScript.
@@ -54,7 +62,8 @@ platform: *const Platform,
// the global isolate // the global isolate
isolate: js.Isolate, isolate: js.Isolate,
contexts: std.ArrayList(*js.Context), contexts: [64]*Context,
context_count: usize,
// just kept around because we need to free it on deinit // just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams, isolate_params: *v8.CreateParams,
@@ -73,11 +82,28 @@ global_template: v8.Eternal,
// Inspector associated with the Isolate. Exists when CDP is being used. // Inspector associated with the Isolate. Exists when CDP is being used.
inspector: ?*Inspector, inspector: ?*Inspector,
// We can store data in a v8::Object's Private data bag. The keys are v8::Private
// which an be created once per isolaet.
private_symbols: PrivateSymbols,
microtask_queues_are_running: bool,
pub const InitOpts = struct { pub const InitOpts = struct {
with_inspector: bool = false, with_inspector: bool = false,
}; };
pub fn init(app: *App, opts: InitOpts) !Env { pub fn init(app: *App, opts: InitOpts) !Env {
if (comptime IS_DEBUG) {
comptime {
// V8 requirement for any data using SetAlignedPointerInInternalField
const a = @alignOf(@import("TaggedOpaque.zig"));
std.debug.assert(a >= 2 and a % 2 == 0);
}
}
// Initialize class IDs once before any V8 work
class_id_once.call();
const allocator = app.allocator; const allocator = app.allocator;
const snapshot = &app.snapshot; const snapshot = &app.snapshot;
@@ -114,13 +140,13 @@ pub fn init(app: *App, opts: InitOpts) !Env {
errdefer allocator.free(templates); errdefer allocator.free(templates);
var global_eternal: v8.Eternal = undefined; var global_eternal: v8.Eternal = undefined;
var private_symbols: PrivateSymbols = undefined;
{ {
var temp_scope: js.HandleScope = undefined; var temp_scope: js.HandleScope = undefined;
temp_scope.init(isolate); temp_scope.init(isolate);
defer temp_scope.deinit(); defer temp_scope.deinit();
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |_, i| {
JsApi.Meta.class_id = i;
const data = v8.v8__Isolate__GetDataFromSnapshotOnce(isolate_handle, snapshot.data_start + i); const data = v8.v8__Isolate__GetDataFromSnapshotOnce(isolate_handle, snapshot.data_start + i);
const function_handle: *const v8.FunctionTemplate = @ptrCast(data); const function_handle: *const v8.FunctionTemplate = @ptrCast(data);
// Make function template eternal // Make function template eternal
@@ -152,7 +178,24 @@ pub fn init(app: *App, opts: InitOpts) !Env {
.data = null, .data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
}); });
// I don't 100% understand this. We actually set this up in the snapshot,
// but for the global instance, it doesn't work. SetIndexedHandler and
// SetNamedHandler are set on the Instance template, and that's the key
// difference. The context has its own global instance, so we need to set
// these back up directly on it. There might be a better way to do this.
v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{
.getter = Window.JsApi.index.getter,
.setter = null,
.query = null,
.deleter = null,
.enumerator = null,
.definer = null,
.descriptor = null,
.data = null,
.flags = 0,
});
v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal); v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal);
private_symbols = PrivateSymbols.init(isolate_handle);
} }
var inspector: ?*js.Inspector = null; var inspector: ?*js.Inspector = null;
@@ -163,22 +206,25 @@ pub fn init(app: *App, opts: InitOpts) !Env {
return .{ return .{
.app = app, .app = app,
.context_id = 0, .context_id = 0,
.contexts = .empty, .contexts = undefined,
.context_count = 0,
.isolate = isolate, .isolate = isolate,
.platform = &app.platform, .platform = &app.platform,
.templates = templates, .templates = templates,
.isolate_params = params, .isolate_params = params,
.inspector = inspector, .inspector = inspector,
.eternal_function_templates = eternal_function_templates,
.global_template = global_eternal, .global_template = global_eternal,
.private_symbols = private_symbols,
.microtask_queues_are_running = false,
.eternal_function_templates = eternal_function_templates,
}; };
} }
pub fn deinit(self: *Env) void { pub fn deinit(self: *Env) void {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(self.contexts.items.len == 0); std.debug.assert(self.context_count == 0);
} }
for (self.contexts.items) |ctx| { for (self.contexts[0..self.context_count]) |ctx| {
ctx.deinit(); ctx.deinit();
} }
@@ -187,10 +233,9 @@ pub fn deinit(self: *Env) void {
i.deinit(allocator); i.deinit(allocator);
} }
self.contexts.deinit(allocator);
allocator.free(self.templates); allocator.free(self.templates);
allocator.free(self.eternal_function_templates); allocator.free(self.eternal_function_templates);
self.private_symbols.deinit();
self.isolate.exit(); self.isolate.exit();
self.isolate.deinit(); self.isolate.deinit();
@@ -198,7 +243,7 @@ pub fn deinit(self: *Env) void {
allocator.destroy(self.isolate_params); allocator.destroy(self.isolate_params);
} }
pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { pub fn createContext(self: *Env, page: *Page) !*Context {
const context_arena = try self.app.arena_pool.acquire(); const context_arena = try self.app.arena_pool.acquire();
errdefer self.app.arena_pool.release(context_arena); errdefer self.app.arena_pool.release(context_arena);
@@ -207,26 +252,45 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
hs.init(isolate); hs.init(isolate);
defer hs.deinit(); defer hs.deinit();
// Create a per-context microtask queue for isolation
const microtask_queue = v8.v8__MicrotaskQueue__New(isolate.handle, v8.kExplicit).?;
errdefer v8.v8__MicrotaskQueue__DELETE(microtask_queue);
// Get the global template that was created once per isolate // Get the global template that was created once per isolate
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?)); const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?; v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
const v8_context = v8.v8__Context__New__Config(isolate.handle, &.{
.global_template = global_template,
.global_object = null,
.microtask_queue = microtask_queue,
}).?;
// Create the v8::Context and wrap it in a v8::Global // Create the v8::Context and wrap it in a v8::Global
var context_global: v8.Global = undefined; var context_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, v8_context, &context_global); v8.v8__Global__New(isolate.handle, v8_context, &context_global);
// our window wrapped in a v8::Global // get the global object for the context, this maps to our Window
const global_obj = v8.v8__Context__Global(v8_context).?; const global_obj = v8.v8__Context__Global(v8_context).?;
{
// Store our TAO inside the internal field of the global object. This
// maps the v8::Object -> Zig instance. Almost all objects have this, and
// it gets setup automatically as objects are created, but the Window
// object already exists in v8 (it's the global) so we manually create
// the mapping here.
const tao = try context_arena.create(@import("TaggedOpaque.zig"));
tao.* = .{
.value = @ptrCast(page.window),
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
.subtype = .node, // this probably isn't right, but it's what we've been doing all along
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
}
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined; var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global); v8.v8__Global__New(isolate.handle, global_obj, &global_global);
if (enter) {
v8.v8__Context__Enter(v8_context);
}
errdefer if (enter) {
v8.v8__Context__Exit(v8_context);
};
const context_id = self.context_id; const context_id = self.context_id;
self.context_id = context_id + 1; self.context_id = context_id + 1;
@@ -235,12 +299,12 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
.env = self, .env = self,
.page = page, .page = page,
.id = context_id, .id = context_id,
.entered = enter,
.isolate = isolate, .isolate = isolate,
.arena = context_arena, .arena = context_arena,
.handle = context_global, .handle = context_global,
.templates = self.templates, .templates = self.templates,
.call_arena = page.call_arena, .call_arena = page.call_arena,
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager, .script_manager = &page._script_manager,
.scheduler = .init(context_arena), .scheduler = .init(context_arena),
.finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator), .finalizer_callback_pool = std.heap.MemoryPool(Context.FinalizerCallback).init(self.app.allocator),
@@ -249,17 +313,24 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
// Store a pointer to our context inside the v8 context so that, given // Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out // a v8 context, we can get our context out
const data = isolate.initBigInt(@intFromPtr(context)); v8.v8__Context__SetAlignedPointerInEmbedderData(v8_context, 1, @ptrCast(context));
v8.v8__Context__SetEmbedderData(v8_context, 1, @ptrCast(data.handle));
const count = self.context_count;
if (count >= self.contexts.len) {
return error.TooManyContexts;
}
self.contexts[count] = context;
self.context_count = count + 1;
try self.contexts.append(self.app.allocator, context);
return context; return context;
} }
pub fn destroyContext(self: *Env, context: *Context) void { pub fn destroyContext(self: *Env, context: *Context) void {
for (self.contexts.items, 0..) |ctx, i| { for (self.contexts[0..self.context_count], 0..) |ctx, i| {
if (ctx == context) { if (ctx == context) {
_ = self.contexts.swapRemove(i); // Swap with last element and decrement count
self.context_count -= 1;
self.contexts[i] = self.contexts[self.context_count];
break; break;
} }
} else { } else {
@@ -277,16 +348,26 @@ pub fn destroyContext(self: *Env, context: *Context) void {
} }
context.deinit(); context.deinit();
isolate.notifyContextDisposed();
} }
pub fn runMicrotasks(self: *const Env) void { pub fn runMicrotasks(self: *Env) void {
self.isolate.performMicrotasksCheckpoint(); if (self.microtask_queues_are_running == false) {
const v8_isolate = self.isolate.handle;
self.microtask_queues_are_running = true;
defer self.microtask_queues_are_running = false;
var i: usize = 0;
while (i < self.context_count) : (i += 1) {
const ctx = self.contexts[i];
v8.v8__MicrotaskQueue__PerformCheckpoint(ctx.microtask_queue, v8_isolate);
}
}
} }
pub fn runMacrotasks(self: *Env) !?u64 { pub fn runMacrotasks(self: *Env) !?u64 {
var ms_to_next_task: ?u64 = null; var ms_to_next_task: ?u64 = null;
for (self.contexts.items) |ctx| { for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests // I hate this comptime check as much as you do. But we have tests
// which rely on short execution before shutdown. In real world, it's // which rely on short execution before shutdown. In real world, it's
@@ -309,12 +390,31 @@ pub fn runMacrotasks(self: *Env) !?u64 {
return ms_to_next_task; return ms_to_next_task;
} }
pub fn pumpMessageLoop(self: *const Env) bool { pub fn pumpMessageLoop(self: *const Env) void {
var hs: v8.HandleScope = undefined; var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle); v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs); defer v8.v8__HandleScope__DESTRUCT(&hs);
return v8.v8__Platform__PumpMessageLoop(self.platform.handle, self.isolate.handle, false); const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Platform__PumpMessageLoop(platform, isolate, false)) {}
}
pub fn hasBackgroundTasks(self: *const Env) bool {
return v8.v8__Isolate__HasPendingBackgroundTasks(self.isolate.handle);
}
pub fn waitForBackgroundTasks(self: *Env) void {
var hs: v8.HandleScope = undefined;
v8.v8__HandleScope__CONSTRUCT(&hs, self.isolate.handle);
defer v8.v8__HandleScope__DESTRUCT(&hs);
const isolate = self.isolate.handle;
const platform = self.platform.handle;
while (v8.v8__Isolate__HasPendingBackgroundTasks(isolate)) {
_ = v8.v8__Platform__PumpMessageLoop(platform, isolate, true);
self.runMicrotasks();
}
} }
pub fn runIdleTasks(self: *const Env) void { pub fn runIdleTasks(self: *const Env) void {
@@ -405,3 +505,19 @@ fn oomCallback(c_location: [*c]const u8, details: ?*const v8.OOMDetails) callcon
log.fatal(.app, "V8 OOM", .{ .location = location, .detail = detail }); log.fatal(.app, "V8 OOM", .{ .location = location, .detail = detail });
@import("../../crash_handler.zig").crash("V8 OOM", .{ .location = location, .detail = detail }, @returnAddress()); @import("../../crash_handler.zig").crash("V8 OOM", .{ .location = location, .detail = detail }, @returnAddress());
} }
const PrivateSymbols = struct {
const Private = @import("Private.zig");
child_nodes: Private,
fn init(isolate: *v8.Isolate) PrivateSymbols {
return .{
.child_nodes = Private.init(isolate, "child_nodes"),
};
}
fn deinit(self: *PrivateSymbols) void {
self.child_nodes.deinit();
}
};

View File

@@ -28,7 +28,11 @@ handle: v8.HandleScope,
// value, as v8 will then have taken the address of the function-scopped (and no // value, as v8 will then have taken the address of the function-scopped (and no
// longer valid) local. // longer valid) local.
pub fn init(self: *HandleScope, isolate: js.Isolate) void { pub fn init(self: *HandleScope, isolate: js.Isolate) void {
v8.v8__HandleScope__CONSTRUCT(&self.handle, isolate.handle); self.initWithIsolateHandle(isolate.handle);
}
pub fn initWithIsolateHandle(self: *HandleScope, isolate: *v8.Isolate) void {
v8.v8__HandleScope__CONSTRUCT(&self.handle, isolate);
} }
pub fn deinit(self: *HandleScope) void { pub fn deinit(self: *HandleScope) void {

View File

@@ -241,8 +241,6 @@ pub const Session = struct {
msg.ptr, msg.ptr,
msg.len, msg.len,
); );
v8.v8__Isolate__PerformMicrotaskCheckpoint(isolate);
} }
// Gets a value by object ID regardless of which context it is in. // Gets a value by object ID regardless of which context it is in.
@@ -364,9 +362,8 @@ pub fn getTaggedOpaque(value: *const v8.Value) ?*TaggedOpaque {
return null; return null;
} }
const external_value = v8.v8__Object__GetInternalField(value, 0).?; const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(value, 0).?;
const external_data = v8.v8__External__Value(external_value).?; return @ptrCast(@alignCast(tao_ptr));
return @ptrCast(@alignCast(external_data));
} }
fn cZigStringToString(s: v8.CZigString) ?[]const u8 { fn cZigStringToString(s: v8.CZigString) ?[]const u8 {

View File

@@ -41,18 +41,6 @@ pub fn exit(self: Isolate) void {
v8.v8__Isolate__Exit(self.handle); v8.v8__Isolate__Exit(self.handle);
} }
pub fn performMicrotasksCheckpoint(self: Isolate) void {
v8.v8__Isolate__PerformMicrotaskCheckpoint(self.handle);
}
pub fn enqueueMicrotask(self: Isolate, callback: anytype, data: anytype) void {
v8.v8__Isolate__EnqueueMicrotask(self.handle, callback, data);
}
pub fn enqueueMicrotaskFunc(self: Isolate, function: js.Function) void {
v8.v8__Isolate__EnqueueMicrotaskFunc(self.handle, function.handle);
}
pub fn lowMemoryNotification(self: Isolate) void { pub fn lowMemoryNotification(self: Isolate) void {
v8.v8__Isolate__LowMemoryNotification(self.handle); v8.v8__Isolate__LowMemoryNotification(self.handle);
} }

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const Page = @import("../Page.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const string = @import("../../string.zig"); const string = @import("../../string.zig");
@@ -75,8 +76,20 @@ pub fn newArray(self: *const Local, len: u32) js.Array {
}; };
} }
/// Creates a new typed array. Memory is owned by JS context.
/// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Typed_arrays
pub fn createTypedArray(self: *const Local, comptime array_type: js.ArrayType, size: usize) js.ArrayBufferRef(array_type) {
return .init(self, size);
}
pub fn runMacrotasks(self: *const Local) void {
const env = self.ctx.env;
env.pumpMessageLoop();
env.runMicrotasks(); // macrotasks can cause microtasks to queue
}
pub fn runMicrotasks(self: *const Local) void { pub fn runMicrotasks(self: *const Local) void {
self.isolate.performMicrotasksCheckpoint(); self.ctx.env.runMicrotasks();
} }
// == Executors == // == Executors ==
@@ -181,11 +194,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
.subtype = if (@hasDecl(JsApi.Meta, "subtype")) JsApi.Meta.subype else .node, .subtype = if (@hasDecl(JsApi.Meta, "subtype")) JsApi.Meta.subype else .node,
}; };
// Skip setting internal field for the global object (Window) v8.v8__Object__SetAlignedPointerInInternalField(js_obj.handle, 0, tao);
// Window accessors get the instance from context.page.window instead
// if (resolved.class_id != @import("../webapi/Window.zig").JsApi.Meta.class_id) {
v8.v8__Object__SetInternalField(js_obj.handle, 0, isolate.createExternal(tao));
// }
} else { } else {
// If the struct is empty, we don't need to do all // If the struct is empty, we don't need to do all
// the TOA stuff and setting the internal data. // the TOA stuff and setting the internal data.
@@ -214,7 +223,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc); try ctx.finalizer_callbacks.put(ctx.arena, @intFromPtr(resolved.ptr), fc);
} }
conditionallyFlagHandoff(value); conditionallyReference(value);
if (@hasDecl(JsApi.Meta, "weak")) { if (@hasDecl(JsApi.Meta, "weak")) {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(JsApi.Meta.weak == true); std.debug.assert(JsApi.Meta.weak == true);
@@ -310,7 +319,17 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
js.Value => return value, js.Value => return value,
js.Exception => return .{ .local = self, .handle = isolate.throwException(value.handle) }, js.Exception => return .{ .local = self, .handle = isolate.throwException(value.handle) },
js.ArrayBufferRef(.int8).Global, js.ArrayBufferRef(.uint8).Global,
js.ArrayBufferRef(.uint8_clamped).Global, js.ArrayBufferRef(.int16).Global,
js.ArrayBufferRef(.uint16).Global, js.ArrayBufferRef(.int32).Global,
js.ArrayBufferRef(.uint32).Global, js.ArrayBufferRef(.float16).Global,
js.ArrayBufferRef(.float32).Global, js.ArrayBufferRef(.float64).Global,
=> {
return .{ .local = self, .handle = value.local(self).handle };
},
inline inline
js.Array,
js.Function, js.Function,
js.Object, js.Object,
js.Promise, js.Promise,
@@ -442,6 +461,13 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
return js_val; return js_val;
} }
if (comptime o.child == js.NullableString) {
if (js_val.isUndefined()) {
return null;
}
return .{ .value = try js_val.toStringSlice() };
}
if (comptime o.child == js.Object) { if (comptime o.child == js.Object) {
return js.Object{ return js.Object{
.local = self, .local = self,
@@ -1043,7 +1069,7 @@ const Resolved = struct {
class_id: u16, class_id: u16,
prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry, prototype_chain: []const @import("TaggedOpaque.zig").PrototypeChainEntry,
finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null, finalizer_from_v8: ?*const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void = null,
finalizer_from_zig: ?*const fn (ptr: *anyopaque) void = null, finalizer_from_zig: ?*const fn (ptr: *anyopaque, page: *Page) void = null,
}; };
pub fn resolveValue(value: anytype) Resolved { pub fn resolveValue(value: anytype) Resolved {
const T = bridge.Struct(@TypeOf(value)); const T = bridge.Struct(@TypeOf(value));
@@ -1082,14 +1108,14 @@ fn resolveT(comptime T: type, value: *anyopaque) Resolved {
}; };
} }
fn conditionallyFlagHandoff(value: anytype) void { fn conditionallyReference(value: anytype) void {
const T = bridge.Struct(@TypeOf(value)); const T = bridge.Struct(@TypeOf(value));
if (@hasField(T, "_v8_handoff")) { if (@hasDecl(T, "acquireRef")) {
value._v8_handoff = true; value.acquireRef();
return; return;
} }
if (@hasField(T, "_proto")) { if (@hasField(T, "_proto")) {
conditionallyFlagHandoff(value._proto); conditionallyReference(value._proto);
} }
} }
@@ -1164,6 +1190,9 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
if (js_val.isSymbol()) { if (js_val.isSymbol()) {
const symbol_handle = v8.v8__Symbol__Description(@ptrCast(js_val.handle), self.isolate.handle).?; const symbol_handle = v8.v8__Symbol__Description(@ptrCast(js_val.handle), self.isolate.handle).?;
if (v8.v8__Value__IsUndefined(symbol_handle)) {
return writer.writeAll("undefined (symbol)");
}
return writer.print("{f} (symbol)", .{js.String{ .local = self, .handle = @ptrCast(symbol_handle) }}); return writer.print("{f} (symbol)", .{js.String{ .local = self, .handle = @ptrCast(symbol_handle) }});
} }
const js_val_str = try js_val.toStringSlice(); const js_val_str = try js_val.toStringSlice();
@@ -1187,13 +1216,20 @@ fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnman
gop.value_ptr.* = {}; gop.value_ptr.* = {};
} }
const names_arr = js_obj.getOwnPropertyNames();
const len = names_arr.len();
if (depth > 20) { if (depth > 20) {
return writer.writeAll("...deeply nested object..."); return writer.writeAll("...deeply nested object...");
} }
const own_len = js_obj.getOwnPropertyNames().len();
const names_arr = js_obj.getOwnPropertyNames() catch {
return writer.writeAll("...invalid object...");
};
const len = names_arr.len();
const own_len = blk: {
const own_names = js_obj.getOwnPropertyNames() catch break :blk 0;
break :blk own_names.len();
};
if (own_len == 0) { if (own_len == 0) {
const js_val_str = try js_val.toStringSlice(); const js_val_str = try js_val.toStringSlice();
if (js_val_str.len > 2000) { if (js_val_str.len > 2000) {
@@ -1312,6 +1348,7 @@ pub const Scope = struct {
handle_scope: js.HandleScope, handle_scope: js.HandleScope,
pub fn deinit(self: *Scope) void { pub fn deinit(self: *Scope) void {
v8.v8__Context__Exit(self.local.handle);
self.handle_scope.deinit(); self.handle_scope.deinit();
} }

View File

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

View File

@@ -0,0 +1,42 @@
// 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.zig");
const v8 = js.v8;
const Private = @This();
// Unlike most types, we always store the Private as a Global. It makes more
// sense for this type given how it's used.
handle: v8.Global,
pub fn init(isolate: *v8.Isolate, name: []const u8) Private {
const v8_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const private_handle = v8.v8__Private__New(isolate, v8_name);
var global: v8.Global = undefined;
v8.v8__Global__New(isolate, private_handle, &global);
return .{
.handle = global,
};
}
pub fn deinit(self: *Private) void {
v8.v8__Global__Reset(&self.handle);
}

View File

@@ -202,12 +202,16 @@ pub fn create() !Snapshot {
const name = JsApi.Meta.name; const name = JsApi.Meta.name;
const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const illegal_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result2: v8.MaybeBool = undefined; var maybe_result2: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, illegal_class_name, func, &maybe_result2); v8.v8__Object__DefineOwnProperty(global_obj, context, illegal_class_name, func, 0, &maybe_result2);
} else { } else {
const name = JsApi.Meta.name; const name = JsApi.Meta.name;
const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
var maybe_result: v8.MaybeBool = undefined; var maybe_result: v8.MaybeBool = undefined;
v8.v8__Object__Set(global_obj, context, v8_class_name, func, &maybe_result); var properties: v8.PropertyAttribute = v8.None;
if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == false) {
properties |= v8.DontEnum;
}
v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result);
} }
} }
} }
@@ -278,9 +282,13 @@ fn hasNamedIndexedGetter(comptime JsApi: type) bool {
fn countExternalReferences() comptime_int { fn countExternalReferences() comptime_int {
@setEvalBranchQuota(100_000); @setEvalBranchQuota(100_000);
// +1 for the illegal constructor callback var count: comptime_int = 0;
var count: comptime_int = 1;
var has_non_template_property: bool = false; // +1 for the illegal constructor callback shared by various types
count += 1;
// +1 for the noop function shared by various types
count += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
// Constructor (only if explicit) // Constructor (only if explicit)
@@ -300,17 +308,18 @@ fn countExternalReferences() comptime_int {
const T = @TypeOf(value); const T = @TypeOf(value);
if (T == bridge.Accessor) { if (T == bridge.Accessor) {
count += 1; // getter count += 1; // getter
if (value.setter != null) count += 1; // setter if (value.setter != null) {
count += 1;
}
} else if (T == bridge.Function) { } else if (T == bridge.Function) {
count += 1; count += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) { } else if (T == bridge.Iterator) {
count += 1; count += 1;
} else if (T == bridge.Indexed) { } else if (T == bridge.Indexed) {
count += 1; count += 1;
if (value.enumerator != null) {
count += 1;
}
} else if (T == bridge.NamedIndexed) { } else if (T == bridge.NamedIndexed) {
count += 1; // getter count += 1; // getter
if (value.setter != null) count += 1; if (value.setter != null) count += 1;
@@ -319,10 +328,6 @@ fn countExternalReferences() comptime_int {
} }
} }
if (has_non_template_property) {
count += 1;
}
// In debug mode, add unknown property callbacks for types without NamedIndexed // In debug mode, add unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
@@ -342,7 +347,8 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback)); references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1; idx += 1;
var has_non_template_property = false; references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
idx += 1;
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
@@ -369,16 +375,16 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} else if (T == bridge.Function) { } else if (T == bridge.Function) {
references[idx] = @bitCast(@intFromPtr(value.func)); references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1; idx += 1;
} else if (T == bridge.Property) {
if (value.template == false) {
has_non_template_property = true;
}
} else if (T == bridge.Iterator) { } else if (T == bridge.Iterator) {
references[idx] = @bitCast(@intFromPtr(value.func)); references[idx] = @bitCast(@intFromPtr(value.func));
idx += 1; idx += 1;
} else if (T == bridge.Indexed) { } else if (T == bridge.Indexed) {
references[idx] = @bitCast(@intFromPtr(value.getter)); references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1; idx += 1;
if (value.enumerator) |enumerator| {
references[idx] = @bitCast(@intFromPtr(enumerator));
idx += 1;
}
} else if (T == bridge.NamedIndexed) { } else if (T == bridge.NamedIndexed) {
references[idx] = @bitCast(@intFromPtr(value.getter)); references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1; idx += 1;
@@ -394,11 +400,6 @@ fn collectExternalReferences() [countExternalReferences()]isize {
} }
} }
if (has_non_template_property) {
references[idx] = @bitCast(@intFromPtr(&bridge.Property.getter));
idx += 1;
}
// In debug mode, collect unknown property callbacks for types without NamedIndexed // In debug mode, collect unknown property callbacks for types without NamedIndexed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
inline for (JsApis) |JsApi| { inline for (JsApis) |JsApi| {
@@ -429,9 +430,12 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
}; };
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?); const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) { {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template); const internal_field_count = comptime countInternalFields(JsApi);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, 1); if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
} }
const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi); const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi);
const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len)); const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len));
@@ -439,10 +443,48 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
return template; return template;
} }
pub fn countInternalFields(comptime JsApi: type) u8 {
var last_used_id = 0;
var cache_count: u8 = 0;
inline for (@typeInfo(JsApi).@"struct".decls) |d| {
const name: [:0]const u8 = d.name;
const value = @field(JsApi, name);
const definition = @TypeOf(value);
switch (definition) {
inline bridge.Accessor, bridge.Function => {
const cache = value.cache orelse continue;
if (cache != .internal) {
continue;
}
// We assert that they are declared in-order. This isn't necessary
// but I don't want to do anything fancy to look for gaps or
// duplicates.
const internal_id = cache.internal;
if (internal_id != last_used_id + 1) {
@compileError(@typeName(JsApi) ++ "." ++ name ++ " has a non-monotonic cache index");
}
last_used_id = internal_id;
cache_count += 1; // this is just last_used, but it's more explicit this way
},
else => {},
}
}
if (@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
return cache_count;
}
// we need cache_count internal fields, + 1 for the TAO pointer (the v8 -> Zig)
// mapping) itself.
return cache_count + 1;
}
// Attaches JsApi members to the prototype template (normal case) // Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void { fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false; var has_named_index_getter = false;
@@ -460,14 +502,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) { if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback); v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
} else { } else {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback); v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
} }
} else { } else {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(value.static == false); std.debug.assert(value.static == false);
} }
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?); const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(target, js_name, getter_callback, setter_callback); v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
} }
}, },
bridge.Function => { bridge.Function => {
@@ -476,16 +518,16 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
if (value.static) { if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
} else { } else {
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
} }
}, },
bridge.Indexed => { bridge.Indexed => {
var configuration: v8.IndexedPropertyHandlerConfiguration = .{ var configuration: v8.IndexedPropertyHandlerConfiguration = .{
.getter = value.getter, .getter = value.getter,
.enumerator = value.enumerator,
.setter = null, .setter = null,
.query = null, .query = null,
.deleter = null, .deleter = null,
.enumerator = null,
.definer = null, .definer = null,
.descriptor = null, .descriptor = null,
.data = null, .data = null,
@@ -514,7 +556,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
v8.v8__Symbol__GetAsyncIterator(isolate) v8.v8__Symbol__GetAsyncIterator(isolate)
else else
v8.v8__Symbol__GetIterator(isolate); v8.v8__Symbol__GetIterator(isolate);
v8.v8__Template__Set(@ptrCast(target), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None);
}, },
bridge.Property => { bridge.Property => {
const js_value = switch (value.value) { const js_value = switch (value.value) {
@@ -523,22 +565,14 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
}; };
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.template == false) { {
// not defined on the template, only on the instance. This const flags = if (value.readonly) v8.ReadOnly + v8.DontDelete else 0;
// is like an Accessor, but because the value is known at v8.v8__Template__Set(@ptrCast(prototype), js_name, js_value, flags);
// compile time, we skip _a lot_ of code and quickly return }
// the hard-coded value
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ if (value.template) {
.callback = bridge.Property.getter, // apply it both to the type itself (e.g. Node.Elem)
.data = js_value,
.side_effect_type = v8.kSideEffectType_HasSideEffectToReceiver,
}));
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(target, js_name, getter_callback);
} else {
// apply it both to the type itself
v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete); v8.v8__Template__Set(@ptrCast(template), js_name, js_value, v8.ReadOnly + v8.DontDelete);
// and to instances of the type
v8.v8__Template__Set(@ptrCast(target), js_name, js_value, v8.ReadOnly + v8.DontDelete);
} }
}, },
bridge.Constructor => {}, // already handled in generateConstructor bridge.Constructor => {}, // already handled in generateConstructor

View File

@@ -95,33 +95,6 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
} }
const internal_field_count = v8.v8__Object__InternalFieldCount(js_obj_handle); const internal_field_count = v8.v8__Object__InternalFieldCount(js_obj_handle);
// Special case for Window: the global object doesn't have internal fields
// Window instance is stored in context.page.window instead
if (internal_field_count == 0) {
// Normally, this would be an error. All JsObject that map to a Zig type
// are either `empty_with_no_proto` (handled above) or have an
// interalFieldCount. The only exception to that is the Window...
const isolate = v8.v8__Object__GetIsolate(js_obj_handle).?;
const context = js.Context.fromIsolate(.{ .handle = isolate });
const Window = @import("../webapi/Window.zig");
if (T == Window) {
return context.page.window;
}
// ... Or the window's prototype.
// We could make this all comptime-fancy, but it's easier to hard-code
// the EventTarget
const EventTarget = @import("../webapi/EventTarget.zig");
if (T == EventTarget) {
return context.page.window._proto;
}
// Type not found in Window's prototype chain
return error.InvalidArgument;
}
// if it isn't an empty struct, then the v8.Object should have an // if it isn't an empty struct, then the v8.Object should have an
// InternalFieldCount > 0, since our toa pointer should be embedded // InternalFieldCount > 0, since our toa pointer should be embedded
// at index 0 of the internal field count. // at index 0 of the internal field count.
@@ -133,8 +106,8 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
@compileError("unknown Zig type: " ++ @typeName(R)); @compileError("unknown Zig type: " ++ @typeName(R));
} }
const internal_field_handle = v8.v8__Object__GetInternalField(js_obj_handle, 0).?; const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?;
const tao: *TaggedOpaque = @ptrCast(@alignCast(v8.v8__External__Value(internal_field_handle))); const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr));
const expected_type_index = bridge.JsApiLookup.getId(JsApi); const expected_type_index = bridge.JsApiLookup.getId(JsApi);
const prototype_chain = tao.prototype_chain[0..tao.prototype_len]; const prototype_chain = tao.prototype_chain[0..tao.prototype_len];

View File

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

View File

@@ -38,16 +38,16 @@ pub fn Builder(comptime T: type) type {
return Constructor.init(T, func, opts); return Constructor.init(T, func, opts);
} }
pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: Accessor.Opts) Accessor { pub fn accessor(comptime getter: anytype, comptime setter: anytype, comptime opts: Caller.Function.Opts) Accessor {
return Accessor.init(T, getter, setter, opts); return Accessor.init(T, getter, setter, opts);
} }
pub fn function(comptime func: anytype, comptime opts: Function.Opts) Function { pub fn function(comptime func: anytype, comptime opts: Caller.Function.Opts) Function {
return Function.init(T, func, opts); return Function.init(T, func, opts);
} }
pub fn indexed(comptime getter_func: anytype, comptime opts: Indexed.Opts) Indexed { pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, opts); return Indexed.init(T, getter_func, enumerator_func, opts);
} }
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
@@ -104,11 +104,11 @@ pub fn Builder(comptime T: type) type {
return entries; return entries;
} }
pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool) void) Finalizer { pub fn finalizer(comptime func: *const fn (self: *T, shutdown: bool, page: *Page) void) Finalizer {
return .{ return .{
.from_zig = struct { .from_zig = struct {
fn wrap(ptr: *anyopaque) void { fn wrap(ptr: *anyopaque, page: *Page) void {
func(@ptrCast(@alignCast(ptr)), true); func(@ptrCast(@alignCast(ptr)), true, page);
} }
}.wrap, }.wrap,
@@ -120,7 +120,7 @@ pub fn Builder(comptime T: type) type {
const ctx = fc.ctx; const ctx = fc.ctx;
const value_ptr = fc.ptr; const value_ptr = fc.ptr;
if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { if (ctx.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false); func(@ptrCast(@alignCast(value_ptr)), false, ctx.page);
ctx.release(value_ptr); ctx.release(value_ptr);
} else { } else {
// A bit weird, but v8 _requires_ that we release it // A bit weird, but v8 _requires_ that we release it
@@ -160,44 +160,25 @@ pub const Constructor = struct {
pub const Function = struct { pub const Function = struct {
static: bool, static: bool,
arity: usize, arity: usize,
noop: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void, func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
const Opts = struct { fn init(comptime T: type, comptime func: anytype, comptime opts: Caller.Function.Opts) Function {
static: bool = false,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
};
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Function {
return .{ return .{
.cache = opts.cache,
.static = opts.static, .static = opts.static,
.arity = getArity(@TypeOf(func)), .arity = getArity(@TypeOf(func)),
.func = struct { .func = if (opts.noop) noopFunction else struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; Caller.Function.call(T, handle.?, func, opts);
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, func, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
} }
}.wrap, }.wrap,
}; };
} }
pub fn noopFunction(_: ?*const v8.FunctionCallbackInfo) callconv(.c) void {}
fn getArity(comptime T: type) usize { fn getArity(comptime T: type) usize {
var count: usize = 0; var count: usize = 0;
var params = @typeInfo(T).@"fn".params; var params = @typeInfo(T).@"fn".params;
@@ -217,42 +198,20 @@ pub const Function = struct {
pub const Accessor = struct { pub const Accessor = struct {
static: bool = false, static: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
const Opts = struct { fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Caller.Function.Opts) Accessor {
static: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,
dom_exception: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Opts) Accessor {
var accessor = Accessor{ var accessor = Accessor{
.cache = opts.cache,
.static = opts.static, .static = opts.static,
}; };
if (@typeInfo(@TypeOf(getter)) != .null) { if (@typeInfo(@TypeOf(getter)) != .null) {
accessor.getter = struct { accessor.getter = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; Caller.Function.call(T, handle.?, getter, opts);
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
if (comptime opts.static) {
caller.function(T, getter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} else {
caller.method(T, getter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
} }
}.wrap; }.wrap;
} }
@@ -260,16 +219,7 @@ pub const Accessor = struct {
if (@typeInfo(@TypeOf(setter)) != .null) { if (@typeInfo(@TypeOf(setter)) != .null) {
accessor.setter = struct { accessor.setter = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; Caller.Function.call(T, handle.?, setter, opts);
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, setter, handle.?, .{
.dom_exception = opts.dom_exception,
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
} }
}.wrap; }.wrap;
} }
@@ -280,26 +230,44 @@ pub const Accessor = struct {
pub const Indexed = struct { pub const Indexed = struct {
getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8, getter: *const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8,
const Opts = struct { const Opts = struct {
as_typed_array: bool = false, as_typed_array: bool = false,
null_as_undefined: bool = false, null_as_undefined: bool = false,
}; };
fn init(comptime T: type, comptime getter: anytype, comptime opts: Opts) Indexed { fn init(comptime T: type, comptime getter: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
return .{ .getter = struct { var indexed = Indexed{
fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 { .enumerator = null,
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; .getter = struct {
var caller: Caller = undefined; fn wrap(idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
caller.init(v8_isolate); const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
defer caller.deinit(); var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getIndex(T, getter, idx, handle.?, .{ return caller.getIndex(T, getter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array, .as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined, .null_as_undefined = opts.null_as_undefined,
}); });
} }
}.wrap }; }.wrap,
};
if (@typeInfo(@TypeOf(enumerator)) != .null) {
indexed.enumerator = struct {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u8 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
return caller.getEnumerator(T, enumerator, handle.?, .{});
}
}.wrap;
}
return indexed;
} }
}; };
@@ -390,11 +358,9 @@ pub const Iterator = struct {
.async = opts.async, .async = opts.async,
.func = struct { .func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; return Caller.Function.call(T, handle.?, struct_or_func, .{
var caller: Caller = undefined; .null_as_undefined = opts.null_as_undefined,
caller.init(v8_isolate); });
defer caller.deinit();
caller.method(T, struct_or_func, handle.?, .{});
} }
}.wrap, }.wrap,
}; };
@@ -411,12 +377,7 @@ pub const Callable = struct {
fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Callable { fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Callable {
return .{ .func = struct { return .{ .func = struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(handle).?; Caller.Function.call(T, handle.?, func, .{
var caller: Caller = undefined;
caller.init(v8_isolate);
defer caller.deinit();
caller.method(T, func, handle.?, .{
.null_as_undefined = opts.null_as_undefined, .null_as_undefined = opts.null_as_undefined,
}); });
} }
@@ -427,6 +388,7 @@ pub const Callable = struct {
pub const Property = struct { pub const Property = struct {
value: Value, value: Value,
template: bool, template: bool,
readonly: bool,
const Value = union(enum) { const Value = union(enum) {
null, null,
@@ -438,27 +400,22 @@ pub const Property = struct {
const Opts = struct { const Opts = struct {
template: bool, template: bool,
readonly: bool = true,
}; };
fn init(value: Value, opts: Opts) Property { fn init(value: Value, opts: Opts) Property {
return .{ return .{
.value = value, .value = value,
.template = opts.template, .template = opts.template,
.readonly = opts.readonly,
}; };
} }
pub fn getter(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const value = v8.v8__FunctionCallbackInfo__Data(handle.?);
var rv: v8.ReturnValue = undefined;
v8.v8__FunctionCallbackInfo__GetReturnValue(handle.?, &rv);
v8.v8__ReturnValue__Set(rv, value);
}
}; };
const Finalizer = struct { const Finalizer = struct {
// The finalizer wrapper when called fro Zig. This is only called on // The finalizer wrapper when called fro Zig. This is only called on
// Context.deinit // Context.deinit
from_zig: *const fn (ctx: *anyopaque) void, from_zig: *const fn (ctx: *anyopaque, page: *Page) void,
// The finalizer wrapper when called from V8. This may never be called // The finalizer wrapper when called from V8. This may never be called
// (hence why we fallback to calling in Context.denit). If it is called, // (hence why we fallback to calling in Context.denit). If it is called,
@@ -773,6 +730,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"), @import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"), @import("../webapi/css/StyleSheetList.zig"),
@import("../webapi/Document.zig"), @import("../webapi/Document.zig"),
@@ -811,6 +769,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/element/html/DataList.zig"), @import("../webapi/element/html/DataList.zig"),
@import("../webapi/element/html/Dialog.zig"), @import("../webapi/element/html/Dialog.zig"),
@import("../webapi/element/html/Directory.zig"), @import("../webapi/element/html/Directory.zig"),
@import("../webapi/element/html/DList.zig"),
@import("../webapi/element/html/Div.zig"), @import("../webapi/element/html/Div.zig"),
@import("../webapi/element/html/Embed.zig"), @import("../webapi/element/html/Embed.zig"),
@import("../webapi/element/html/FieldSet.zig"), @import("../webapi/element/html/FieldSet.zig"),
@@ -880,6 +839,9 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/MouseEvent.zig"), @import("../webapi/event/MouseEvent.zig"),
@import("../webapi/event/PointerEvent.zig"), @import("../webapi/event/PointerEvent.zig"),
@import("../webapi/event/KeyboardEvent.zig"), @import("../webapi/event/KeyboardEvent.zig"),
@import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"), @import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"), @import("../webapi/MessagePort.zig"),
@@ -913,15 +875,18 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/IdleDeadline.zig"), @import("../webapi/IdleDeadline.zig"),
@import("../webapi/Blob.zig"), @import("../webapi/Blob.zig"),
@import("../webapi/File.zig"), @import("../webapi/File.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/Screen.zig"), @import("../webapi/Screen.zig"),
@import("../webapi/VisualViewport.zig"), @import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"), @import("../webapi/PerformanceObserver.zig"),
@import("../webapi/navigation/Navigation.zig"), @import("../webapi/navigation/Navigation.zig"),
@import("../webapi/navigation/NavigationEventTarget.zig"),
@import("../webapi/navigation/NavigationHistoryEntry.zig"), @import("../webapi/navigation/NavigationHistoryEntry.zig"),
@import("../webapi/navigation/NavigationActivation.zig"), @import("../webapi/navigation/NavigationActivation.zig"),
@import("../webapi/canvas/CanvasRenderingContext2D.zig"), @import("../webapi/canvas/CanvasRenderingContext2D.zig"),
@import("../webapi/canvas/WebGLRenderingContext.zig"), @import("../webapi/canvas/WebGLRenderingContext.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/SubtleCrypto.zig"), @import("../webapi/SubtleCrypto.zig"),
@import("../webapi/Selection.zig"), @import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"),
}); });

View File

@@ -77,6 +77,107 @@ pub const ArrayBuffer = struct {
} }
}; };
pub const ArrayType = enum(u8) {
int8,
uint8,
uint8_clamped,
int16,
uint16,
int32,
uint32,
float16,
float32,
float64,
};
pub fn ArrayBufferRef(comptime kind: ArrayType) type {
return struct {
const Self = @This();
const BackingInt = switch (kind) {
.int8 => i8,
.uint8, .uint8_clamped => u8,
.int16 => i16,
.uint16 => u16,
.int32 => i32,
.uint32 => u32,
.float16 => f16,
.float32 => f32,
.float64 => f64,
};
local: *const Local,
handle: *const v8.Value,
/// Persisted typed array.
pub const Global = struct {
handle: v8.Global,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Global, l: *const Local) Self {
return .{ .local = l, .handle = v8.v8__Global__Get(&self.handle, l.isolate.handle).? };
}
};
pub fn init(local: *const Local, size: usize) Self {
const ctx = local.ctx;
const isolate = ctx.isolate;
const bits = switch (@typeInfo(BackingInt)) {
.int => |n| n.bits,
.float => |f| f.bits,
else => unreachable,
};
var array_buffer: *const v8.ArrayBuffer = undefined;
if (size == 0) {
array_buffer = v8.v8__ArrayBuffer__New(isolate.handle, 0).?;
} else {
const buffer_len = size * bits / 8;
const backing_store = v8.v8__ArrayBuffer__NewBackingStore(isolate.handle, buffer_len).?;
const backing_store_ptr = v8.v8__BackingStore__TO_SHARED_PTR(backing_store);
array_buffer = v8.v8__ArrayBuffer__New2(isolate.handle, &backing_store_ptr).?;
}
const handle: *const v8.Value = switch (comptime kind) {
.int8 => @ptrCast(v8.v8__Int8Array__New(array_buffer, 0, size).?),
.uint8 => @ptrCast(v8.v8__Uint8Array__New(array_buffer, 0, size).?),
.uint8_clamped => @ptrCast(v8.v8__Uint8ClampedArray__New(array_buffer, 0, size).?),
.int16 => @ptrCast(v8.v8__Int16Array__New(array_buffer, 0, size).?),
.uint16 => @ptrCast(v8.v8__Uint16Array__New(array_buffer, 0, size).?),
.int32 => @ptrCast(v8.v8__Int32Array__New(array_buffer, 0, size).?),
.uint32 => @ptrCast(v8.v8__Uint32Array__New(array_buffer, 0, size).?),
.float16 => @ptrCast(v8.v8__Float16Array__New(array_buffer, 0, size).?),
.float32 => @ptrCast(v8.v8__Float32Array__New(array_buffer, 0, size).?),
.float64 => @ptrCast(v8.v8__Float64Array__New(array_buffer, 0, size).?),
};
return .{ .local = local, .handle = handle };
}
pub fn persist(self: *const Self) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.global_values.append(ctx.arena, global);
return .{ .handle = global };
}
};
}
// If a WebAPI takes a []const u8, then we'll coerce any JS value to that string
// so null -> "null". But if a WebAPI takes an optional string, ?[]const u8,
// how should we handle null? If the parameter _isn't_ passed, then it's obvious
// that it should be null, but what if `null` is passed? It's ambiguous, should
// that be null, or "null"? It could depend on the api. So, `null` passed to
// ?[]const u8 will be `null`. If you want it to be "null", use a `.js.NullableString`.
pub const NullableString = struct {
value: []const u8,
};
pub const Exception = struct { pub const Exception = struct {
local: *const Local, local: *const Local,
handle: *const v8.Value, handle: *const v8.Value,
@@ -134,8 +235,10 @@ pub fn simpleZigValueToJs(isolate: Isolate, value: anytype, comptime fail: bool,
const values = value.values; const values = value.values;
const len = values.len; const len = values.len;
const backing_store = v8.v8__ArrayBuffer__NewBackingStore(isolate.handle, len); const backing_store = v8.v8__ArrayBuffer__NewBackingStore(isolate.handle, len);
const data: [*]u8 = @ptrCast(@alignCast(v8.v8__BackingStore__Data(backing_store))); if (len > 0) {
@memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]); const data: [*]u8 = @ptrCast(@alignCast(v8.v8__BackingStore__Data(backing_store)));
@memcpy(data[0..len], @as([]const u8, @ptrCast(values))[0..len]);
}
const backing_store_ptr = v8.v8__BackingStore__TO_SHARED_PTR(backing_store); const backing_store_ptr = v8.v8__BackingStore__TO_SHARED_PTR(backing_store);
return @ptrCast(v8.v8__ArrayBuffer__New2(isolate.handle, &backing_store_ptr).?); return @ptrCast(v8.v8__ArrayBuffer__New2(isolate.handle, &backing_store_ptr).?);
}, },

607
src/browser/markdown.zig Normal file
View File

@@ -0,0 +1,607 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Page = @import("Page.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
pub const Opts = struct {
// Options for future customization (e.g., dialect)
};
const State = struct {
const ListType = enum { ordered, unordered };
const ListState = struct {
type: ListType,
index: usize,
};
list_depth: usize = 0,
list_stack: [32]ListState = undefined,
pre_node: ?*Node = null,
in_code: bool = false,
in_table: bool = false,
table_row_index: usize = 0,
table_col_count: usize = 0,
last_char_was_newline: bool = true,
};
fn isBlock(tag: Element.Tag) bool {
return switch (tag) {
.p, .div, .section, .article, .main, .header, .footer, .nav, .aside, .h1, .h2, .h3, .h4, .h5, .h6, .ul, .ol, .blockquote, .pre, .table, .hr => true,
else => false,
};
}
fn shouldAddSpacing(tag: Element.Tag) bool {
return switch (tag) {
.p, .h1, .h2, .h3, .h4, .h5, .h6, .blockquote, .pre, .table => true,
else => false,
};
}
fn isLayoutBlock(tag: Element.Tag) bool {
return switch (tag) {
.main, .section, .article, .nav, .aside, .header, .footer, .div, .ul, .ol => true,
else => false,
};
}
fn isStandaloneAnchor(el: *Element) bool {
const node = el.asNode();
const parent = node.parentNode() orelse return false;
const parent_el = parent.is(Element) orelse return false;
if (!isLayoutBlock(parent_el.getTag())) return false;
var prev = node.previousSibling();
while (prev) |p| : (prev = p.previousSibling()) {
if (isSignificantText(p)) return false;
if (p.is(Element)) |pe| {
if (isVisibleElement(pe)) break;
}
}
var next = node.nextSibling();
while (next) |n| : (next = n.nextSibling()) {
if (isSignificantText(n)) return false;
if (n.is(Element)) |ne| {
if (isVisibleElement(ne)) break;
}
}
return true;
}
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.getWholeText());
}
fn isVisibleElement(el: *Element) bool {
return switch (el.getTag()) {
.script, .style, .noscript, .template, .head, .meta, .link, .title, .svg => false,
else => true,
};
}
fn isAllWhitespace(text: []const u8) bool {
return for (text) |c| {
if (!std.ascii.isWhitespace(c)) break false;
} else true;
}
fn hasBlockDescendant(node: *Node) bool {
var it = node.childrenIterator();
return while (it.next()) |child| {
if (child.is(Element)) |el| {
if (isBlock(el.getTag())) break true;
if (hasBlockDescendant(child)) break true;
}
} else 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;
}
}
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts;
var state = State{};
try render(node, &state, writer, page);
if (!state.last_char_was_newline) {
try writer.writeByte('\n');
}
}
fn 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 (isBlock(tag) 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| {
try writer.writeAll(src);
}
try writer.writeAll(")");
state.last_char_was_newline = false;
return;
},
.anchor => {
const has_block = hasBlockDescendant(el.asNode());
if (has_block) {
try renderChildren(el.asNode(), state, writer, page);
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
if (!state.last_char_was_newline) try writer.writeByte('\n');
try writer.writeAll("([Link](");
try writer.writeAll(href);
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('[');
try renderChildren(el.asNode(), state, writer, page);
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
}
try writer.writeAll(")\n");
state.last_char_was_newline = true;
return;
}
try writer.writeByte('[');
try renderChildren(el.asNode(), state, writer, page);
try writer.writeAll("](");
if (el.getAttributeSafe(comptime .wrap("href"))) |href| {
try writer.writeAll(href);
}
try writer.writeByte(')');
state.last_char_was_newline = false;
return;
},
.input => {
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 (isBlock(tag) 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();
defer testing.test_session.removePage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try dump(div.asNode(), .{}, &aw.writer, page);
try testing.expectString(expected, aw.written());
}
test "browser.markdown: basic" {
try testMarkdownHTML("Hello world", "Hello world\n");
}
test "browser.markdown: whitespace" {
try testMarkdownHTML("<span>A</span> <span>B</span>", "A B\n");
}
test "browser.markdown: escaping" {
try testMarkdownHTML("<p># Not a header</p>", "\n\\# Not a header\n");
}
test "browser.markdown: strikethrough" {
try testMarkdownHTML("<s>deleted</s>", "~~deleted~~\n");
}
test "browser.markdown: task list" {
try testMarkdownHTML(
\\<input type="checkbox" checked><input type="checkbox">
, "[x] [ ] \n");
}
test "browser.markdown: ordered list" {
try testMarkdownHTML(
\\<ol><li>First</li><li>Second</li></ol>
, "1. First\n2. Second\n");
}
test "browser.markdown: table" {
try testMarkdownHTML(
\\<table><thead><tr><th>Head 1</th><th>Head 2</th></tr></thead>
\\<tbody><tr><td>Cell 1</td><td>Cell 2</td></tr></tbody></table>
,
\\
\\| Head 1 | Head 2 |
\\|---|---|
\\| Cell 1 | Cell 2 |
\\
);
}
test "browser.markdown: nested lists" {
try testMarkdownHTML(
\\<ul><li>Parent<ul><li>Child</li></ul></li></ul>
,
\\- Parent
\\ - Child
\\
);
}
test "browser.markdown: blockquote" {
try testMarkdownHTML("<blockquote>Hello world</blockquote>", "\n> Hello world\n");
}
test "browser.markdown: links" {
try testMarkdownHTML("<a href=\"https://lightpanda.io\">Lightpanda</a>", "[Lightpanda](https://lightpanda.io)\n");
}
test "browser.markdown: images" {
try testMarkdownHTML("<img src=\"logo.png\" alt=\"Logo\">", "![Logo](logo.png)\n");
}
test "browser.markdown: headings" {
try testMarkdownHTML("<h1>Title</h1><h2>Subtitle</h2>",
\\
\\# Title
\\
\\## Subtitle
\\
);
}
test "browser.markdown: code" {
try testMarkdownHTML(
\\<p>Use git push</p>
\\<pre><code>line 1
\\line 2</code></pre>
,
\\
\\Use git push
\\
\\```
\\line 1
\\line 2
\\```
\\
);
}
test "browser.markdown: block link" {
try testMarkdownHTML(
\\<a href="https://example.com">
\\ <h3>Title</h3>
\\ <p>Description</p>
\\</a>
,
\\
\\### Title
\\
\\Description
\\([Link](https://example.com))
\\
);
}
test "browser.markdown: inline link" {
try testMarkdownHTML(
\\<p>Visit <a href="https://example.com">Example</a>.</p>
,
\\
\\Visit [Example](https://example.com).
\\
);
}
test "browser.markdown: standalone anchors" {
// Inside main, with whitespace between anchors -> treated as blocks
try testMarkdownHTML(
\\<main>
\\ <a href="1">Link 1</a>
\\ <a href="2">Link 2</a>
\\</main>
,
\\[Link 1](1)
\\[Link 2](2)
\\
);
}
test "browser.markdown: mixed anchors in main" {
// Anchors surrounded by text should remain inline
try testMarkdownHTML(
\\<main>
\\ Welcome <a href="1">Link 1</a>.
\\</main>
,
\\Welcome [Link 1](1).
\\
);
}

View File

@@ -421,7 +421,16 @@ fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or
fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void { fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void {
const parent = sibling.parentNode() orelse return error.NoParent; const parent = sibling.parentNode() orelse return error.NoParent;
const node: *Node = switch (node_or_text.toUnion()) { const node: *Node = switch (node_or_text.toUnion()) {
.node => |cpn| getNode(cpn), .node => |cpn| blk: {
const child = getNode(cpn);
if (child._parent) |previous_parent| {
// A custom element constructor may have inserted the node into the
// DOM before the parser officially places it (e.g. via foster
// parenting). Detach it first so insertNodeRelative's assertion holds.
self.page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent.isConnected() });
}
break :blk child;
},
.text => |txt| try self.page.createTextNode(txt), .text => |txt| try self.page.createTextNode(txt),
}; };
try self.page.insertNodeRelative(parent, node, .{ .before = sibling }, .{}); try self.page.insertNodeRelative(parent, node, .{ .before = sibling }, .{});

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=OffscreenCanvas>
{
const canvas = new OffscreenCanvas(256, 256);
testing.expectEqual(true, canvas instanceof OffscreenCanvas);
testing.expectEqual(canvas.width, 256);
testing.expectEqual(canvas.height, 256);
}
</script>
<script id=OffscreenCanvas#width>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.width, 100);
canvas.width = 300;
testing.expectEqual(canvas.width, 300);
}
</script>
<script id=OffscreenCanvas#height>
{
const canvas = new OffscreenCanvas(100, 200);
testing.expectEqual(canvas.height, 200);
canvas.height = 400;
testing.expectEqual(canvas.height, 400);
}
</script>
<script id=OffscreenCanvas#getContext>
{
const canvas = new OffscreenCanvas(64, 64);
const ctx = canvas.getContext("2d");
testing.expectEqual(true, ctx instanceof OffscreenCanvasRenderingContext2D);
// We can't really test rendering but let's try to call it at least.
ctx.fillRect(0, 0, 10, 10);
}
</script>
<script id=OffscreenCanvas#convertToBlob>
{
const canvas = new OffscreenCanvas(64, 64);
const promise = canvas.convertToBlob();
testing.expectEqual(true, promise instanceof Promise);
// The promise should resolve to a Blob (even if empty)
promise.then(blob => {
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(blob.size, 0); // Empty since no rendering
});
}
</script>
<script id=HTMLCanvasElement#transferControlToOffscreen>
{
const htmlCanvas = document.createElement("canvas");
htmlCanvas.width = 128;
htmlCanvas.height = 96;
const offscreen = htmlCanvas.transferControlToOffscreen();
testing.expectEqual(true, offscreen instanceof OffscreenCanvas);
testing.expectEqual(offscreen.width, 128);
testing.expectEqual(offscreen.height, 96);
}
</script>

View File

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

View File

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

View File

@@ -16,44 +16,44 @@
isRandom(ti8a) isRandom(ti8a)
} }
// { {
// let tu16a = new Uint16Array(100) let tu16a = new Uint16Array(100)
// testing.expectEqual(tu16a, crypto.getRandomValues(tu16a)) testing.expectEqual(tu16a, crypto.getRandomValues(tu16a))
// isRandom(tu16a) isRandom(tu16a)
// let ti16a = new Int16Array(100) let ti16a = new Int16Array(100)
// testing.expectEqual(ti16a, crypto.getRandomValues(ti16a)) testing.expectEqual(ti16a, crypto.getRandomValues(ti16a))
// isRandom(ti16a) isRandom(ti16a)
// } }
// { {
// let tu32a = new Uint32Array(100) let tu32a = new Uint32Array(100)
// testing.expectEqual(tu32a, crypto.getRandomValues(tu32a)) testing.expectEqual(tu32a, crypto.getRandomValues(tu32a))
// isRandom(tu32a) isRandom(tu32a)
// let ti32a = new Int32Array(100) let ti32a = new Int32Array(100)
// testing.expectEqual(ti32a, crypto.getRandomValues(ti32a)) testing.expectEqual(ti32a, crypto.getRandomValues(ti32a))
// isRandom(ti32a) isRandom(ti32a)
// } }
// { {
// let tu64a = new BigUint64Array(100) let tu64a = new BigUint64Array(100)
// testing.expectEqual(tu64a, crypto.getRandomValues(tu64a)) testing.expectEqual(tu64a, crypto.getRandomValues(tu64a))
// isRandom(tu64a) isRandom(tu64a)
// let ti64a = new BigInt64Array(100) let ti64a = new BigInt64Array(100)
// testing.expectEqual(ti64a, crypto.getRandomValues(ti64a)) testing.expectEqual(ti64a, crypto.getRandomValues(ti64a))
// isRandom(ti64a) isRandom(ti64a)
// } }
</script> </script>
<!-- <script id="randomUUID"> <script id="randomUUID">
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
testing.expectEqual('string', typeof uuid); testing.expectEqual('string', typeof uuid);
testing.expectEqual(36, uuid.length); testing.expectEqual(36, uuid.length);
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
testing.expectEqual(true, regex.test(uuid)); testing.expectEqual(true, regex.test(uuid));
</script> --> </script>
<script id=SubtleCrypto> <script id=SubtleCrypto>
testing.expectEqual(true, crypto.subtle instanceof SubtleCrypto); testing.expectEqual(true, crypto.subtle instanceof SubtleCrypto);
@@ -119,3 +119,16 @@
testing.expectEqual(16, sharedKey.byteLength); testing.expectEqual(16, sharedKey.byteLength);
}); });
</script> </script>
<script id="digest">
testing.async(async () => {
async function hash(algo, data) {
const buffer = await window.crypto.subtle.digest(algo, new TextEncoder().encode(data));
return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
}
testing.expectEqual("a6a1e3375239f215f09a156df29c17c7d1ac6722", await hash('sha-1', 'over 9000'));
testing.expectEqual("1bc375bb92459685194dda18a4b835f4e2972ec1bde6d9ab3db53fcc584a6580", await hash('sha-256', 'over 9000'));
testing.expectEqual("a4260d64c2eea9fd30c1f895c5e48a26d817e19d3a700b61b3ce665864ff4b8e012bd357d345aa614c5f642dab865ea1", await hash('sha-384', 'over 9000'));
testing.expectEqual("6cad17e6f3f76680d6dd18ed043b75b4f6e1aa1d08b917294942e882fb6466c3510948c34af8b903ed0725b582b3b39c0e485ae2c1b7dfdb192ee38b79c782b6", await hash('sha-512', 'over 9000'));
});
</script>

View File

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

View File

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

View File

@@ -205,3 +205,54 @@
testing.expectEqual('', style.getPropertyPriority('content')); testing.expectEqual('', style.getPropertyPriority('content'));
} }
</script> </script>
<script id="CSSStyleDeclaration_style_syncs_to_attribute">
{
// JS style modifications must be reflected in getAttribute.
const div = document.createElement('div');
// Named property assignment (element.style.X = ...)
div.style.opacity = '0';
testing.expectEqual('opacity: 0;', div.getAttribute('style'));
// Update existing property
div.style.opacity = '1';
testing.expectEqual('opacity: 1;', div.getAttribute('style'));
// Add a second property
div.style.color = 'red';
testing.expectTrue(div.getAttribute('style').includes('opacity: 1'));
testing.expectTrue(div.getAttribute('style').includes('color: red'));
// removeProperty syncs back
div.style.removeProperty('opacity');
testing.expectTrue(!div.getAttribute('style').includes('opacity'));
testing.expectTrue(div.getAttribute('style').includes('color: red'));
// setCssText syncs back
div.style.cssText = 'filter: blur(0px)';
testing.expectEqual('filter: blur(0px);', div.getAttribute('style'));
// setCssText with empty string clears attribute
div.style.cssText = '';
testing.expectEqual('', div.getAttribute('style'));
}
</script>
<script id="CSSStyleDeclaration_outerHTML_reflects_style_changes">
{
// outerHTML must reflect JS-modified styles (regression test for
// DOM serialization reading stale HTML-parsed attribute values).
const div = document.createElement('div');
div.setAttribute('style', 'filter:blur(10px);opacity:0');
div.style.filter = 'blur(0px)';
div.style.opacity = '1';
const html = div.outerHTML;
testing.expectTrue(html.includes('filter: blur(0px)'));
testing.expectTrue(html.includes('opacity: 1'));
testing.expectTrue(!html.includes('blur(10px)'));
testing.expectTrue(!html.includes('opacity:0'));
}
</script>

View File

@@ -119,3 +119,33 @@
} }
</script> </script>
<script id="constructor_self_insert_foster_parent">
{
// Regression: custom element constructor inserting itself (via appendChild) during
// innerHTML parsing. When the element is not valid table content, the HTML5 parser
// foster-parents it before the <table> via appendBeforeSiblingCallback. That callback
// previously didn't check for an existing _parent before calling insertNodeRelative,
// causing the "Page.insertNodeRelative parent" assertion to fire.
let constructorCalled = 0;
let container;
class CtorSelfInsert extends HTMLElement {
constructor() {
super();
constructorCalled++;
// Insert self into container so _parent is set before the parser
// officially places this element via appendBeforeSiblingCallback.
if (container) container.appendChild(this);
}
}
customElements.define('ctor-self-insert', CtorSelfInsert);
container = document.createElement('div');
// ctor-self-insert is not valid table content; the parser foster-parents it
// before the <table>, calling appendBeforeSiblingCallback(sibling=table, node=element).
// At that point the element already has _parent=container from the constructor.
container.innerHTML = '<table><ctor-self-insert></ctor-self-insert></table>';
testing.expectEqual(1, constructorCalled);
}
</script>

View File

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

View File

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

View File

@@ -81,6 +81,172 @@
</script> </script>
<script id="focusin_focusout_events">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let events = [];
input1.addEventListener('focus', () => events.push('focus1'));
input1.addEventListener('focusin', () => events.push('focusin1'));
input1.addEventListener('blur', () => events.push('blur1'));
input1.addEventListener('focusout', () => events.push('focusout1'));
input2.addEventListener('focus', () => events.push('focus2'));
input2.addEventListener('focusin', () => events.push('focusin2'));
// Focus input1 — should fire focus then focusin
input1.focus();
testing.expectEqual('focus1,focusin1', events.join(','));
// Focus input2 — should fire blur, focusout on input1, then focus, focusin on input2
events = [];
input2.focus();
testing.expectEqual('blur1,focusout1,focus2,focusin2', events.join(','));
}
</script>
<script id="focusin_bubbles">
{
const input1 = $('#input1');
if (document.activeElement) {
document.activeElement.blur();
}
let bodyFocusin = 0;
let bodyFocus = 0;
document.body.addEventListener('focusin', () => bodyFocusin++);
document.body.addEventListener('focus', () => bodyFocus++);
input1.focus();
// focusin should bubble to body, focus should not
testing.expectEqual(1, bodyFocusin);
testing.expectEqual(0, bodyFocus);
}
</script>
<script id="focusout_bubbles">
{
const input1 = $('#input1');
input1.focus();
let bodyFocusout = 0;
let bodyBlur = 0;
document.body.addEventListener('focusout', () => bodyFocusout++);
document.body.addEventListener('blur', () => bodyBlur++);
input1.blur();
// focusout should bubble to body, blur should not
testing.expectEqual(1, bodyFocusout);
testing.expectEqual(0, bodyBlur);
}
</script>
<script id="focus_relatedTarget">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusRelated = null;
let blurRelated = null;
let focusinRelated = null;
let focusoutRelated = null;
input1.addEventListener('blur', (e) => { blurRelated = e.relatedTarget; });
input1.addEventListener('focusout', (e) => { focusoutRelated = e.relatedTarget; });
input2.addEventListener('focus', (e) => { focusRelated = e.relatedTarget; });
input2.addEventListener('focusin', (e) => { focusinRelated = e.relatedTarget; });
input1.focus();
input2.focus();
// blur/focusout on input1 should have relatedTarget = input2 (gaining focus)
testing.expectEqual(input2, blurRelated);
testing.expectEqual(input2, focusoutRelated);
// focus/focusin on input2 should have relatedTarget = input1 (losing focus)
testing.expectEqual(input1, focusRelated);
testing.expectEqual(input1, focusinRelated);
}
</script>
<script id="blur_relatedTarget_null">
{
const btn = $('#btn1');
btn.focus();
let blurRelated = 'not_set';
btn.addEventListener('blur', (e) => { blurRelated = e.relatedTarget; });
btn.blur();
// blur without moving to another element should have relatedTarget = null
testing.expectEqual(null, blurRelated);
}
</script>
<script id="focus_event_properties">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusEvent = null;
let focusinEvent = null;
let blurEvent = null;
let focusoutEvent = null;
input1.addEventListener('blur', (e) => { blurEvent = e; });
input1.addEventListener('focusout', (e) => { focusoutEvent = e; });
input2.addEventListener('focus', (e) => { focusEvent = e; });
input2.addEventListener('focusin', (e) => { focusinEvent = e; });
input1.focus();
input2.focus();
// All four should be FocusEvent instances
testing.expectEqual(true, blurEvent instanceof FocusEvent);
testing.expectEqual(true, focusoutEvent instanceof FocusEvent);
testing.expectEqual(true, focusEvent instanceof FocusEvent);
testing.expectEqual(true, focusinEvent instanceof FocusEvent);
// All four should be composed per spec
testing.expectEqual(true, blurEvent.composed);
testing.expectEqual(true, focusoutEvent.composed);
testing.expectEqual(true, focusEvent.composed);
testing.expectEqual(true, focusinEvent.composed);
// None should be cancelable
testing.expectEqual(false, blurEvent.cancelable);
testing.expectEqual(false, focusoutEvent.cancelable);
testing.expectEqual(false, focusEvent.cancelable);
testing.expectEqual(false, focusinEvent.cancelable);
// blur/focus don't bubble, focusin/focusout do
testing.expectEqual(false, blurEvent.bubbles);
testing.expectEqual(true, focusoutEvent.bubbles);
testing.expectEqual(false, focusEvent.bubbles);
testing.expectEqual(true, focusinEvent.bubbles);
}
</script>
<script id="focus_disconnected"> <script id="focus_disconnected">
{ {
const focused = document.activeElement; const focused = document.activeElement;
@@ -88,3 +254,68 @@
testing.expectEqual(focused, document.activeElement); testing.expectEqual(focused, document.activeElement);
} }
</script> </script>
<script id="click_focuses_element">
{
const input1 = $('#input1');
const input2 = $('#input2');
if (document.activeElement) {
document.activeElement.blur();
}
let focusCount = 0;
let blurCount = 0;
input1.addEventListener('focus', () => focusCount++);
input1.addEventListener('blur', () => blurCount++);
input2.addEventListener('focus', () => focusCount++);
// Click input1 — should focus it and fire focus event
input1.click();
testing.expectEqual(input1, document.activeElement);
testing.expectEqual(1, focusCount);
testing.expectEqual(0, blurCount);
// Click input2 — should move focus, fire blur on input1 and focus on input2
input2.click();
testing.expectEqual(input2, document.activeElement);
testing.expectEqual(2, focusCount);
testing.expectEqual(1, blurCount);
}
</script>
<script id="click_focuses_button">
{
const btn = $('#btn1');
if (document.activeElement) {
document.activeElement.blur();
}
btn.click();
testing.expectEqual(btn, document.activeElement);
}
</script>
<script id="focus_disconnected_no_blur">
{
const input1 = $('#input1');
if (document.activeElement) {
document.activeElement.blur();
}
input1.focus();
testing.expectEqual(input1, document.activeElement);
let blurCount = 0;
input1.addEventListener('blur', () => { blurCount++ });
// Focusing a disconnected element should be a no-op:
// blur must not fire on the currently focused element
document.createElement('a').focus();
testing.expectEqual(input1, document.activeElement);
testing.expectEqual(0, blurCount);
}
</script>

View File

@@ -270,3 +270,36 @@
testing.expectEqual('rect', document.querySelector('svg g rect').tagName); testing.expectEqual('rect', document.querySelector('svg g rect').tagName);
} }
</script> </script>
<script id=special>
testing.expectEqual(null, document.querySelector('\\'));
testing.expectEqual(null, document.querySelector('div\\'));
testing.expectEqual(null, document.querySelector('.test-class\\'));
testing.expectEqual(null, document.querySelector('#byId\\'));
</script>
<div class="café">Non-ASCII class 1</div>
<div class="日本語">Non-ASCII class 2</div>
<span id="niño">Non-ASCII ID 1</span>
<p id="🎨">Non-ASCII ID 2</p>
<script id=nonAsciiSelectors>
testing.expectEqual('Non-ASCII class 1', document.querySelector('.café').textContent);
testing.expectEqual('Non-ASCII class 2', document.querySelector('.日本語').textContent);
testing.expectEqual('Non-ASCII ID 1', document.querySelector('#niño').textContent);
testing.expectEqual('Non-ASCII ID 2', document.querySelector('#🎨').textContent);
testing.expectEqual('Non-ASCII class 1', document.querySelector('div.café').textContent);
testing.expectEqual('Non-ASCII ID 1', document.querySelector('span#niño').textContent);
</script>
<span id=".,:!">Punctuation test</span>
<script id=escapedPunctuation>
{
// Test escaped punctuation in ID selectors
testing.expectEqual('Punctuation test', document.querySelector('#\\.\\,\\:\\!').textContent);
}
</script>

View File

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

View File

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

View File

@@ -93,6 +93,29 @@
} }
</script> </script>
<script id=replace_errors>
{
const div = document.createElement('div');
div.className = 'foo bar';
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('', 'baz'));
testing.withError((err) => {
testing.expectEqual('SyntaxError', err.name);
}, () => div.classList.replace('foo', ''));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo bar', 'baz'));
testing.withError((err) => {
testing.expectEqual('InvalidCharacterError', err.name);
}, () => div.classList.replace('foo', 'bar baz'));
}
</script>
<script id=item> <script id=item>
{ {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -166,6 +189,29 @@
} }
</script> </script>
<script id=classList_assignment>
{
const div = document.createElement('div');
// Direct assignment should work (equivalent to classList.value = ...)
div.classList = 'foo bar baz';
testing.expectEqual('foo bar baz', div.className);
testing.expectEqual(3, div.classList.length);
testing.expectEqual(true, div.classList.contains('foo'));
// Assigning again should replace
div.classList = 'qux';
testing.expectEqual('qux', div.className);
testing.expectEqual(1, div.classList.length);
testing.expectEqual(false, div.classList.contains('foo'));
// Empty assignment
div.classList = '';
testing.expectEqual('', div.className);
testing.expectEqual(0, div.classList.length);
}
</script>
<script id=errors> <script id=errors>
{ {
const div = document.createElement('div'); const div = document.createElement('div');

View File

@@ -121,6 +121,29 @@
} }
</script> </script>
<script id="propertyAssignment">
{
const div = $('#test-div');
div.style.cssText = '';
// camelCase assignment
div.style.opacity = '0.5';
testing.expectEqual('0.5', div.style.opacity);
// bracket notation assignment
div.style['filter'] = 'blur(5px)';
testing.expectEqual('blur(5px)', div.style.filter);
// numeric value coerced to string
div.style.opacity = 1;
testing.expectEqual('1', div.style.opacity);
// assigning method names should be ignored (not intercepted)
div.style.setProperty('color', 'blue');
testing.expectEqual('blue', div.style.color);
}
</script>
<script id="prototypeChainCheck"> <script id="prototypeChainCheck">
{ {
const div = $('#test-div'); const div = $('#test-div');
@@ -131,3 +154,11 @@
testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function'); testing.expectEqual(true, typeof div.style.getPropertyPriority === 'function');
} }
</script> </script>
<div id=crash1 style="background-position: 5% .1em"></div>
<script id="crash_case_1">
{
testing.expectEqual('5% .1em', $('#crash1').style.backgroundPosition);
}
</script>

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="container" xmlns="http://www.w3.org/1999/xhtml">
<div id="div1">div1</div>
<p id="p1">p1</p>
<div id="div2">div2</div>
</div>
<svg id="svgContainer" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle id="circle1" cx="50" cy="50" r="40"/>
<rect id="rect1" x="10" y="10" width="30" height="30"/>
<circle id="circle2" cx="25" cy="25" r="10"/>
</svg>
<div id="mixed">
<div id="htmlDiv" xmlns="http://www.w3.org/1999/xhtml">HTML div</div>
<svg xmlns="http://www.w3.org/2000/svg">
<circle id="svgCircle" cx="10" cy="10" r="5"/>
</svg>
</div>
<script id=basic>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const svgNS = "http://www.w3.org/2000/svg";
// Test HTML namespace
const htmlDivs = document.getElementsByTagNameNS(htmlNS, 'div');
testing.expectEqual(true, htmlDivs instanceof HTMLCollection);
testing.expectEqual(5, htmlDivs.length); // container, div1, div2, mixed, htmlDiv
const htmlPs = document.getElementsByTagNameNS(htmlNS, 'p');
testing.expectEqual(1, htmlPs.length);
testing.expectEqual('p1', htmlPs[0].id);
}
</script>
<script id=svgNamespace>
{
const svgNS = "http://www.w3.org/2000/svg";
const circles = document.getElementsByTagNameNS(svgNS, 'circle');
testing.expectEqual(3, circles.length); // circle1, circle2, svgCircle
testing.expectEqual('circle1', circles[0].id);
testing.expectEqual('circle2', circles[1].id);
testing.expectEqual('svgCircle', circles[2].id);
const rects = document.getElementsByTagNameNS(svgNS, 'rect');
testing.expectEqual(1, rects.length);
testing.expectEqual('rect1', rects[0].id);
}
</script>
<script id=nullNamespace>
{
// Null namespace should match elements with null namespace
const nullNsElements = document.getElementsByTagNameNS(null, 'div');
testing.expectEqual(0, nullNsElements.length); // Our divs are in HTML namespace
}
</script>
<script id=wildcardNamespace>
{
// Wildcard namespace "*" should match all namespaces
const allDivs = document.getElementsByTagNameNS('*', 'div');
testing.expectEqual(5, allDivs.length); // All divs regardless of namespace
}
</script>
<script id=wildcardLocalName>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
// Wildcard local name should match all elements in that namespace
const allHtmlElements = document.getElementsByTagNameNS(htmlNS, '*');
testing.expectEqual(true, allHtmlElements.length > 0);
}
</script>
<script id=caseSensitive>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
// getElementsByTagNameNS is case-sensitive for local names
const lowerDivs = document.getElementsByTagNameNS(htmlNS, 'div');
const upperDivs = document.getElementsByTagNameNS(htmlNS, 'DIV');
testing.expectEqual(5, lowerDivs.length);
testing.expectEqual(0, upperDivs.length); // Should be 0 because it's case-sensitive
}
</script>
<script id=unknownNamespace>
{
// Unknown namespace should still work
const unknownNs = document.getElementsByTagNameNS('http://example.com/unknown', 'div');
testing.expectEqual(0, unknownNs.length);
}
</script>
<script id=emptyResult>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const svgNS = "http://www.w3.org/2000/svg";
testing.expectEqual(0, document.getElementsByTagNameNS(htmlNS, 'nonexistent').length);
testing.expectEqual(0, document.getElementsByTagNameNS(svgNS, 'nonexistent').length);
}
</script>
<script id=elementMethod>
{
const htmlNS = "http://www.w3.org/1999/xhtml";
const container = document.getElementById('container');
// getElementsByTagNameNS on element should only search descendants
const divsInContainer = container.getElementsByTagNameNS(htmlNS, 'div');
testing.expectEqual(2, divsInContainer.length); // div1, div2 (not container itself)
testing.expectEqual('div1', divsInContainer[0].id);
testing.expectEqual('div2', divsInContainer[1].id);
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<fieldset id="fs1" disabled name="group1">
<input type="text">
</fieldset>
<fieldset id="fs2">
<input type="text">
</fieldset>
<script id="disabled">
{
const fs1 = document.getElementById('fs1');
testing.expectEqual(true, fs1.disabled);
fs1.disabled = false;
testing.expectEqual(false, fs1.disabled);
const fs2 = document.getElementById('fs2');
testing.expectEqual(false, fs2.disabled);
}
</script>
<script id="name">
{
const fs1 = document.getElementById('fs1');
testing.expectEqual('group1', fs1.name);
fs1.name = 'updated';
testing.expectEqual('updated', fs1.name);
const fs2 = document.getElementById('fs2');
testing.expectEqual('', fs2.name);
}
</script>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<div id="d1" hidden>Hidden div</div>
<div id="d2">Visible div</div>
<input id="i1" tabindex="5">
<div id="d3">No tabindex</div>
<script id="hidden">
{
const d1 = document.getElementById('d1');
testing.expectEqual(true, d1.hidden);
d1.hidden = false;
testing.expectEqual(false, d1.hidden);
const d2 = document.getElementById('d2');
testing.expectEqual(false, d2.hidden);
d2.hidden = true;
testing.expectEqual(true, d2.hidden);
}
</script>
<script id="tabIndex">
{
const i1 = document.getElementById('i1');
testing.expectEqual(5, i1.tabIndex);
i1.tabIndex = 10;
testing.expectEqual(10, i1.tabIndex);
// Non-interactive elements default to -1
const d3 = document.getElementById('d3');
testing.expectEqual(-1, d3.tabIndex);
d3.tabIndex = 0;
testing.expectEqual(0, d3.tabIndex);
// Interactive elements default to 0 per spec
const input = document.createElement('input');
testing.expectEqual(0, input.tabIndex);
const button = document.createElement('button');
testing.expectEqual(0, button.tabIndex);
const a = document.createElement('a');
testing.expectEqual(0, a.tabIndex);
const select = document.createElement('select');
testing.expectEqual(0, select.tabIndex);
const textarea = document.createElement('textarea');
testing.expectEqual(0, textarea.tabIndex);
}
</script>

View File

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

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<input id="i1" placeholder="Enter name" min="0" max="100" step="5" autocomplete="email">
<input id="i2" type="file" multiple>
<input id="i3">
<script id="placeholder">
{
const i1 = document.getElementById('i1');
testing.expectEqual('Enter name', i1.placeholder);
i1.placeholder = 'Updated';
testing.expectEqual('Updated', i1.placeholder);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.placeholder);
}
</script>
<script id="min">
{
const i1 = document.getElementById('i1');
testing.expectEqual('0', i1.min);
i1.min = '10';
testing.expectEqual('10', i1.min);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.min);
}
</script>
<script id="max">
{
const i1 = document.getElementById('i1');
testing.expectEqual('100', i1.max);
i1.max = '200';
testing.expectEqual('200', i1.max);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.max);
}
</script>
<script id="step">
{
const i1 = document.getElementById('i1');
testing.expectEqual('5', i1.step);
i1.step = '0.5';
testing.expectEqual('0.5', i1.step);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.step);
}
</script>
<script id="multiple">
{
const i2 = document.getElementById('i2');
testing.expectEqual(true, i2.multiple);
i2.multiple = false;
testing.expectEqual(false, i2.multiple);
const i3 = document.getElementById('i3');
testing.expectEqual(false, i3.multiple);
i3.multiple = true;
testing.expectEqual(true, i3.multiple);
}
</script>
<script id="autocomplete">
{
const i1 = document.getElementById('i1');
testing.expectEqual('email', i1.autocomplete);
i1.autocomplete = 'off';
testing.expectEqual('off', i1.autocomplete);
const i3 = document.getElementById('i3');
testing.expectEqual('', i3.autocomplete);
}
</script>

View File

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

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Checkbox click tests -->
<input id="checkbox1" type="checkbox">
<input id="checkbox2" type="checkbox" checked>
<input id="checkbox_disabled" type="checkbox" disabled>
<!-- Radio click tests -->
<input id="radio1" type="radio" name="clickgroup" checked>
<input id="radio2" type="radio" name="clickgroup">
<input id="radio3" type="radio" name="clickgroup">
<input id="radio_disabled" type="radio" name="clickgroup" disabled>
<script id="checkbox_click_toggles">
{
const cb = $('#checkbox1');
testing.expectEqual(false, cb.checked);
cb.click();
testing.expectEqual(true, cb.checked);
cb.click();
testing.expectEqual(false, cb.checked);
}
</script>
<script id="checkbox_click_preventDefault_reverts">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
testing.expectEqual(false, cb.checked);
cb.addEventListener('click', (e) => {
testing.expectEqual(true, cb.checked, 'checkbox should be checked during click handler');
e.preventDefault();
});
cb.click();
testing.expectEqual(false, cb.checked, 'checkbox should revert after preventDefault');
}
</script>
<script id="checkbox_click_events_order">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_preventDefault_no_input_change">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', (e) => {
events.push('click');
e.preventDefault();
});
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(1, events.length, 'only click event should fire');
testing.expectEqual('click', events[0]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_state_visible_in_handler">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addEventListener('click', (e) => {
testing.expectEqual(false, cb.checked, 'should see toggled state in handler');
e.preventDefault();
testing.expectEqual(false, cb.checked, 'should still be toggled after preventDefault in handler');
});
cb.click();
testing.expectEqual(true, cb.checked, 'should revert to original state after handler completes');
}
</script>
<script id="radio_click_checks_clicked">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
}
</script>
<script id="radio_click_preventDefault_reverts">
{
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'testgroup';
r1.checked = true;
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'testgroup';
document.body.appendChild(r1);
document.body.appendChild(r2);
r2.addEventListener('click', (e) => {
testing.expectEqual(false, r1.checked, 'r1 should be unchecked during click handler');
testing.expectEqual(true, r2.checked, 'r2 should be checked during click handler');
e.preventDefault();
});
r2.click();
testing.expectEqual(true, r1.checked, 'r1 should be restored after preventDefault');
testing.expectEqual(false, r2.checked, 'r2 should revert after preventDefault');
document.body.removeChild(r1);
document.body.removeChild(r2);
}
</script>
<script id="radio_click_events_order">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'eventtest';
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(r);
}
</script>
<script id="radio_click_already_checked_no_events">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'alreadytest';
r.checked = true;
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(1, events.length, 'only click event should fire for already-checked radio');
testing.expectEqual('click', events[0]);
document.body.removeChild(r);
}
</script>
<script id="disabled_checkbox_no_click">
{
const cb = $('#checkbox_disabled');
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(0, events.length, 'disabled checkbox should not fire any events');
}
</script>
<script id="disabled_radio_no_click">
{
const r = $('#radio_disabled');
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(0, events.length, 'disabled radio should not fire any events');
}
</script>
<script id="input_and_change_are_trusted">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
let inputEvent = null;
let changeEvent = null;
cb.addEventListener('input', (e) => inputEvent = e);
cb.addEventListener('change', (e) => changeEvent = e);
cb.click();
testing.expectEqual(true, inputEvent.isTrusted, 'input event should be trusted');
testing.expectEqual(true, inputEvent.bubbles, 'input event should bubble');
testing.expectEqual(false, inputEvent.cancelable, 'input event should not be cancelable');
testing.expectEqual(true, changeEvent.isTrusted, 'change event should be trusted');
testing.expectEqual(true, changeEvent.bubbles, 'change event should bubble');
testing.expectEqual(false, changeEvent.cancelable, 'change event should not be cancelable');
document.body.removeChild(cb);
}
</script>
<script id="multiple_radios_click_sequence">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
const r3 = $('#radio3');
// Reset to known state
r1.checked = true;
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
testing.expectEqual(false, r3.checked);
r3.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(true, r3.checked);
r1.click();
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
}
</script>

View File

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

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<ol>
<li id="li1" value="5">Item</li>
<li id="li2">Item</li>
</ol>
<script id="value">
{
const li1 = document.getElementById('li1');
testing.expectEqual(5, li1.value);
li1.value = 10;
testing.expectEqual(10, li1.value);
const li2 = document.getElementById('li2');
testing.expectEqual(0, li2.value);
li2.value = -3;
testing.expectEqual(-3, li2.value);
}
</script>

View File

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

View File

@@ -50,6 +50,50 @@
} }
</script> </script>
<script id="play_pause_events">
{
const audio = document.createElement('audio');
const events = [];
audio.addEventListener('play', () => events.push('play'));
audio.addEventListener('playing', () => events.push('playing'));
audio.addEventListener('pause', () => events.push('pause'));
audio.addEventListener('emptied', () => events.push('emptied'));
// First play: paused -> playing, fires play + playing
audio.play();
testing.expectEqual('play,playing', events.join(','));
// Second play: already playing, no events
audio.play();
testing.expectEqual('play,playing', events.join(','));
// Pause: playing -> paused, fires pause
audio.pause();
testing.expectEqual('play,playing,pause', events.join(','));
// Second pause: already paused, no event
audio.pause();
testing.expectEqual('play,playing,pause', events.join(','));
// Third play: resume from pause, fires play + playing (verified in Chrome)
audio.play();
testing.expectEqual('play,playing,pause,play,playing', events.join(','));
// Pause again
audio.pause();
testing.expectEqual('play,playing,pause,play,playing,pause', events.join(','));
// Load: resets state, fires emptied
audio.load();
testing.expectEqual('play,playing,pause,play,playing,pause,emptied', events.join(','));
// Play after load: fires play + playing
audio.play();
testing.expectEqual('play,playing,pause,play,playing,pause,emptied,play,playing', events.join(','));
}
</script>
<script id="volume_muted"> <script id="volume_muted">
{ {
const audio = document.getElementById('audio1'); const audio = document.getElementById('audio1');
@@ -194,7 +238,7 @@
testing.expectEqual('', audio.src); testing.expectEqual('', audio.src);
audio.src = 'test.mp3'; audio.src = 'test.mp3';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/test.mp3', audio.src); testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
} }
</script> </script>
@@ -204,7 +248,7 @@
testing.expectEqual('', video.poster); testing.expectEqual('', video.poster);
video.poster = 'poster.jpg'; video.poster = 'poster.jpg';
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/poster.jpg', video.poster); testing.expectEqual(testing.BASE_URL + 'element/html/poster.jpg', video.poster);
} }
</script> </script>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<ol id="ol1" start="5" reversed type="a">
<li>Item</li>
</ol>
<ol id="ol2">
<li>Item</li>
</ol>
<script id="start">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual(5, ol1.start);
ol1.start = 10;
testing.expectEqual(10, ol1.start);
const ol2 = document.getElementById('ol2');
testing.expectEqual(1, ol2.start);
}
</script>
<script id="reversed">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual(true, ol1.reversed);
ol1.reversed = false;
testing.expectEqual(false, ol1.reversed);
const ol2 = document.getElementById('ol2');
testing.expectEqual(false, ol2.reversed);
ol2.reversed = true;
testing.expectEqual(true, ol2.reversed);
}
</script>
<script id="type">
{
const ol1 = document.getElementById('ol1');
testing.expectEqual('a', ol1.type);
ol1.type = '1';
testing.expectEqual('1', ol1.type);
const ol2 = document.getElementById('ol2');
testing.expectEqual('1', ol2.type);
}
</script>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<select>
<optgroup id="og1" label="Group 1" disabled>
<option>A</option>
</optgroup>
<optgroup id="og2" label="Group 2">
<option>B</option>
</optgroup>
</select>
<script id="disabled">
{
const og1 = document.getElementById('og1');
testing.expectEqual(true, og1.disabled);
og1.disabled = false;
testing.expectEqual(false, og1.disabled);
const og2 = document.getElementById('og2');
testing.expectEqual(false, og2.disabled);
og2.disabled = true;
testing.expectEqual(true, og2.disabled);
}
</script>
<script id="label">
{
const og1 = document.getElementById('og1');
testing.expectEqual('Group 1', og1.label);
og1.label = 'Updated';
testing.expectEqual('Updated', og1.label);
const og = document.createElement('optgroup');
testing.expectEqual('', og.label);
}
</script>

View File

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

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<blockquote id="q1" cite="https://example.com/source">Quote</blockquote>
<script id="cite">
{
const q1 = document.getElementById('q1');
testing.expectEqual('https://example.com/source', q1.cite);
q1.cite = 'https://example.com/other';
testing.expectEqual('https://example.com/other', q1.cite);
const q2 = document.createElement('blockquote');
testing.expectEqual('', q2.cite);
}
</script>

View File

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

View File

@@ -3,7 +3,46 @@
<script id="sheet"> <script id="sheet">
{ {
// Disconnected style element should have no sheet
testing.expectEqual(null, document.createElement('style').sheet); testing.expectEqual(null, document.createElement('style').sheet);
// Connected style element should have a CSSStyleSheet
const style = document.createElement('style');
document.head.appendChild(style);
testing.expectEqual(true, style.sheet instanceof CSSStyleSheet);
// Same sheet instance on repeated access
testing.expectEqual(true, style.sheet === style.sheet);
// Non-CSS type should have no sheet
const lessStyle = document.createElement('style');
lessStyle.type = 'text/less';
document.head.appendChild(lessStyle);
testing.expectEqual(null, lessStyle.sheet);
// Empty type attribute is valid (defaults to text/css per spec)
const emptyType = document.createElement('style');
emptyType.setAttribute('type', '');
document.head.appendChild(emptyType);
testing.expectEqual(true, emptyType.sheet instanceof CSSStyleSheet);
// Case-insensitive type check
const upperType = document.createElement('style');
upperType.type = 'TEXT/CSS';
document.head.appendChild(upperType);
testing.expectEqual(true, upperType.sheet instanceof CSSStyleSheet);
// Disconnection clears sheet
const tempStyle = document.createElement('style');
document.head.appendChild(tempStyle);
testing.expectEqual(true, tempStyle.sheet instanceof CSSStyleSheet);
document.head.removeChild(tempStyle);
testing.expectEqual(null, tempStyle.sheet);
// ownerNode points back to the style element
const ownStyle = document.createElement('style');
document.head.appendChild(ownStyle);
testing.expectEqual(true, ownStyle.sheet.ownerNode === ownStyle);
} }
</script> </script>
@@ -67,3 +106,28 @@
testing.expectEqual(true, style.disabled); testing.expectEqual(true, style.disabled);
} }
</script> </script>
<script id="style-load-event">
{
// A style element fires a load event when appended to the DOM.
const style = document.createElement("style");
let result = false;
testing.async(async () => {
await new Promise(resolve => {
style.addEventListener("load", ({ bubbles, cancelBubble, cancelable, composed, isTrusted, target }) => {
testing.expectEqual(false, bubbles);
testing.expectEqual(false, cancelBubble);
testing.expectEqual(false, cancelable);
testing.expectEqual(false, composed);
testing.expectEqual(true, isTrusted);
testing.expectEqual(style, target);
result = true;
return resolve();
});
document.head.appendChild(style);
});
});
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<table>
<tr>
<td id="td1" colspan="3" rowspan="2">Cell</td>
<td id="td2">Cell</td>
</tr>
</table>
<script id="colSpan">
{
const td1 = document.getElementById('td1');
testing.expectEqual(3, td1.colSpan);
td1.colSpan = 5;
testing.expectEqual(5, td1.colSpan);
const td2 = document.getElementById('td2');
testing.expectEqual(1, td2.colSpan);
// colSpan 0 clamps to 1
td2.colSpan = 0;
testing.expectEqual(1, td2.colSpan);
// colSpan > 1000 clamps to 1000
td2.colSpan = 9999;
testing.expectEqual(1000, td2.colSpan);
}
</script>
<script id="rowSpan">
{
const td1 = document.getElementById('td1');
testing.expectEqual(2, td1.rowSpan);
td1.rowSpan = 4;
testing.expectEqual(4, td1.rowSpan);
const td2 = document.getElementById('td2');
testing.expectEqual(1, td2.rowSpan);
// rowSpan 0 is valid per spec (span remaining rows)
td2.rowSpan = 0;
testing.expectEqual(0, td2.rowSpan);
// rowSpan > 65534 clamps to 65534
td2.rowSpan = 99999;
testing.expectEqual(65534, td2.rowSpan);
}
</script>

View File

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

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<time id="t1" datetime="2024-01-15">January 15</time>
<script id="dateTime">
{
const t = document.getElementById('t1');
testing.expectEqual('2024-01-15', t.dateTime);
t.dateTime = '2024-12-25T10:00';
testing.expectEqual('2024-12-25T10:00', t.dateTime);
const t2 = document.createElement('time');
testing.expectEqual('', t2.dateTime);
}
</script>

View File

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

View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="test1">Test Element</div>
<div id="test2">Another Element</div>
<script id="clientDimensions">
{
const test1 = $('#test1');
// clientWidth/Height - default is 5px in dummy layout
testing.expectEqual('number', typeof test1.clientWidth);
testing.expectEqual('number', typeof test1.clientHeight);
testing.expectTrue(test1.clientWidth >= 0);
testing.expectTrue(test1.clientHeight >= 0);
// clientTop/Left should be 0 (no borders in dummy layout)
testing.expectEqual(0, test1.clientTop);
testing.expectEqual(0, test1.clientLeft);
}
</script>
<script id="scrollDimensions">
{
const test1 = $('#test1');
// In dummy layout, scroll dimensions equal client dimensions (no overflow)
testing.expectEqual(test1.clientWidth, test1.scrollWidth);
testing.expectEqual(test1.clientHeight, test1.scrollHeight);
}
</script>
<script id="scrollPosition">
{
const test1 = $('#test1');
// Initial scroll position should be 0
testing.expectEqual(0, test1.scrollTop);
testing.expectEqual(0, test1.scrollLeft);
// Setting scroll position
test1.scrollTop = 50;
testing.expectEqual(50, test1.scrollTop);
test1.scrollLeft = 25;
testing.expectEqual(25, test1.scrollLeft);
// Negative values should be clamped to 0
test1.scrollTop = -10;
testing.expectEqual(0, test1.scrollTop);
test1.scrollLeft = -5;
testing.expectEqual(0, test1.scrollLeft);
// Each element has independent scroll position
const test2 = $('#test2');
testing.expectEqual(0, test2.scrollTop);
testing.expectEqual(0, test2.scrollLeft);
test2.scrollTop = 100;
testing.expectEqual(100, test2.scrollTop);
testing.expectEqual(0, test1.scrollTop); // test1 should still be 0
}
</script>
<script id="offsetDimensions">
{
const test1 = $('#test1');
// offsetWidth/Height should be numbers
testing.expectEqual('number', typeof test1.offsetWidth);
testing.expectEqual('number', typeof test1.offsetHeight);
testing.expectTrue(test1.offsetWidth >= 0);
testing.expectTrue(test1.offsetHeight >= 0);
// Should equal client dimensions
testing.expectEqual(test1.clientWidth, test1.offsetWidth);
testing.expectEqual(test1.clientHeight, test1.offsetHeight);
}
</script>
<script id="offsetPosition">
{
const test1 = $('#test1');
const test2 = $('#test2');
// offsetTop/Left should be calculated from tree position
// These values are based on the heuristic layout engine
const top1 = test1.offsetTop;
const left1 = test1.offsetLeft;
const top2 = test2.offsetTop;
const left2 = test2.offsetLeft;
// Position values should be numbers
testing.expectEqual('number', typeof top1);
testing.expectEqual('number', typeof left1);
testing.expectEqual('number', typeof top2);
testing.expectEqual('number', typeof left2);
// Siblings should have different positions (either different x or y)
testing.expectTrue(top1 !== top2 || left1 !== left2);
}
</script>
<script id="offsetVsBounding">
{
const test1 = $('#test1');
// offsetTop/Left should match getBoundingClientRect
const rect = test1.getBoundingClientRect();
testing.expectEqual(rect.y, test1.offsetTop);
testing.expectEqual(rect.x, test1.offsetLeft);
testing.expectEqual(rect.width, test1.offsetWidth);
testing.expectEqual(rect.height, test1.offsetHeight);
}
</script>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
let event = new FocusEvent('focus');
testing.expectEqual('focus', event.type);
testing.expectEqual(true, event instanceof FocusEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(true, event instanceof Event);
testing.expectEqual(null, event.relatedTarget);
</script>
<script id=parameters>
let div = document.createElement('div');
let focusEvent = new FocusEvent('blur', { relatedTarget: div });
testing.expectEqual(div, focusEvent.relatedTarget);
</script>
<script id=createEvent>
let evt = document.createEvent('focusevent');
testing.expectEqual(true, evt instanceof FocusEvent);
testing.expectEqual(true, evt instanceof UIEvent);
</script>

View File

@@ -103,3 +103,16 @@
document.dispatchEvent(new KeyboardEvent('keytest', {key: 'b'})); document.dispatchEvent(new KeyboardEvent('keytest', {key: 'b'}));
testing.expectEqual(false, keyIsTrusted); testing.expectEqual(false, keyIsTrusted);
</script> </script>
<script id=non_keyboard_keydown>
// this used to crash
{
let called = false;
const div = document.createElement('div')
div.addEventListener('keydown', () => {
called = true;
});
div.dispatchEvent(new Event('keydown'));
testing.expectEqual(true, called);
}
</script>

View File

@@ -10,11 +10,13 @@
testing.expectEqual(0, event.clientY); testing.expectEqual(0, event.clientY);
testing.expectEqual(0, event.screenX); testing.expectEqual(0, event.screenX);
testing.expectEqual(0, event.screenY); testing.expectEqual(0, event.screenY);
testing.expectEqual(0, event.buttons);
</script> </script>
<script id=parameters> <script id=parameters>
let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20, screenX: 200, screenY: 500 }); let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20, screenX: 200, screenY: 500, buttons: 5 });
testing.expectEqual(0, new_event.button); testing.expectEqual(0, new_event.button);
testing.expectEqual(5, new_event.buttons);
testing.expectEqual(10, new_event.x); testing.expectEqual(10, new_event.x);
testing.expectEqual(20, new_event.y); testing.expectEqual(20, new_event.y);
testing.expectEqual(10, new_event.pageX); testing.expectEqual(10, new_event.pageX);

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=createEvent>
let evt = document.createEvent('TextEvent');
testing.expectEqual(true, evt instanceof TextEvent);
testing.expectEqual(true, evt instanceof UIEvent);
testing.expectEqual('', evt.data);
</script>
<script id=initTextEvent>
let textEvent = document.createEvent('TextEvent');
textEvent.initTextEvent('textInput', true, false, window, 'test data');
testing.expectEqual('textInput', textEvent.type);
testing.expectEqual('test data', textEvent.data);
testing.expectEqual(true, textEvent.bubbles);
testing.expectEqual(false, textEvent.cancelable);
</script>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=default>
let event = new WheelEvent('wheel');
testing.expectEqual('wheel', event.type);
testing.expectEqual(true, event instanceof WheelEvent);
testing.expectEqual(true, event instanceof MouseEvent);
testing.expectEqual(true, event instanceof UIEvent);
testing.expectEqual(0, event.deltaX);
testing.expectEqual(0, event.deltaY);
testing.expectEqual(0, event.deltaZ);
testing.expectEqual(0, event.deltaMode);
</script>
<script id=parameters>
let wheelEvent = new WheelEvent('wheel', {
deltaX: 10,
deltaY: 20,
deltaMode: WheelEvent.DOM_DELTA_LINE
});
testing.expectEqual(10, wheelEvent.deltaX);
testing.expectEqual(20, wheelEvent.deltaY);
testing.expectEqual(1, wheelEvent.deltaMode);
</script>
<script id=constants>
testing.expectEqual(0, WheelEvent.DOM_DELTA_PIXEL);
testing.expectEqual(1, WheelEvent.DOM_DELTA_LINE);
testing.expectEqual(2, WheelEvent.DOM_DELTA_PAGE);
</script>

View File

@@ -635,3 +635,130 @@
// https://github.com/lightpanda-io/browser/pull/1316 // https://github.com/lightpanda-io/browser/pull/1316
testing.expectError('TypeError', () => MessageEvent('')); testing.expectError('TypeError', () => MessageEvent(''));
</script> </script>
<div id=inline_parent><div id=inline_child></div></div>
<script id=inlineHandlerReceivesEvent>
// Test that inline onclick handler receives the event object
{
const inline_child = $('#inline_child');
let receivedType = null;
let receivedTarget = null;
let receivedCurrentTarget = null;
inline_child.onclick = function(e) {
// Capture values DURING handler execution
receivedType = e.type;
receivedTarget = e.target;
receivedCurrentTarget = e.currentTarget;
};
inline_child.click();
testing.expectEqual('click', receivedType);
testing.expectEqual(inline_child, receivedTarget);
testing.expectEqual(inline_child, receivedCurrentTarget);
}
</script>
<div id=inline_order_parent><div id=inline_order_child></div></div>
<script id=inlineHandlerOrder>
// Test that inline handler executes in proper order with addEventListener
{
const inline_order_child = $('#inline_order_child');
const inline_order_parent = $('#inline_order_parent');
const order = [];
// Capture listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-capture'), true);
// Inline handler on child (should execute at target phase)
inline_order_child.onclick = () => order.push('child-onclick');
// addEventListener on child (should execute at target phase, after onclick)
inline_order_child.addEventListener('click', () => order.push('child-listener'));
// Bubble listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-bubble'));
inline_order_child.click();
// Expected order: capture, then onclick, then addEventListener, then bubble
testing.expectEqual('parent-capture', order[0]);
testing.expectEqual('child-onclick', order[1]);
testing.expectEqual('child-listener', order[2]);
testing.expectEqual('parent-bubble', order[3]);
testing.expectEqual(4, order.length);
}
</script>
<div id=inline_prevent><div id=inline_prevent_child></div></div>
<script id=inlineHandlerPreventDefault>
// Test that inline handler can preventDefault and it affects addEventListener listeners
{
const inline_prevent_child = $('#inline_prevent_child');
let preventDefaultCalled = false;
let listenerSawPrevented = false;
inline_prevent_child.onclick = function(e) {
e.preventDefault();
preventDefaultCalled = true;
};
inline_prevent_child.addEventListener('click', (e) => {
listenerSawPrevented = e.defaultPrevented;
});
const result = inline_prevent_child.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true
}));
testing.expectEqual(true, preventDefaultCalled);
testing.expectEqual(true, listenerSawPrevented);
testing.expectEqual(false, result); // dispatchEvent returns false when prevented
}
</script>
<div id=inline_stop_parent><div id=inline_stop_child></div></div>
<script id=inlineHandlerStopPropagation>
// Test that inline handler can stopPropagation
{
const inline_stop_child = $('#inline_stop_child');
const inline_stop_parent = $('#inline_stop_parent');
let childCalled = false;
let parentCalled = false;
inline_stop_child.onclick = function(e) {
childCalled = true;
e.stopPropagation();
};
inline_stop_parent.addEventListener('click', () => {
parentCalled = true;
});
inline_stop_child.click();
testing.expectEqual(true, childCalled);
testing.expectEqual(false, parentCalled); // Should not bubble to parent
}
</script>
<div id=inline_replace_test></div>
<script id=inlineHandlerReplacement>
// Test that setting onclick property replaces previous handler
{
const inline_replace_test = $('#inline_replace_test');
let calls = [];
inline_replace_test.onclick = () => calls.push('first');
inline_replace_test.click();
inline_replace_test.onclick = () => calls.push('second');
inline_replace_test.click();
testing.expectEqual('first', calls[0]);
testing.expectEqual('second', calls[1]);
testing.expectEqual(2, calls.length);
}
</script>

View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<meta charset="UTF-8">
<script src="./testing.js"></script>
<script id=basic>
{
const reader = new FileReader();
testing.expectEqual(0, reader.readyState);
testing.expectEqual(null, reader.result);
testing.expectEqual(null, reader.error);
}
// Constants
testing.expectEqual(0, FileReader.EMPTY);
testing.expectEqual(1, FileReader.LOADING);
testing.expectEqual(2, FileReader.DONE);
</script>
<script id=readAsText>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["Hello, World!"], { type: "text/plain" });
let loadstartFired = false;
let progressFired = false;
let loadFired = false;
let loadendFired = false;
const promise = new Promise((resolve) => {
reader.onloadstart = function(e) {
loadstartFired = true;
testing.expectEqual("loadstart", e.type);
testing.expectEqual(1, reader.readyState);
};
reader.onprogress = function(e) {
progressFired = true;
testing.expectEqual(13, e.loaded);
testing.expectEqual(13, e.total);
};
reader.onload = function(e) {
loadFired = true;
testing.expectEqual(2, reader.readyState);
testing.expectEqual("Hello, World!", reader.result);
};
reader.onloadend = function(e) {
loadendFired = true;
testing.expectEqual(true, loadstartFired);
testing.expectEqual(true, progressFired);
testing.expectEqual(true, loadFired);
resolve();
};
});
reader.readAsText(blob);
await promise;
});
</script>
<script id=readAsDataURL>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"], { type: "text/plain" });
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("data:text/plain;base64,dGVzdA==", reader.result);
resolve();
};
});
reader.readAsDataURL(blob);
await promise;
});
// Empty MIME type
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"]);
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("data:application/octet-stream;base64,dGVzdA==", reader.result);
resolve();
};
});
reader.readAsDataURL(blob);
await promise;
});
</script>
<script id=readAsArrayBuffer>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob([new Uint8Array([65, 66, 67])]);
const promise = new Promise((resolve) => {
reader.onload = function() {
const result = reader.result;
testing.expectEqual(true, result instanceof ArrayBuffer);
testing.expectEqual(3, result.byteLength);
const view = new Uint8Array(result);
testing.expectEqual(65, view[0]);
testing.expectEqual(66, view[1]);
testing.expectEqual(67, view[2]);
resolve();
};
});
reader.readAsArrayBuffer(blob);
await promise;
});
</script>
<script id=readAsBinaryString>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["ABC"]);
const promise = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("ABC", reader.result);
resolve();
};
});
reader.readAsBinaryString(blob);
await promise;
});
</script>
<script id=abort>
// Test aborting when not loading (should do nothing)
{
const reader = new FileReader();
reader.abort(); // Should not throw
testing.expectEqual(0, reader.readyState);
}
// Note: Testing abort during read is implementation-dependent.
// In synchronous implementations (like ours), the read completes before abort can be called.
// In async implementations (like Firefox), you can abort during the read.
// We test that abort() at least doesn't throw and maintains correct state.
</script>
<script id=multipleReads>
testing.async(async () => {
const reader = new FileReader();
const blob1 = new Blob(["first"]);
const blob2 = new Blob(["second"]);
// First read
const promise1 = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("first", reader.result);
resolve();
};
});
reader.readAsText(blob1);
await promise1;
// Second read - should work after first completes
const promise2 = new Promise((resolve) => {
reader.onload = function() {
testing.expectEqual("second", reader.result);
resolve();
};
});
reader.readAsText(blob2);
await promise2;
});
</script>
<script id=addEventListener>
testing.async(async () => {
const reader = new FileReader();
const blob = new Blob(["test"]);
let loadFired = false;
const promise = new Promise((resolve) => {
reader.addEventListener("load", function() {
loadFired = true;
resolve();
});
});
reader.readAsText(blob);
await promise;
testing.expectEqual(true, loadFired);
});
</script>

View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script>
function frame1Onload() {
window.f1_onload = true;
}
</script>
<iframe id=f1 onload="frame1Onload" src="support/sub 1.html"></iframe>
<iframe id=f2 src="support/sub2.html"></iframe>
<script id="basic">
testing.eventually(() => {
testing.expectEqual(undefined, window[10]);
testing.expectEqual(window, window[0].top);
testing.expectEqual(window, window[0].parent);
testing.expectEqual(false, window === window[0]);
testing.expectEqual(window, window[1].top);
testing.expectEqual(window, window[1].parent);
testing.expectEqual(false, window === window[1]);
testing.expectEqual(false, window[0] === window[1]);
testing.expectEqual(0, $('#f1').childNodes.length);
testing.expectEqual(testing.BASE_URL + 'frames/support/sub%201.html', $('#f1').src);
testing.expectEqual(window[0], $('#f1').contentWindow);
testing.expectEqual(window[1], $('#f2').contentWindow);
testing.expectEqual(window[0].document, $('#f1').contentDocument);
testing.expectEqual(window[1].document, $('#f2').contentDocument);
// sibling frames share the same top
testing.expectEqual(window[0].top, window[1].top);
// child frames have no sub-frames
testing.expectEqual(0, window[0].length);
testing.expectEqual(0, window[1].length);
// self and window are self-referential on child frames
testing.expectEqual(window[0], window[0].self);
testing.expectEqual(window[0], window[0].window);
testing.expectEqual(window[1], window[1].self);
// child frame's top.parent is itself (root has no parent)
testing.expectEqual(window, window[0].top.parent);
// testing.expectEqual(true, window.sub1_loaded);
// testing.expectEqual(true, window.sub2_loaded);
});
</script>
<script id=onload>
{
let f3_load_event = false;
let f3 = document.createElement('iframe');
f3.addEventListener('load', () => {
f3_load_event = true;
});
f3.src = 'invalid'; // still fires load!
document.documentElement.appendChild(f3);
testing.eventually(() => {
testing.expectEqual(true, window.f1_onload);
testing.expectEqual(true, f3_load_event);
});
}
</script>
<script id=count>
testing.eventually(() => {
testing.expectEqual(3, window.length);
});
</script>

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<div id=div-1>sub1 div1</div>
<script>
// should not have access to the parent's JS context
window.top.sub1_loaded = window.testing == undefined;
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<div id=div-1>sub2 div1</div>
<script>
// should not have access to the parent's JS context
window.top.sub2_loaded = window.testing == undefined;
</script>

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=constructor-basic>
{
const img = new ImageData(10, 20);
testing.expectEqual(10, img.width);
testing.expectEqual(20, img.height);
testing.expectEqual("srgb", img.colorSpace);
testing.expectEqual("rgba-unorm8", img.pixelFormat);
}
</script>
<script id=data-property>
{
const img = new ImageData(2, 3);
const data = img.data;
testing.expectEqual(true, data instanceof Uint8ClampedArray);
// 2 * 3 * 4 (RGBA) = 24 bytes
testing.expectEqual(24, data.length);
}
</script>
<script id=data-initialized-to-zero>
{
const img = new ImageData(2, 2);
const data = img.data;
for (let i = 0; i < data.length; i++) {
testing.expectEqual(0, data[i]);
}
}
</script>
<script id=data-mutability>
{
const img = new ImageData(1, 1);
const data = img.data;
// Set pixel to red (RGBA)
data[0] = 255;
data[1] = 0;
data[2] = 0;
data[3] = 255;
// Read back through the same accessor
const data2 = img.data;
testing.expectEqual(255, data2[0]);
testing.expectEqual(0, data2[1]);
testing.expectEqual(0, data2[2]);
testing.expectEqual(255, data2[3]);
}
</script>
<script id=constructor-with-settings>
{
const img = new ImageData(5, 5, { colorSpace: "srgb" });
testing.expectEqual(5, img.width);
testing.expectEqual(5, img.height);
testing.expectEqual("srgb", img.colorSpace);
}
</script>
</script>
<script id=single-pixel>
{
const img = new ImageData(1, 1);
testing.expectEqual(4, img.data.length);
testing.expectEqual(1, img.width);
testing.expectEqual(1, img.height);
}
</script>
<script id=too-large>
testing.expectError("IndexSizeError", () => new ImageData(2_147_483_648, 2_147_483_648));
</script>

View File

@@ -25,6 +25,8 @@
testing.expectEqual('number', typeof entry.intersectionRatio); testing.expectEqual('number', typeof entry.intersectionRatio);
testing.expectEqual('object', typeof entry.boundingClientRect); testing.expectEqual('object', typeof entry.boundingClientRect);
testing.expectEqual('object', typeof entry.intersectionRect); testing.expectEqual('object', typeof entry.intersectionRect);
testing.expectEqual('number', typeof entry.time);
testing.expectEqual(true, entry.time > 0);
observer.disconnect(); observer.disconnect();
}); });

View File

@@ -43,7 +43,7 @@
testing.expectEqual(5, input.maxLength); testing.expectEqual(5, input.maxLength);
input.maxLength = 'banana'; input.maxLength = 'banana';
testing.expectEqual(0, input.maxLength); testing.expectEqual(0, input.maxLength);
testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;}); testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => { input.maxLength = -45;});
testing.expectEqual(20, input.size); testing.expectEqual(20, input.size);
input.size = 5; input.size = 5;

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