229 Commits

Author SHA1 Message Date
Pierre Tachoire
03ed45637a Merge pull request #1889 from lightpanda-io/wp/mrdimidium/refactor-redirects
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / demo-scripts (push) Blocked by required conditions
e2e-test / wba-demo-scripts (push) Blocked by required conditions
e2e-test / wba-test (push) Blocked by required conditions
e2e-test / cdp-and-hyperfine-bench (push) Blocked by required conditions
e2e-test / perf-fmt (push) Blocked by required conditions
e2e-test / browser fetch (push) Blocked by required conditions
zig-test / zig fmt (push) Waiting to run
zig-test / zig test using v8 in debug mode (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
Rework header/data callbacks in HttpClient
2026-03-27 14:22:58 +01:00
Nikolay Govorov
9068fe718e Fix SameSite cookies 2026-03-27 11:16:46 +00:00
Nikolay Govorov
5369d25213 fix recv e2e test 2026-03-27 09:49:16 +00:00
Nikolay Govorov
649d8d1024 Remove duplication in cookies instalation 2026-03-27 09:49:13 +00:00
Nikolay Govorov
15d60d845a Fixup error handling in HttpClient process messages 2026-03-27 09:49:11 +00:00
Nikolay Govorov
c4b837b598 Revert log reimport 2026-03-27 09:49:09 +00:00
Nikolay Govorov
54391238c9 Move cdp callbacks from dataCallback to processMessages 2026-03-27 09:49:07 +00:00
Nikolay Govorov
d33edc5697 Fixup cookies management 2026-03-27 09:49:05 +00:00
Nikolay Govorov
16ca8d4b14 Fix cleanup connections in HttpClient 2026-03-27 09:49:03 +00:00
Nikolay Govorov
707ffb4893 Move redirects handling from curl callbacks 2026-03-27 09:48:59 +00:00
Pierre Tachoire
4782b37216 Merge pull request #2016 from lightpanda-io/readme-mention-cors
mention CORS is missing in the README's status
2026-03-27 08:34:09 +01:00
Pierre Tachoire
ce197256dd Merge pull request #2010 from lightpanda-io/build-pre-nightly
build: simplify nightly versioning
2026-03-27 08:33:45 +01:00
Pierre Tachoire
e6d644998a mention CORS is missing in the README's status 2026-03-27 08:26:56 +01:00
Karl Seguin
67bd555e75 Merge pull request #2013 from lightpanda-io/cleanup_dead_code_removal
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove unused imports
2026-03-27 13:52:49 +08:00
Adrià Arrufat
a10e533701 Remove more unused imports 2026-03-27 14:24:17 +09:00
Karl Seguin
0065677273 Merge pull request #2011 from lightpanda-io/mcp-fixes
MCP fixes
2026-03-27 13:02:59 +08:00
Karl Seguin
226d9bfc6f zig fmt 2026-03-27 12:47:24 +08:00
Karl Seguin
2e65ae632e Merge pull request #2009 from lightpanda-io/fix/issue-1960
mcp: improve argument parsing error handling
2026-03-27 12:46:34 +08:00
Karl Seguin
ea422075c7 Remove unused imports
And some smaller cleanups.
2026-03-27 12:45:26 +08:00
Adrià Arrufat
1d54e6944b mcp: send error response when message is too long 2026-03-27 11:36:18 +09:00
Adrià Arrufat
de32e5cf34 mcp: handle missing request IDs safely 2026-03-27 11:34:06 +09:00
Adrià Arrufat
c8d8ca5e94 mcp: improve error handling in resources and tools
- Handle failures during HTML, Markdown, and link serialization.
- Return MCP internal errors when result serialization fails.
- Refactor resource reading logic for better clarity and consistency.
2026-03-27 11:28:47 +09:00
Adrià Arrufat
7f2139f612 build: simplify nightly versioning 2026-03-27 10:47:43 +09:00
Adrià Arrufat
da0828620f mcp: improve argument parsing error handling
Closes #1960
2026-03-27 10:04:45 +09:00
Adrià Arrufat
cdd33621e3 Merge pull request #2005 from lightpanda-io/mcp-lp-node-registry
MCP/CDP: unify node registration
2026-03-27 09:36:08 +09:00
Karl Seguin
8001709506 Merge pull request #2002 from lightpanda-io/nikneym/form-data-event
Support `FormDataEvent`
2026-03-27 08:16:32 +08:00
Karl Seguin
a0ae6b4c92 Merge pull request #2008 from buley/feature/fix-scanner-warnings
chore: fix dead code and error swallowing warnings
2026-03-27 08:10:31 +08:00
Karl Seguin
fdf7f5267a Merge pull request #2001 from lightpanda-io/refactor/mcp-tools-dedup
mcp: extract parseOptionalAndGetPage helper
2026-03-27 07:58:18 +08:00
Taylor
88e0b39d6b chore: fix dead code and error swallowing warnings
Fixes issues reported by polyglot-scanner:
- Removed explicit `return` keywords and trailing semicolons to resolve DEAD_CODE/DEAD_BRANCH warnings.
- Replaced `epoch::advance().unwrap()` and `stats::resident::read().unwrap()` with safer alternatives (`drop` and `unwrap_or(0)`) to resolve ERROR_SWALLOW warnings.
- Replaced `let _ = Box::from_raw(...)` with `drop(Box::from_raw(...))` to correctly drop the box while fixing the ERROR_SWALLOW warning.
2026-03-26 09:58:49 -07:00
Pierre Tachoire
f95396a487 Merge pull request #1998 from lightpanda-io/url_origin_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Improve authority parsing
2026-03-26 17:32:40 +01:00
Pierre Tachoire
d02d05b246 Merge pull request #2004 from lightpanda-io/nikneym/resize-unobserver
`ResizeObserver`: make `unobserve` available in JS context
2026-03-26 16:48:42 +01:00
Pierre Tachoire
7b2d817d0e Merge pull request #2003 from lightpanda-io/nikneym/canvas-access-canvas
`CanvasRenderingContext2D`: make canvas able to access canvas element
2026-03-26 16:48:11 +01:00
Adrià Arrufat
7e778a17d6 MCP/CDP: unify node registration
This fixes a bug in MCP where interactive elements were not assigned
a backendNodeId, preventing agents from clicking or filling them. Also
extracts link collection to a shared browser module.
2026-03-26 23:51:43 +09:00
Pierre Tachoire
a0dd14aaad Merge pull request #1999 from lightpanda-io/wait_until_default
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix --wait-until default value.
2026-03-26 15:03:59 +01:00
Halil Durak
d447d1e3c7 ResizeObserver: make unobserve available in JS context 2026-03-26 16:37:17 +03:00
Halil Durak
8684d35394 add tests 2026-03-26 16:35:23 +03:00
Halil Durak
e243f96988 CanvasRenderingContext2D: make canvas able to access canvas element 2026-03-26 16:35:13 +03:00
Pierre Tachoire
7ea8f3f766 Merge pull request #2000 from lightpanda-io/add-pre-version
add a -Dpre_version build flag for custom pre version
2026-03-26 12:06:38 +01:00
Halil Durak
5e6082b5e9 FormDataEvent: add tests 2026-03-26 14:04:03 +03:00
Halil Durak
1befd9a5e8 make comment on SubmitEvent doc-comment 2026-03-26 14:03:51 +03:00
Halil Durak
e103ce0f39 FormDataEvent: initial support 2026-03-26 14:03:33 +03:00
Adrià Arrufat
14fa2da2ad mcp: remove duplicate code in testLoadPage 2026-03-26 19:57:14 +09:00
Pierre Tachoire
28cc60adb0 add a -Dpre_version build flag for custom pre version 2026-03-26 11:52:16 +01:00
Adrià Arrufat
96d24b5dc6 mcp: extract parseOptionalAndGetPage helper
Deduplicate the repeated "parse optional URL, maybe navigate, get page"
pattern across 6 MCP tool handlers (markdown, links, semantic_tree,
interactiveElements, structuredData, detectForms).
2026-03-26 19:44:44 +09:00
Karl Seguin
c14a9ad986 Merge pull request #1992 from navidemad/cdp-page-reload
CDP: implement Page.reload
2026-03-26 18:14:49 +08:00
Karl Seguin
679f2104f4 Fix --wait-until default value.
This was `load`, but it should have been (and was documented as `done`). This
is my fault. Sorry.

Should help with: https://github.com/lightpanda-io/browser/issues/1947#issuecomment-4120597764
2026-03-26 18:06:14 +08:00
Navid EMAD
c6b0c75106 Address review: use arena.dupeZ for URL copy, add try to testing.context()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Navid EMAD
93485c1ef3 CDP: implement Page.reload
Add `Page.reload` to the CDP Page domain dispatch. Reuses the existing
`page.navigate()` path with `NavigationKind.reload`, matching what
`Location.reload` already does for the JS `location.reload()` API.

Accepts the standard CDP params (`ignoreCache`, `scriptToEvaluateOnLoad`)
per the Chrome DevTools Protocol spec.

The current page URL is copied to the stack before `replacePage()` to
avoid a use-after-free when the old page's arena is freed.

This unblocks CDP clients (Puppeteer, capybara-lightpanda, etc.) that
call `Page.reload` and currently get `UnknownMethod`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Karl Seguin
0324d5c232 Merge pull request #1997 from lightpanda-io/update-zig-v8
build: bump zig-v8 to v0.3.7
2026-03-26 16:01:40 +08:00
Karl Seguin
0588cc374d Improve authority parsing
Only look for @ within the first part of the url (up to the first possible
separator, i.e /, # or ?). This fixes potentially incorrect (and insecure)
getOrigin and getHost, both of which use the new helper.

Also make port parsing IPv6-aware.
2026-03-26 13:22:56 +08:00
Adrià Arrufat
a75c0cf08d build: bump zig-v8 to v0.3.7 2026-03-26 12:34:10 +09:00
Karl Seguin
2812b8f07c Merge pull request #1991 from lightpanda-io/v8_signature
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Set v8::Signature on FunctionTemplates
2026-03-26 09:27:22 +08:00
Karl Seguin
e2afbec29d update v8 dep 2026-03-26 09:17:32 +08:00
Karl Seguin
a45f9cb810 Set v8::Signature on FunctionTemplates
This causes v8 to verify the receiver of a function, and prevents calling an
accessor or function with the wrong receiver, e.g.:

```
const g = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
g.call(null);
```

A few other cleanups in this commit:
1 - Define any accessor with a getter as ReadOnly
2 - Ability to define an accessor with the DontDelete attribute
    (window.document and window.location)
3 - Replace v8__ObjectTemplate__SetAccessorProperty__DEFAULTX overloads with
    new v8__ObjectTemplate__SetAccessorProperty__Config
4 - Remove unnecessary @constCast for FunctionTemplate which can be const
    everywhere.
2026-03-26 09:15:33 +08:00
Karl Seguin
cf641ed458 Merge pull request #1990 from lightpanda-io/remove_cdp_generic
Remove cdp generic
2026-03-26 07:49:13 +08:00
Karl Seguin
0fc959dcc5 re-anble unreachable 2026-03-26 07:42:45 +08:00
Karl Seguin
077376ea04 Merge pull request #1985 from lightpanda-io/intersection_observer_root_document
Allow Document to be the root of an intersection observer
2026-03-26 07:41:40 +08:00
Karl Seguin
6ed8d1d201 Merge pull request #1981 from lightpanda-io/window_cross_origin
Window cross origin
2026-03-26 07:41:22 +08:00
Karl Seguin
5207bd4202 Merge pull request #1980 from lightpanda-io/frames_test
Improve async tests
2026-03-26 07:41:05 +08:00
Karl Seguin
11ed95290b Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-26 07:35:05 +08:00
Pierre Tachoire
a876275828 Merge pull request #1995 from lightpanda-io/ci-wba-test
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
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: don't run wba test on PR
2026-03-25 18:09:26 +01:00
Pierre Tachoire
e83b8aa36d ci: don't run wba test on PR
wba test requires secrets read to run.
But we don't want to exposes secrets on external contributions.
So it's easier to run it only after PR merged.
2026-03-25 16:55:45 +01:00
Halil Durak
179f9c1169 Merge pull request #1984 from navidemad/fix-submit-event-submitter
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix Form.requestSubmit(submitter) not setting SubmitEvent.submitter
2026-03-25 15:39:57 +03:00
Karl Seguin
ca41bb5fa2 fix import casing 2026-03-25 17:54:24 +08:00
Pierre Tachoire
9c37961042 Merge pull request #1989 from lightpanda-io/licensing.md
update LICENSING.md
2026-03-25 10:43:57 +01:00
Karl Seguin
0dd0495ab8 Removes CDPT (generic CDP)
CDPT used to be a generic so that we could inject Browser, Session, Page and
Client. At some point, it [thankfully] became a generic only to inject Client.

This commit removes the generic and bakes the *Server.Client instance in CDP.
It uses a socketpair for testing.

BrowserContext is still generic, but that's generic for a very different reason
and, while I'd like to remove that generic too, it belongs in a different PR.
2026-03-25 17:43:30 +08:00
Pierre Tachoire
c9fa76da0c update LICENSING.md 2026-03-25 10:42:52 +01:00
Halil Durak
7718184e22 Merge pull request #1983 from lightpanda-io/nikneym/crypto-changes
Small `SubtleCrypto` refactor
2026-03-25 11:23:13 +03:00
Karl Seguin
b81b41cbf0 Merge pull request #1987 from lightpanda-io/conn-close
handle Connection: close without TLS close_notify
2026-03-25 16:11:42 +08:00
Pierre Tachoire
3a0cead03a Merge pull request #1917 from lightpanda-io/semantic-versioning
build: automate version resolution in build.zig
2026-03-25 08:46:04 +01:00
Pierre Tachoire
92ce6a916a http: don't check transfer._header_done_called on RecvError 2026-03-25 08:23:23 +01:00
Adrià Arrufat
130bf7ba11 Merge pull request #1951 from mvanhorn/osc/feat-mcp-detect-forms
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
mcp: add detectForms tool for structured form discovery
2026-03-25 14:23:09 +09:00
Adrià Arrufat
2e40354a3a forms: add tests for input values and form defaults 2026-03-25 09:39:52 +09:00
Adrià Arrufat
3074bde2f3 forms: always include required and disabled fields 2026-03-25 09:35:17 +09:00
Adrià Arrufat
ed9f5aae2e docs(forms): clarify arena allocator requirement for collectForms 2026-03-25 09:33:10 +09:00
Adrià Arrufat
8e315e551a forms: extract form node registration logic 2026-03-25 09:30:06 +09:00
Pierre Tachoire
bad690da65 handle Connection: close without TLS close_notify
Some servers (e.g. ec.europa.eu) close the TCP connection without
sending a TLS close_notify alert after responding with Connection: close.
BoringSSL treats this as a fatal error, which libcurl surfaces as
CURLE_RECV_ERROR. If we already received valid HTTP headers and the
response included Connection: close, the connection closure is the
expected end-of-body signal per HTTP/1.1 — treat it as success.

You can reproduce with
```
lightpanda fetch https://ec.europa.eu/commission/presscorner/detail/en/ip_26_614
```
2026-03-24 21:20:59 +01:00
Karl Seguin
ae080f32eb Allow Document to be the root of an intersection observer
We previously only supported an Element. null == viewport, but document means
the entire (scrollable) area, since we don't render anything, treating
document  as null seems ok?
2026-03-24 21:48:38 +08:00
Pierre Tachoire
c5c1d1f2f8 tag next version to 1.0.0 2026-03-24 14:47:20 +01:00
Pierre Tachoire
eb18dc89f6 ci: use -Dversion_string for release (nightly) build 2026-03-24 14:46:48 +01:00
Navid EMAD
afb0c29243 Add submit_event case to Event.Type exhaustive switch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:24:35 +01:00
Navid EMAD
267eee9693 Fix Form.requestSubmit(submitter) not setting SubmitEvent.submitter
Create SubmitEvent type and use it in submitForm() so that
e.submitter is correctly set when requestSubmit(submitter) is called.

Fixes #1982

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:19:50 +01:00
Halil Durak
39352a6bda refactor SubtleCrypto
I've been thinking the implementation here is messy (ever since we added support for it) and thought it would be better to separate each algorithm to their respective files in order to maintain in a long run. `digest` is also refactored to prefer libcrypto instead of std.
2026-03-24 16:04:50 +03:00
Halil Durak
0838b510f8 src/crypto.zig -> src/sys/libcrypto.zig
Now that we have `sys/`, I think this makes more sense.
2026-03-24 16:04:49 +03:00
Karl Seguin
b19f30d865 Start allowing some cross-origin scripting.
There are a few things allowed in cross origin scripting, the most important
being window.postMessage and window.parent.

This commit changes window-returning functions (e.g. window.top, window.parent
iframe.contentWindow) from always returning a *Window, to conditionally
returning a *Window or a *CrossOriginWindow. The CrossOriginWindow only allows
a few methods (e.g. postMessage).
2026-03-24 19:27:55 +08:00
Karl Seguin
35be9f897f Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-24 17:21:39 +08:00
Karl Seguin
d517488158 Merge pull request #1979 from lightpanda-io/dash-command-line-arguments
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Support (and prefer) dash-separated command line arguments
2026-03-24 17:12:09 +08:00
Karl Seguin
fee8fe7830 Merge pull request #1978 from lightpanda-io/eventually_rename_onload
Rename testing.eventually to testing.onload, to make it more clear
2026-03-24 17:11:49 +08:00
Karl Seguin
428190aecc Merge pull request #1972 from lightpanda-io/fix-issue-1970
Fix Expo Web crash by gracefully handling at-rules in CSSStyleSheet.insertRule
2026-03-24 13:52:09 +08:00
Karl Seguin
61dabdedec Support (and prefer) dash-separated command line arguments
--log_level -> --log-level

Underscored arguments are still supported for backwards compatibility.
2026-03-24 12:55:08 +08:00
Karl Seguin
dfd9f216bd Rename testing.eventually to testing.onload, to make it more clear 2026-03-24 12:21:46 +08:00
Adrià Arrufat
567cd97312 webapi.Element: centralize disabled state logic 2026-03-24 13:13:53 +09:00
Adrià Arrufat
0bfe00bbb7 css: disallow multiple rules in insertRule 2026-03-24 12:53:49 +09:00
Adrià Arrufat
260768463b Merge branch 'main' into osc/feat-mcp-detect-forms 2026-03-24 09:25:47 +09:00
Adrià Arrufat
fd96cd6eb9 chore(css): log unimplemented at-rules in insertRule 2026-03-24 09:20:21 +09:00
Adrià Arrufat
25a7b5b778 Merge pull request #1977 from lightpanda-io/check_dirty_once
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Only check StyleSheet dirty flag at the start (once) of operation
2026-03-24 09:12:10 +09:00
Karl Seguin
d4bcfa974f Only check StyleSheet dirty flag at the start (once) of operation 2026-03-24 07:55:11 +08:00
Karl Seguin
c91eac17d0 Merge pull request #1975 from lightpanda-io/percent-encode-path
fix: percent-encode pathname in URL.setPathname per URL spec
2026-03-24 07:41:27 +08:00
Karl Seguin
5c79961bb7 Merge pull request #1969 from lightpanda-io/fix_append_child_crash
Handle `appendAllChildren` mutating the list of children
2026-03-24 07:29:16 +08:00
Karl Seguin
a0c200bc49 Merge pull request #1968 from lightpanda-io/document_write_deleted_parent
Handle nested document.write where parent gets deleted
2026-03-24 07:29:08 +08:00
Karl Seguin
9ea39e1c34 Merge pull request #1967 from lightpanda-io/css_anchor_normalization
Anchor(...) css property normalization
2026-03-24 07:28:59 +08:00
Karl Seguin
f7125d2bf3 Merge pull request #1964 from lightpanda-io/currentSrc
Add Image.currentSrc and Media.currentSrc
2026-03-24 07:28:51 +08:00
Karl Seguin
b163d9709b Merge pull request #1959 from lightpanda-io/form_iterator
Expose form.iterator()
2026-03-24 07:28:31 +08:00
Karl Seguin
5453630955 Merge pull request #1958 from lightpanda-io/runner
Extract Session.wait into a Runner
2026-03-24 07:28:18 +08:00
Pierre Tachoire
8ada67637f fix: precent-encode hash and search 2026-03-23 17:22:50 +01:00
Adrià Arrufat
5972630e95 Update CSS parser to track skipped at-rules and refine insertRule logic 2026-03-24 00:54:20 +09:00
Pierre Tachoire
58c18114a5 fix: percent-encode pathname in URL.setPathname per URL spec
URL.setPathname() inserted the value verbatim without percent-encoding,
so `url.pathname = "c d"` produced `http://a/c d` instead of
`http://a/c%20d`. This caused sites using URL polyfills (e.g. Angular's
polyfills bundle) to detect broken native URL support and fall back to a
polyfill that relies on HTMLInputElement.checkValidity(), which is not
implemented — crashing the entire app bootstrap.
2026-03-23 16:52:39 +01:00
Pierre Tachoire
a94b0bec93 Merge pull request #1946 from lightpanda-io/cdp-response-body
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Encode non-utf8 Network.getResponseBody in base64
2026-03-23 16:46:12 +01:00
Adrià Arrufat
ff0fbb6b41 Fix Expo Web crash by gracefully handling at-rules in CSSStyleSheet.insertRule 2026-03-23 23:45:11 +09:00
Pierre Tachoire
797cae2ef8 encode captured response body during CDP call 2026-03-23 14:26:27 +01:00
Karl Seguin
433c03c709 Handle appendAllChildren mutating the list of children
`appendAllChildren` iterates through the children, but when a child is appended
it can mutate the DOM (only via a custom element connected callback AFAIK) which
can render the iterator invalid. Constantly get parent.firstChild() as the
target.
2026-03-23 21:16:11 +08:00
Karl Seguin
4d3e9feaf4 Handle nested document.write where parent gets deleted
Handles a real life case where a nested document.write mutates the DOM in a way
where there outer document.write loses its parent.
2026-03-23 21:00:02 +08:00
Karl Seguin
5700e214bf Merge pull request #1966 from lightpanda-io/mcp_tools_test
Improve MCP tools test
2026-03-23 20:47:42 +08:00
Karl Seguin
88d40a7dcd Anchor(...) css property normalization
Expands on https://github.com/lightpanda-io/browser/pull/1754 to do for
anchor(...) what we did for anchor-size(...)

fixes a number of WPT tests in:
/css/css-anchor-position/anchor-parse-valid.html
2026-03-23 20:32:03 +08:00
Karl Seguin
ff209f5adf Merge pull request #1955 from lightpanda-io/advertise_host
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add --advertise_host option to serve command
2026-03-23 20:00:42 +08:00
Pierre Tachoire
8ad092a960 Merge pull request #1965 from lightpanda-io/katie-lpd-patch-1
Update README.md
2026-03-23 12:20:16 +01:00
Karl Seguin
0fcdc1d194 Improve MCP tools test
Add helper to navigate to page, to reduce the boilerplate in each test.

Reduce waitForSelector time from 200ms to 20ms to speed up tests.
2026-03-23 19:15:50 +08:00
Karl Seguin
60c2359fdd Merge pull request #1797 from lightpanda-io/css-improvements
Implement CSSOM and Enhanced Visibility Filtering
2026-03-23 19:11:35 +08:00
katie-lpd
08c8ba72f5 Update README.md
Update benchmark images and text to real benchmark over the network
2026-03-23 12:05:44 +01:00
Karl Seguin
cfa4201532 Add Image.currentSrc and Media.currentSrc 2026-03-23 18:20:15 +08:00
Karl Seguin
cb02eb000e Merge pull request #1961 from lightpanda-io/test_runner_fail_summary
Print summary of failed tests name at end of test runner
2026-03-23 18:13:37 +08:00
Karl Seguin
23334edc05 Merge pull request #1963 from lightpanda-io/nested_navigation
Use double-queue to better support recursive navigation
2026-03-23 18:13:18 +08:00
Karl Seguin
8dbe22a01a Use double-queue to better support recursive navigation
Enqueuing while processing the navigation queue is rare, but apparently can
happen. The most likely culprit is the microqueue task being processed which
enqueues a new navigation (e.g. when a promise resolves).

This was never well handled, with the possibility of a use-after-free or of
skipping the new navigation. This commit introduces a double queue, which is
swapped at the start of processing, so that we always have 1 list for queueing
new navigation requests, and one list that we're currently processing.
2026-03-23 18:00:04 +08:00
Adrià Arrufat
80235e2ddd test: fix scoping bug in frames test causing spurious failures 2026-03-23 16:04:21 +09:00
Karl Seguin
2abed9fe75 Print summary of failed tests name at end of test runner
Helps to see, at a glance, which test failed without having to scroll up through
the list.
2026-03-23 15:00:51 +08:00
Matt Van Horn
35551ac84e fix: add disabled flag, external form fields, and param ordering
Address review feedback from @karlseguin:

1. Use Form.getElements() instead of manual TreeWalker for field
   collection. This reuses NodeLive(.form) which handles fields
   outside the <form> via the form="id" attribute per spec.

2. Add disabled detection: checks both the element's disabled
   attribute and ancestor <fieldset disabled> (with first-legend
   exemption per spec). Fields are flagged rather than excluded -
   agents need visibility into disabled state.

3. allocator is now the first parameter in collectForms/helpers.

4. handleDetectForms returns InvalidParams on bad input instead
   of silently swallowing parse errors.

5. Added tests for disabled fields, disabled fieldsets, and
   external form fields via form="id".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:31:59 -07:00
Adrià Arrufat
c3a2318eca fix: pass allocator as first parameter in forms.zig 2026-03-23 15:27:49 +09:00
Adrià Arrufat
a6e801be59 forms: casting
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-23 15:12:16 +09:00
Adrià Arrufat
0bbe25ab5e forms: casting
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-23 15:12:02 +09:00
Adrià Arrufat
c37286f845 forms: casting
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-23 15:11:47 +09:00
Karl Seguin
34079913a3 Expose form.iterator()
Meant to help things like https://github.com/lightpanda-io/browser/pull/1951

Small optimization to form node_live iterator

Disable iframes test (not related, but they are super-flaky, and I'm tired of
CI's failing because of it. I'll look at them later today).
2026-03-23 13:12:22 +08:00
Adrià Arrufat
4f1b499d0f zig fmt 2026-03-23 13:52:28 +09:00
Karl Seguin
c9bc370d6a Extract Session.wait into a Runner
This is done for a couple reasons. The first is just to have things a little
more self-contained for eventually supporting more advanced "wait" logic, e.g.
waiting for a selector.

The other is to provide callers with more fine-grained controlled. Specifically
the ability to manually "tick", so that they can [presumably] do something
after every tick. This is needed by the test runner to support more advanced
cases (cases that need to test beyond 'load') and it also improves (and fixes
potential use-after-free, the lp.waitForSelector)
2026-03-23 12:30:41 +08:00
Adrià Arrufat
4b29823a5b refactor: simplify form extraction and remove const casts 2026-03-23 13:24:21 +09:00
Karl Seguin
a69a22ccd7 Merge pull request #1948 from lightpanda-io/cdp-waitforselector
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
CDP: add waitForSelector to lp.actions
2026-03-23 10:09:09 +08:00
Adrià Arrufat
a6d2ec7610 refactor: share form node ID serialization between MCP and CDP 2026-03-23 10:18:24 +09:00
Adrià Arrufat
ad83c6e70b test: fix forms unit test method casing to match normalization 2026-03-22 21:14:26 +09:00
Adrià Arrufat
c2a0d4c0b2 Merge pull request #1950 from mvanhorn/osc/feat-mcp-action-feedback
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
mcp: return page state from click/fill/scroll tools
2026-03-22 20:55:52 +09:00
Adrià Arrufat
9e7f0b4776 test: verify feedback message content in click/fill/scroll MCP tools 2026-03-22 20:39:20 +09:00
Karl Seguin
e3085cb0f1 fix test 2026-03-22 12:47:33 +08:00
Karl Seguin
4e2e895cd9 Add --advertise_host option to serve command
Allows overwriting the --host for the json/version payload. When --host is set
to 0.0.0.0, we want to provide a mechanism to specify the specific address to
connect to in /json/version (or anywhere else that we "advertise" the address).

Inspired by https://github.com/lightpanda-io/browser/pull/1923 but rather than
defaulting to 127.0.0.1 (which seems just as unsafe), adds the explicit config
option.
2026-03-22 12:40:17 +08:00
Karl Seguin
c1fc2b1301 Merge pull request #1949 from lightpanda-io/1800-fix-startup-frame-id
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix Page.getFrameId on STARTUP when a browser context and a target exist
2026-03-22 07:14:33 +08:00
Karl Seguin
324e5eb152 Merge pull request #1945 from lightpanda-io/replace_children
Add validation to replaceChildren
2026-03-22 06:44:53 +08:00
Karl Seguin
df4df64066 Merge pull request #1944 from lightpanda-io/about_blank_location
new URL('about:blank');
2026-03-22 06:44:37 +08:00
Karl Seguin
c557a0fd87 Merge pull request #1942 from lightpanda-io/about_blank_resolve
Search for base page when resolving from about:blank
2026-03-22 06:44:19 +08:00
Karl Seguin
a869f92e9a Merge pull request #1939 from lightpanda-io/timer_cleanup
More aggressive timer cleanup
2026-03-22 06:44:00 +08:00
Matt Van Horn
4d28265839 fix: use raw action attribute instead of resolved URL in forms
Form.getAction() resolves relative URLs against the page base, which
causes test failures when the page URL is a test server address. Use
the raw action attribute value instead, which matches what agents need
to understand the form's target path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:54:33 -07:00
Matt Van Horn
78c6def2b1 mcp: add detectForms tool for structured form discovery
Add a detectForms MCP tool and lp.detectForms CDP command that return
structured form metadata from the current page. Each form includes its
action URL, HTTP method, and fields with names, types, required status,
values, select options, and backendNodeIds for use with the fill tool.

This lets AI agents discover and fill forms in a single step instead of
calling interactiveElements, filtering for form fields, and guessing
which fields belong to which form.

New files:
- src/browser/forms.zig: FormInfo/FormField structs, collectForms()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:40:50 -07:00
Matt Van Horn
87a0690776 mcp: return page state from click/fill/scroll tools
After click, fill, and scroll actions, return the current page URL
and title instead of static success messages. This gives AI agents
immediate feedback about the page state after an action, matching
the pattern already used by waitForSelector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:32:32 -07:00
Pierre Tachoire
fbc71d6ff7 cdp: handle STARTUP session into Page.getFrameTree gracefully 2026-03-21 16:29:58 +01:00
Adrià Arrufat
e10ccd846d CDP: add waitForSelector to lp.actions
It refactors the implementation from MCP to be reused.
2026-03-22 00:09:02 +09:00
Pierre Tachoire
384b2f7614 cdp: call Page.getFrameTree on startup when possible 2026-03-21 16:07:48 +01:00
Adrià Arrufat
fdc79af55c Merge pull request #1941 from mvanhorn/osc/feat-mcp-waitforselector
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Add waitForSelector MCP tool
2026-03-21 23:59:14 +09:00
Matt Van Horn
e9bed18cd8 test: add waitForSelector MCP tool tests
Add three test cases covering:
- Immediate match on an already-present element
- Polling match on an element added after a 200ms setTimeout delay
- Timeout error on a non-existent element with a short timeout

Add mcp_wait_for_selector.html test fixture that injects a #delayed
element after 200ms via setTimeout for the polling test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 06:40:43 -07:00
Pierre Tachoire
30f387d361 encode captured response depending of the content type 2026-03-21 14:11:06 +01:00
Karl Seguin
e7d272eaf6 Merge pull request #1940 from lightpanda-io/fix-mcp-crash
mcp: initialize server in mcpThread to avoid V8 isolate crashes
2026-03-21 20:35:31 +08:00
Pierre Tachoire
00d06dbe8c encode all captured responses body in base64 2026-03-21 13:29:58 +01:00
Adrià Arrufat
7b104789aa build: simplify dev version resolution 2026-03-21 21:13:50 +09:00
Pierre Tachoire
2107ade3a5 use a CapturedResponse struct for captured responses 2026-03-21 13:11:18 +01:00
Karl Seguin
e60424a402 Add validation to replaceChildren
Extract Document.replaceChildren, Element.replaceChildren and
DocumentFragment.replaceChildren into a common helper, Node.replaceChildren.

Fixes an infinite loop in WPT test:
/dom/nodes/ParentNode-replaceChildren.html
2026-03-21 19:39:49 +08:00
Karl Seguin
107da49f81 new URL('about:blank');
Add correct handling for new URL('about:blank');

When a frame is navigated to about:blank (which happens often, since it happens
as soon as a dynamic iframe is created), we make sure to give window._location
a unique value. This prevents 2 frames from referencing the same
window._location object.

Fixes a WPT crash in: 0/html/browsers/browsing-the-web/navigating-across-documents/initial-empty-document/iframe-nosrc.html
2026-03-21 18:41:58 +08:00
Karl Seguin
3e309da69f Search for base page when resolving from about:blank
When the base page (*cough* frame *cough*) is about:blank, then we need to go
up the parents to find the actual base url to resolve any new navigation URLs.
2026-03-21 16:03:39 +08:00
Adrià Arrufat
370ae2b85c main: zig fmt 2026-03-21 14:06:08 +09:00
Matt Van Horn
6008187c78 Add waitForSelector MCP tool
Adds a waitForSelector tool to the MCP server that polls for a CSS
selector match with a configurable timeout (default 5000ms). Returns the
backendNodeId of the matched element for use with click/fill tools.

The tool runs the session event loop between selector checks, so
dynamically-created elements are found as they appear from JS execution
or network responses.
2026-03-20 21:38:11 -07:00
Adrià Arrufat
598fa254cf mcp: initialize server in mcpThread to avoid V8 isolate crashes
When running mcp server, it initialized lp.mcp.Server in the main thread
which also implicitly created the V8 isolate in the main thread.
When processing requests (like calling the goto tool) inside mcpThread,
V8 would assert that the isolate doesn't match the current thread.

Fixes #1938
2026-03-21 13:33:54 +09:00
Karl Seguin
8526770e9f More aggressive timer cleanup
When a timer is cleared, e.g. clearInterval, we flag the task are deleted and
maintain the entry in window._timers. When run, the task is ignored and deleted
from _timers.

This can result in prematurely rejecting timers due to `TooManyTimeout`. One
pattern I've seen is a RAF associated with an element where the RAF is cleared
(cancelAnimationFrame) if already registered. This can quickly result in
TooManyTimers.

This commit removes the timer from _timers as soon as it's canceled. It doesn't
fully eliminate the chance of TooManyTimeout, but it does reduce it.
2026-03-21 11:38:16 +08:00
Adrià Arrufat
21325ca9be Merge branch 'main' into semantic-versioning 2026-03-21 09:46:05 +09:00
gilangjavier
b5b012bd5d refactor(cdp): always return base64-encoded Network.getResponseBody 2026-03-21 07:06:09 +07:00
Karl Seguin
b4b7a7d58a Merge pull request #1901 from lightpanda-io/goodbye_origin
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove Origins
2026-03-21 07:19:47 +08:00
Karl Seguin
a5378feb1d Merge pull request #1927 from lightpanda-io/feat/fetch-wait-options
Feat/fetch wait options
2026-03-21 07:18:59 +08:00
Adrià Arrufat
b5d3d37f16 Merge pull request #1931 from lightpanda-io/fix/mcp-jsonrpc-response
Fix MCP error responses missing jsonrpc field
2026-03-21 06:23:34 +09:00
Pierre Tachoire
9b02e4963b Merge pull request #1929 from mvanhorn/osc/1819-fix-detach-session-null
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Send Target.detachedFromTarget event on detach
2026-03-20 20:06:19 +01:00
Karl Seguin
2d91acbd14 Merge pull request #1933 from lightpanda-io/css-improvements-perf3
Optimize CSS visibility engine with lazy parsing and cache-friendly evaluation
2026-03-20 17:07:56 +08:00
Karl Seguin
88681b1fdb Fix Context's call_arena
The Context's call_arena should be based on the source, e.g. the IsolateWorld
or the Page, not always the page. There's no rule that says all Contexts have
to be a subset of the Page, and thus some might live longer and by doing so
outlive the page_arena.

Also, on context cleanup, isolate worlds now cleanup their identity.
2026-03-20 16:50:03 +08:00
Adrià Arrufat
1feb121ba7 CSSStyleSheet: use explicit CSSError 2026-03-20 16:50:00 +09:00
Adrià Arrufat
35cdc3c348 StyleManager: simplify rule evaluation by removing SIMD complexity 2026-03-20 12:38:15 +09:00
Adrià Arrufat
1353f76bf1 StyleManager: defer JS CSS rule allocation by lazy parsing 2026-03-20 12:30:07 +09:00
Adrià Arrufat
3e2be5b317 StyleManager: vectorize rule specificity checks with SIMD 2026-03-20 12:13:52 +09:00
Adrià Arrufat
448eca0c32 StyleManager: optimize rule evaluation using SoA and early rejection 2026-03-20 12:02:48 +09:00
Adrià Arrufat
5404ca723c SemanticTree: move NodeData initialization closer to usage 2026-03-20 10:18:16 +09:00
Adrià Arrufat
e56ffe4b60 SemanticTree): use WalkContext for walk function 2026-03-20 10:12:57 +09:00
Adrià Arrufat
02d05ae464 Fix MCP error responses missing jsonrpc field
Closes #1928
2026-03-20 09:55:54 +09:00
Adrià Arrufat
a74e97854d Merge branch 'main' into css-improvements 2026-03-20 09:46:31 +09:00
Matt Van Horn
6925fc3f70 fix(cdp): return real frame ID in STARTUP getFrameTree when page exists
dispatchStartupCommand hard-codes "TID-STARTUP" as the frame ID in
Page.getFrameTree. When a driver connects via connectOverCDP after a
real page already exists, subsequent lifecycle events (frameNavigated)
use the actual page frame ID. The driver's frame tracking was
initialized with "TID-STARTUP", causing a mismatch that hangs
navigation.

Check for an existing browser context with a target_id in
dispatchStartupCommand. If present, return the real frame ID and URL.
Fall back to "TID-STARTUP" only when no page exists yet.

Fixes #1800

This contribution was developed with AI assistance (Claude Code + Codex).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:45:17 -07:00
Matt Van Horn
84557cb4e6 fix(cdp): send Target.detachedFromTarget event on detach
detachFromTarget and setAutoAttach(false) both null bc.session_id
without notifying the client. Per the CDP spec, detaching a session
must fire a Target.detachedFromTarget event so the driver stops
sending messages on the stale session ID.

Capture the session_id before nulling it and fire the event in both
code paths. Add tests covering the event emission and the no-session
edge case.

Fixes #1819

This contribution was developed with AI assistance (Claude Code + Codex).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 16:42:32 -07:00
Adrià Arrufat
f1293b7346 Merge branch 'main' into semantic-versioning 2026-03-20 07:04:05 +09:00
Karl Seguin
a4cb5031d1 Tweak wait_until option
Small tweaks to https://github.com/lightpanda-io/browser/pull/1896

Improve the wait ergonomics with an Option with default parameter. Revert
page pointer logic to original (don't think that change was necessary).
2026-03-19 20:29:20 +08:00
Karl Seguin
f70865e174 Take 2.
History: We started with 1 context and thus only had 1 identity map. Frames
were added, and we tried to stick with 1 identity map per context. That didn't
work - it breaks cross-frame scripting. We introduced "Origin" so that all
frames on the same origin share the same objects. That almost worked, by
the v8::Inspector isn't bound by a Context's SecurityToken. So we tried 1 global
identity map. But that doesn't work. CDP IsolateWorlds do, in fact, need some
isolation. They need new v8::Objects created in their context, even if the
object already exists in the main context.

In the end, you end up with something like this: A page (and all its frames)
needs 1 view of the data. And each IsolateWorld needs it own view. This commit
introduces a js.Identity which is referenced by the context. The Session has a
js.Identity (used by all pages), and each IsolateWorld has its own js.Identity.

As a bonus, the arena pool memory-leak detection has been moved out of the
session and into the ArenaPool. This means _all_ arena pool access is audited
(in debug mode). This seems superfluous, but it's actually necessary since
IsolateWorlds (which now own their own identity) can outlive the Page so there's
no clear place to "check" for leaks - except on ArenaPool deinit.
2026-03-19 18:46:35 +08:00
Karl Seguin
38e9f86088 fix context-leak 2026-03-19 15:42:29 +08:00
Karl Seguin
d9c5f56500 Remove Origins
js.Origin was added to allow frames on the same origin to share our zig<->js
maps / identity. It assumes that scripts on different origins will never be
allowed (by v8) to access the same zig instances.

If two different origins DID access the same zig instance, we'd have a few
different problems. First, while the mapping would exist in Origin1's
identity_map, when the zig instance was returned to a script in Origin2, it
would not be found in Origin2's identity_map, and thus create a new v8::Object.
Thus we'd end up with 2 v8::Objects for the same Zig instance. This is
potentially not the end of the world, but not great either as any zig-native
data _would_ be shared (it's the same instance after all), but js-native data
wouldn't.

The real problem this introduces though is with Finalizers. A weak reference
that falls out of scope in Origin1 will get cleaned up, even though it's still
referenced from Origin2.

Now, under normal circumstances, this isn't an issue; v8 _does_ ensure that
cross-origin access isn't allowed (because we set a SecurityToken on the
v8::Context). But it seems like the v8::Inspector isn't bound by these
restrictions and can happily access and share objects across origin.

The simplest solution I can come up with is to move the mapping from the Origin
to the Session. This does mean that objects might live longer than they have to.
When all references to an origin go out of scope, we can do some cleanup. Not
so when the Session owns this data. But really, how often are iframes on
different origins being created and deleted within the lifetime of a page?

When Origins were first introduces, the Session got burdened with having to
manage multiple lifecycles:
1 - The page-surviving data (e.g. history)
2 - The root page lifecycle (e.g. page_arena, queuedNavigation)
3 - The origin lookup

This commit doesn't change that, but it makes the session responsible for
_a lot_ more of the root page lifecycle (#2 above).

I lied. js.Origin still exists, but it's a shell of its former self. It only
exists to store the SecurityToken name that is re-used for every context with
the same origin.

The v8 namespace leaks into Session.

MutationObserver and IntersectionObserver are now back to using weak/strong refs
which was one of the failing cases before this change.
2026-03-19 14:54:10 +08:00
Karl Seguin
6c5733bba3 Merge pull request #1910 from lightpanda-io/css-improvements-perf2
Bucket stylesheet rules
2026-03-19 14:39:55 +08:00
gilangjavier
b8f1622b52 fix(cdp): base64-encode binary Network.getResponseBody payloads 2026-03-19 13:34:44 +07:00
Adrià Arrufat
2dbd32d120 build: automate version resolution in build.zig
Removes manual git flags from CI and build scripts.
Versioning is now automatically derived from git and build.zig.zon.

With this PR, we follow https://semver.org/
Logic:

1. Read the version from build.zig.zon
2. If it doesn't have a `.pre` field (i.e. dev/alpha/beta) it will use that
3. Otherwise it will get the info from git: hash and number of commits since last `.0` version
4. Then build the version: `0.3.0-dev.1493+0896edc3`

Note that, since the latest stable version is `0.2.6`.
The convention is to use `0.3.0-dev`, as:
- `0.2.6` < `0.3.0.dev` < `0.3.0`
2026-03-19 13:03:29 +09:00
Karl Seguin
1695ea81d2 on rebuild, pre-size lookups based on previous sizes 2026-03-19 11:46:58 +08:00
Karl Seguin
b7bf86fd85 update comments to reflect preference-based bucketing 2026-03-19 11:43:31 +08:00
Karl Seguin
94d8f90a96 Bucket stylesheet rules
In the first iteration of this, we kept an ArrayList of all rules with
visibility properties. Why bother evaluating if a rule's selector matches an
element if that rule doesn't have any meanignful (i.e. visibility) properties?

This commit enhances that approach by bucketing the rules. Given the following
selectors:

.hidden {....}
.footer > .small {...}

We can store the rules based on their right-most selector. So, given an element
we can do:

if (getId(el)) |id| {
   const rules = id_lookup.get(id) orelse continue;
   // check rules
}

if (getClasses(el)) |classes| {
   for (classes) |c| {
     const rules = class_lookup(c) orelse continue;
     // chck rules
   }
}
...

On an amazon product page, the total list of visibility-related rules was ~230.
Now, scanning 230 rules for a match isn't _aweful_, but remember that this has
to be done up the ancestor tree AND, for Amazon, this is called over 20K times.

This change requires that the StyleManager becomes more matching/parsing-aware
but a typical visibility check on that same Amazon product page only has to
check 2 rules (down from 230) and often has to check 0 rules.

Also, we now filter out a few more interactive-related pseudo-elements, e.g.
:hover. These aren't supported by the browser as a whole (i.e. they can _never_
match), so they can be filtered out early, when building the rules lookup.
2026-03-19 11:43:30 +08:00
Karl Seguin
b9bef22bbf Merge pull request #1912 from lightpanda-io/css-improvements-fix
StyleManager: restore dirty state on rebuild allocation failure
2026-03-19 10:25:09 +08:00
Adrià Arrufat
b2a996e5c7 StyleManager: restore dirty state on rebuild allocation failure 2026-03-19 11:13:04 +09:00
shaewe180
e2be8525c4 Config: remove js_enum_from_string constant 2026-03-19 09:40:40 +08:00
shaewe180
c15afa23ca Session: fix page pointer handling in wait loop
- Refactor `wait` and `_wait` to handle `page` as `*Page` instead of `**Page`, preventing stale references during navigations.
- Update `networkidle` wait condition to use `_notified_network_idle == .done`.
- Document `--wait_ms` and `--wait_until` options in `Config.zig` help text.
2026-03-19 09:36:42 +08:00
Karl Seguin
f594b033bf Merge pull request #1897 from lightpanda-io/css-improvements-perf
Introduce StyleManager
2026-03-19 07:10:35 +08:00
Karl Seguin
10e379e4fb fix clamping 2026-03-19 07:00:26 +08:00
Karl Seguin
c1bb27c450 better encapsulate arena reset 2026-03-19 06:53:08 +08:00
Karl Seguin
dda5e2c542 Apply suggestions from code review
Co-authored-by: Adrià Arrufat <1671644+arrufat@users.noreply.github.com>
2026-03-19 06:47:40 +08:00
Karl Seguin
e29778d72b Introduce StyleManager
A Page now has a StyleManager. The StyleManager currently answers two questions:
1 - Is an element hidden
2 - Does an element have pointer-events == none

This is used in calls such as element.checkVisibility which, on some pages, can
be called tens of thousands of times (often through other methods, like
element.getBoundingClientRect). This _can_ be a bottleneck.

The StyleManager keeps a list of rules. The rules include the selector,
specificity, and properties that we care about. Rules in a stylesheet that
contain no properties of interest are ignored. This is the first and likely
most significant optimization. Presumably, most CSS rules don't have a
display/visibility/opacity or pointer-events property.

The list is rules is cached until stylesheets are modified or delete. When this
happens, the StyleManager is flagged as "dirty" and rebuilt on-demand in the
next query.  This is our second major optimization.

For now, to check if an element is visible, we still need to scan all rules.
But having a pre-build subset of all the rules is a first step.

The next step might be to optimize the matching, or possibly optimizing common
cases (e.g. id and/or simple class selector)
2026-03-18 17:52:57 +08:00
shaewe180
09327c3897 feat: fetch add wait_until parameter for page loads options
Add `--wait_until` and `--wait_ms` CLI arguments to configure session wait behavior. Updates `Session.wait` to evaluate specific page load states (`load`, `domcontentloaded`, `networkidle`, `fixed`) before completing the wait loop.
2026-03-18 15:08:51 +08:00
Adrià Arrufat
43a70272c5 Merge branch 'main' into css-improvements 2026-03-16 10:25:35 +09:00
Adrià Arrufat
f0c9c262ca Merge branch 'main' into css-improvements 2026-03-14 20:36:50 +09:00
Adrià Arrufat
3fde349b9f webapi): reorder css function params and merge pointer events 2026-03-14 20:31:00 +09:00
Adrià Arrufat
55a9976d46 css: CSSStyleSheet.replace() should resolve to the stylesheet 2026-03-14 20:30:00 +09:00
Adrià Arrufat
66a86541d1 css: handle top-level semicolons in parser 2026-03-14 20:30:00 +09:00
Adrià Arrufat
bc19079dad css: add unit tests for RulesIterator 2026-03-14 20:30:00 +09:00
Adrià Arrufat
351e44343d css: make CSSStyleSheet.insertRule index optional 2026-03-14 20:30:00 +09:00
Adrià Arrufat
e362a9cbc3 webapi.Element: use dot notation
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-14 20:29:54 +09:00
Adrià Arrufat
e2563e57f2 webapi.Element: make getCssProperties private
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-14 20:18:51 +09:00
Adrià Arrufat
df5e978247 tests: remove warning
Co-authored-by: Karl Seguin <karlseguin@users.noreply.github.com>
2026-03-14 19:54:43 +09:00
Adrià Arrufat
f37862a25d perf: cache css properties for visibility and interactivity
Introduces `CssCache` to store computed CSS properties, avoiding
redundant stylesheet lookups during DOM traversals.
2026-03-13 14:00:07 +09:00
Adrià Arrufat
84d76cf90d browser: improve visibility and interactivity CSS checks
Adds support for `pointer-events: none` in interactivity classification
and expands `checkVisibility` to include `visibility` and `opacity`.
Refactors CSS property lookup into a shared helper.
2026-03-13 13:33:33 +09:00
Adrià Arrufat
e12f28fb70 Merge branch 'main' into css-improvements 2026-03-13 10:07:06 +09:00
Adrià Arrufat
dfe04960c0 css: remove cssText setter from CSSRule and CSSStyleRule 2026-03-12 22:47:41 +09:00
Adrià Arrufat
de2b1cc6fe css: throw IndexSizeError in deleteRule and insertRule 2026-03-12 22:40:01 +09:00
Adrià Arrufat
2aef4ab677 webapi.Element: optimize checkVisibility and refactor loops 2026-03-12 22:32:06 +09:00
Adrià Arrufat
798f68d0ce css: remove curly block helper functions 2026-03-12 22:29:51 +09:00
Adrià Arrufat
e0343a3f6d Replace ArrayListUnmanaged with ArrayList 2026-03-12 22:23:59 +09:00
Adrià Arrufat
d918ec694b css: add log filter to CSSStyleSheet test 2026-03-12 22:21:01 +09:00
Adrià Arrufat
b2b609a309 dom: remove verbose logging and simplify css logic 2026-03-12 22:07:58 +09:00
Adrià Arrufat
48dd80867b dom: support css display: none in checkVisibility
Updates `Element.checkVisibility` to iterate through document
stylesheets and check for matching rules with `display: none`.
Also ensures `<style>` elements register their sheets and
initializes them immediately upon addition to the DOM.
2026-03-12 20:55:44 +09:00
Adrià Arrufat
f58f6e8d65 css: improve CSSOM rule handling and serialization
Refactors `CSSRule` to a union type for better type safety and updates
`CSSStyleRule` to use `CSSStyleProperties`. Adds comprehensive tests for
`insertRule`, `deleteRule`, and `replaceSync`.
2026-03-12 20:23:59 +09:00
Adrià Arrufat
ee034943b6 feat(css): implement stylesheet rule management
Adds a CSS rule parser and implements `insertRule`, `deleteRule`, and
`replaceSync` in `CSSStyleSheet`. Also updates `CSSRuleList` to use
dynamic storage and populates sheets from `<style>` elements.
2026-03-12 16:27:25 +09:00
183 changed files with 7215 additions and 3227 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.3.4' default: 'v0.3.7'
v8: v8:
description: 'v8 version to install' description: 'v8 version to install'
required: false required: false

View File

@@ -27,7 +27,7 @@ jobs:
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: zig build release - name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -61,6 +61,6 @@ jobs:
- name: run end to end integration tests - name: run end to end integration tests
run: | run: |
./lightpanda serve --log_level error & 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

@@ -52,7 +52,7 @@ jobs:
- uses: ./.github/actions/install - uses: ./.github/actions/install
- name: zig build release - name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -98,7 +98,7 @@ jobs:
- name: run end to end tests through proxy - name: run end to end tests through proxy
run: | run: |
./proxy/proxy & echo $! > PROXY.id ./proxy/proxy & echo $! > PROXY.id
./lightpanda serve --http_proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid ./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
go run runner/main.go go run runner/main.go
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`
@@ -139,9 +139,9 @@ jobs:
- name: run end to end tests - name: run end to end tests
run: | run: |
./lightpanda serve \ ./lightpanda serve \
--web_bot_auth_key_file private_key.pem \ --web-bot-auth-key-file private_key.pem \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
& echo $! > LPD.pid & echo $! > LPD.pid
go run runner/main.go go run runner/main.go
kill `cat LPD.pid` kill `cat LPD.pid`
@@ -155,10 +155,10 @@ jobs:
run: | run: |
./proxy/proxy & echo $! > PROXY.id ./proxy/proxy & echo $! > PROXY.id
./lightpanda serve \ ./lightpanda serve \
--web_bot_auth_key_file private_key.pem \ --web-bot-auth-key-file private_key.pem \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \ --web-bot-auth-domain ${{ vars.WBA_DOMAIN }} \
--http_proxy 'http://127.0.0.1:3000' \ --http-proxy 'http://127.0.0.1:3000' \
& echo $! > LPD.pid & echo $! > LPD.pid
go run runner/main.go go run runner/main.go
kill `cat LPD.pid` `cat PROXY.id` kill `cat LPD.pid` `cat PROXY.id`
@@ -179,6 +179,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
# Don't execute on PR
if: github.event_name != 'pull_request'
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -205,9 +208,9 @@ jobs:
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}" exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
./lightpanda fetch --dump http://127.0.0.1:8989/ \ ./lightpanda fetch --dump http://127.0.0.1:8989/ \
--web_bot_auth_key_file /proc/self/fd/3 \ --web-bot-auth-key-file /proc/self/fd/3 \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \ --web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} --web-bot-auth-domain ${{ vars.WBA_DOMAIN }}
wait $VALIDATOR_PID wait $VALIDATOR_PID
exec 3>&- exec 3>&-

View File

@@ -7,7 +7,7 @@ env:
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }} VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion={0}', github.ref_name) || '-Dversion=nightly' }}
on: on:
push: push:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -85,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -127,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -167,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

@@ -40,7 +40,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build release - name: zig build release
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7

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.3.4 ARG ZIG_V8=v0.3.7
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update -yq && \ RUN apt-get update -yq && \
@@ -53,8 +53,7 @@ RUN zig build -Doptimize=ReleaseFast \
# build release # build release
RUN zig build -Doptimize=ReleaseFast \ RUN zig build -Doptimize=ReleaseFast \
-Dsnapshot_path=../../snapshot.bin \ -Dsnapshot_path=../../snapshot.bin \
-Dprebuilt_v8_path=v8/libc_v8.a \ -Dprebuilt_v8_path=v8/libc_v8.a
-Dgit_commit=$(git rev-parse --short HEAD)
FROM debian:stable-slim FROM debian:stable-slim
@@ -75,4 +74,4 @@ EXPOSE 9222/tcp
# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang. # Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang.
# (See https://github.com/krallin/tini#why-tini). # (See https://github.com/krallin/tini#why-tini).
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log_level", "info"] CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log-level", "info"]

View File

@@ -4,11 +4,3 @@ License names used in this document are as per [SPDX License
List](https://spdx.org/licenses/). List](https://spdx.org/licenses/).
The default license for this project is [AGPL-3.0-only](LICENSE). The default license for this project is [AGPL-3.0-only](LICENSE).
The following directories and their subdirectories are licensed under their
original upstream licenses:
```
vendor/
tests/wpt/
```

View File

@@ -58,13 +58,13 @@ 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 fast)...\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 || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n" @printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode ## Build in debug mode
build-dev: build-dev:
@printf "\033[36mBuilding (debug)...\033[0m\n" @printf "\033[36mBuilding (debug)...\033[0m\n"
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;) @$(ZIG) build || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n" @printf "\033[33mBuild OK\033[0m\n"
## Run the server in release mode ## Run the server in release mode

View File

@@ -18,15 +18,15 @@ Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
</div> </div>
<div align="center"> <div align="center">
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg"> [<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time-v2.svg">
](https://github.com/lightpanda-io/demo) ](https://github.com/lightpanda-io/demo)
&emsp; &emsp;
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg"> [<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame-v2.svg">
](https://github.com/lightpanda-io/demo) ](https://github.com/lightpanda-io/demo)
</div> </div>
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance. _chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
See [benchmark details](https://github.com/lightpanda-io/demo)._ See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._
Lightpanda is the open-source browser made for headless usage: Lightpanda is the open-source browser made for headless usage:
@@ -82,7 +82,7 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
### Dump a URL ### Dump a URL
```console ```console
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/ ./lightpanda fetch --obey-robots --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/
``` ```
```console ```console
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms] INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
@@ -117,7 +117,7 @@ INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
### Start a CDP server ### Start a CDP server
```console ```console
./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222 ./lightpanda serve --obey-robots --log-format pretty --log-level info --host 127.0.0.1 --port 9222
``` ```
```console ```console
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms] INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
@@ -170,6 +170,7 @@ You may still encounter errors or crashes. Please open an issue with specifics i
Here are the key features we have implemented: Here are the key features we have implemented:
- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015)
- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) - [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
- [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) - [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
- [x] DOM tree - [x] DOM tree
@@ -186,7 +187,7 @@ Here are the key features we have implemented:
- [x] Custom HTTP headers - [x] Custom HTTP headers
- [x] Proxy support - [x] Proxy support
- [x] Network interception - [x] Network interception
- [x] Respect `robots.txt` with option `--obey_robots` - [x] Respect `robots.txt` with option `--obey-robots`
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time. NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
@@ -317,7 +318,7 @@ First start the WPT's HTTP server from your `wpt/` clone dir.
Run a Lightpanda browser Run a Lightpanda browser
``` ```
zig build run -- --insecure_disable_tls_host_verification zig build run -- --insecure-disable-tls-host-verification
``` ```
Then you can start the wptrunner from the Demo's clone dir: Then you can start the wptrunner from the Demo's clone dir:

105
build.zig
View File

@@ -17,24 +17,37 @@
// 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 builtin = @import("builtin");
const Build = std.Build; const lightpanda_version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch unreachable;
const min_zig_version = std.SemanticVersion.parse(@import("build.zig.zon").minimum_zig_version) catch unreachable;
const Build = blk: {
if (builtin.zig_version.order(min_zig_version) == .lt) {
const message = std.fmt.comptimePrint(
\\Zig version is too old:
\\ current Zig version: {f}
\\ minimum Zig version: {f}
, .{ builtin.zig_version, min_zig_version });
@compileError(message);
} else {
break :blk std.Build;
}
};
pub fn build(b: *Build) !void { pub fn build(b: *Build) !void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
const manifest = Manifest.init(b);
const git_commit = b.option([]const u8, "git_commit", "Current git commit");
const git_version = b.option([]const u8, "git_version", "Current git version (from tag)");
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a"); const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot"); const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
const version = resolveVersion(b);
var stdout = std.fs.File.stdout().writer(&.{});
try stdout.interface.print("Lightpanda {f}\n", .{version});
var opts = b.addOptions(); var opts = b.addOptions();
opts.addOption([]const u8, "version", manifest.version); opts.addOption([]const u8, "version", b.fmt("{f}", .{version}));
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
opts.addOption(?[]const u8, "git_version", git_version orelse null);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path); opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
@@ -96,6 +109,11 @@ pub fn build(b: *Build) !void {
} }
const run_step = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step); run_step.dependOn(&run_cmd.step);
const version_info_step = b.step("version", "Print the resolved version information");
const version_info_run = b.addRunArtifact(exe);
version_info_run.addArg("version");
version_info_step.dependOn(&version_info_run.step);
} }
{ {
@@ -701,27 +719,56 @@ fn buildCurl(
return lib; return lib;
} }
const Manifest = struct { /// Resolves the semantic version of the build.
version: []const u8, ///
minimum_zig_version: []const u8, /// The base version is read from `build.zig.zon`. This can be overridden
/// using the `-Dversion` command-line flag:
/// - If the flag contains a full semantic version (e.g., `1.2.3`), it replaces
/// the base version entirely.
/// - If the flag contains a simple string (e.g., `nightly`), it replaces only
/// the pre-release tag of the base version (e.g., `1.0.0-dev` -> `1.0.0-nightly`).
///
/// For versions that have a pre-release tag and no explicit build metadata,
/// this function automatically enriches the version with the git commit count
/// and short hash (e.g., `1.0.0-dev.5243+dbe45229`).
fn resolveVersion(b: *std.Build) std.SemanticVersion {
const opt_version = b.option([]const u8, "version", "Override the version of this build");
fn init(b: *std.Build) Manifest { const version = if (opt_version) |v|
const input = @embedFile("build.zig.zon"); std.SemanticVersion.parse(v) catch blk: {
var fallback = lightpanda_version;
fallback.pre = v;
break :blk fallback;
}
else
lightpanda_version;
var diagnostics: std.zon.parse.Diagnostics = .{}; // Only enrich versions that have a pre-release field and no explicit build metadata.
defer diagnostics.deinit(b.allocator); if (version.pre == null or version.build != null) return version;
return std.zon.parse.fromSlice(Manifest, b.allocator, input, &diagnostics, .{ // For dev/nightly versions, calculate the commit count and hash
.free_on_error = true, const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return version;
.ignore_unknown_fields = true, const commit_hash = std.mem.trim(u8, git_hash_raw, " \n\r");
}) catch |err| {
switch (err) { const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return version;
error.OutOfMemory => @panic("OOM"), const commit_count = std.mem.trim(u8, git_count_raw, " \n\r");
error.ParseZon => {
std.debug.print("Parse diagnostics:\n{f}\n", .{diagnostics}); return .{
std.process.exit(1); .major = version.major,
}, .minor = version.minor,
} .patch = version.patch,
}; .pre = b.fmt("{s}.{s}", .{ version.pre.?, commit_count }),
} .build = commit_hash,
}; };
}
/// Helper function to run git commands and return stdout
fn runGit(b: *std.Build, args: []const []const u8) ![]const u8 {
var code: u8 = undefined;
const dir = b.pathFromRoot(".");
var command: std.ArrayList([]const u8) = .empty;
defer command.deinit(b.allocator);
try command.appendSlice(b.allocator, &.{ "git", "-C", dir });
try command.appendSlice(b.allocator, args);
return b.runAllowFail(command.items, &code, .Ignore);
}

View File

@@ -1,12 +1,12 @@
.{ .{
.name = .browser, .name = .browser,
.version = "0.0.0", .version = "1.0.0-dev",
.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.3.4.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup", .hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -17,12 +17,16 @@
// 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 builtin = @import("builtin");
const log = @import("log.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator; const ArenaAllocator = std.heap.ArenaAllocator;
const ArenaPool = @This(); const ArenaPool = @This();
const IS_DEBUG = builtin.mode == .Debug;
allocator: Allocator, allocator: Allocator,
retain_bytes: usize, retain_bytes: usize,
free_list_len: u16 = 0, free_list_len: u16 = 0,
@@ -30,10 +34,17 @@ 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 = .{}, mutex: std.Thread.Mutex = .{},
// Debug mode: track acquire/release counts per debug name to detect leaks and double-frees
_leak_track: if (IS_DEBUG) std.StringHashMapUnmanaged(isize) else void = if (IS_DEBUG) .empty else {},
const Entry = struct { const Entry = struct {
next: ?*Entry, next: ?*Entry,
arena: ArenaAllocator, arena: ArenaAllocator,
debug: if (IS_DEBUG) []const u8 else void = if (IS_DEBUG) "" else {},
};
pub const DebugInfo = struct {
debug: []const u8 = "",
}; };
pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool { pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool {
@@ -42,10 +53,26 @@ pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) Arena
.free_list_max = free_list_max, .free_list_max = free_list_max,
.retain_bytes = retain_bytes, .retain_bytes = retain_bytes,
.entry_pool = .init(allocator), .entry_pool = .init(allocator),
._leak_track = if (IS_DEBUG) .empty else {},
}; };
} }
pub fn deinit(self: *ArenaPool) void { pub fn deinit(self: *ArenaPool) void {
if (IS_DEBUG) {
var has_leaks = false;
var it = self._leak_track.iterator();
while (it.next()) |kv| {
if (kv.value_ptr.* != 0) {
log.err(.bug, "ArenaPool leak", .{ .name = kv.key_ptr.*, .count = kv.value_ptr.* });
has_leaks = true;
}
}
if (has_leaks) {
@panic("ArenaPool: leaked arenas detected");
}
self._leak_track.deinit(self.allocator);
}
var entry = self.free_list; var entry = self.free_list;
while (entry) |e| { while (entry) |e| {
entry = e.next; entry = e.next;
@@ -54,13 +81,21 @@ pub fn deinit(self: *ArenaPool) void {
self.entry_pool.deinit(); self.entry_pool.deinit();
} }
pub fn acquire(self: *ArenaPool) !Allocator { pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator {
self.mutex.lock(); self.mutex.lock();
defer self.mutex.unlock(); 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;
if (IS_DEBUG) {
entry.debug = dbg.debug;
const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
}
return entry.arena.allocator(); return entry.arena.allocator();
} }
@@ -68,8 +103,16 @@ pub fn acquire(self: *ArenaPool) !Allocator {
entry.* = .{ entry.* = .{
.next = null, .next = null,
.arena = ArenaAllocator.init(self.allocator), .arena = ArenaAllocator.init(self.allocator),
.debug = if (IS_DEBUG) dbg.debug else {},
}; };
if (IS_DEBUG) {
const gop = try self._leak_track.getOrPut(self.allocator, dbg.debug);
if (!gop.found_existing) {
gop.value_ptr.* = 0;
}
gop.value_ptr.* += 1;
}
return entry.arena.allocator(); return entry.arena.allocator();
} }
@@ -83,6 +126,19 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
self.mutex.lock(); self.mutex.lock();
defer self.mutex.unlock(); defer self.mutex.unlock();
if (IS_DEBUG) {
if (self._leak_track.getPtr(entry.debug)) |count| {
count.* -= 1;
if (count.* < 0) {
log.err(.bug, "ArenaPool double-free", .{ .name = entry.debug });
@panic("ArenaPool: double-free detected");
}
} else {
log.err(.bug, "ArenaPool release unknown", .{ .name = entry.debug });
@panic("ArenaPool: release of untracked arena");
}
}
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();
@@ -100,13 +156,18 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
_ = arena.reset(.{ .retain_with_limit = retain }); _ = arena.reset(.{ .retain_with_limit = retain });
} }
pub fn resetRetain(_: *const ArenaPool, allocator: Allocator) void {
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
_ = arena.reset(.retain_capacity);
}
const testing = std.testing; const testing = std.testing;
test "arena pool - basic acquire and use" { test "arena pool - basic acquire and use" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit(); defer pool.deinit();
const alloc = try pool.acquire(); const alloc = try pool.acquire(.{ .debug = "test" });
const buf = try alloc.alloc(u8, 64); const buf = try alloc.alloc(u8, 64);
@memset(buf, 0xAB); @memset(buf, 0xAB);
try testing.expectEqual(@as(u8, 0xAB), buf[0]); try testing.expectEqual(@as(u8, 0xAB), buf[0]);
@@ -118,14 +179,14 @@ test "arena pool - reuse entry after release" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit(); defer pool.deinit();
const alloc1 = try pool.acquire(); const alloc1 = try pool.acquire(.{ .debug = "test" });
try testing.expectEqual(@as(u16, 0), pool.free_list_len); try testing.expectEqual(@as(u16, 0), pool.free_list_len);
pool.release(alloc1); pool.release(alloc1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len); try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The same entry should be returned from the free list. // The same entry should be returned from the free list.
const alloc2 = try pool.acquire(); const alloc2 = try pool.acquire(.{ .debug = "test" });
try testing.expectEqual(@as(u16, 0), pool.free_list_len); try testing.expectEqual(@as(u16, 0), pool.free_list_len);
try testing.expectEqual(alloc1.ptr, alloc2.ptr); try testing.expectEqual(alloc1.ptr, alloc2.ptr);
@@ -136,9 +197,9 @@ test "arena pool - multiple concurrent arenas" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit(); defer pool.deinit();
const a1 = try pool.acquire(); const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(); const a2 = try pool.acquire(.{ .debug = "test2" });
const a3 = try pool.acquire(); const a3 = try pool.acquire(.{ .debug = "test3" });
// All three must be distinct arenas. // All three must be distinct arenas.
try testing.expect(a1.ptr != a2.ptr); try testing.expect(a1.ptr != a2.ptr);
@@ -161,8 +222,8 @@ test "arena pool - free list respects max limit" {
var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16);
defer pool.deinit(); defer pool.deinit();
const a1 = try pool.acquire(); const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(); const a2 = try pool.acquire(.{ .debug = "test2" });
pool.release(a1); pool.release(a1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len); try testing.expectEqual(@as(u16, 1), pool.free_list_len);
@@ -176,7 +237,7 @@ test "arena pool - reset clears memory without releasing" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit(); defer pool.deinit();
const alloc = try pool.acquire(); const alloc = try pool.acquire(.{ .debug = "test" });
const buf = try alloc.alloc(u8, 128); const buf = try alloc.alloc(u8, 128);
@memset(buf, 0xFF); @memset(buf, 0xFF);
@@ -200,8 +261,8 @@ test "arena pool - deinit with entries in free list" {
// detected by the test allocator). // detected by the test allocator).
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16); var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
const a1 = try pool.acquire(); const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(); const a2 = try pool.acquire(.{ .debug = "test2" });
_ = try a1.alloc(u8, 256); _ = try a1.alloc(u8, 256);
_ = try a2.alloc(u8, 512); _ = try a2.alloc(u8, 512);
pool.release(a1); pool.release(a1);

View File

@@ -163,6 +163,20 @@ pub fn cdpTimeout(self: *const Config) usize {
}; };
} }
pub fn port(self: *const Config) u16 {
return switch (self.mode) {
.serve => |opts| opts.port,
else => unreachable,
};
}
pub fn advertiseHost(self: *const Config) []const u8 {
return switch (self.mode) {
.serve => |opts| opts.advertise_host orelse opts.host,
else => unreachable,
};
}
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) { return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{ inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
@@ -199,6 +213,7 @@ pub const Mode = union(RunMode) {
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,
advertise_host: ?[]const u8 = null,
timeout: u31 = 10, timeout: u31 = 10,
cdp_max_connections: u16 = 16, cdp_max_connections: u16 = 16,
cdp_max_pending_connections: u16 = 128, cdp_max_pending_connections: u16 = 128,
@@ -217,6 +232,13 @@ pub const DumpFormat = enum {
semantic_tree_text, semantic_tree_text,
}; };
pub const WaitUntil = enum {
load,
domcontentloaded,
networkidle,
done,
};
pub const Fetch = struct { pub const Fetch = struct {
url: [:0]const u8, url: [:0]const u8,
dump_mode: ?DumpFormat = null, dump_mode: ?DumpFormat = null,
@@ -224,6 +246,8 @@ pub const Fetch = struct {
with_base: bool = false, with_base: bool = false,
with_frames: bool = false, with_frames: bool = false,
strip: dump.Opts.Strip = .{}, strip: dump.Opts.Strip = .{},
wait_ms: u32 = 5000,
wait_until: WaitUntil = .done,
}; };
pub const Common = struct { pub const Common = struct {
@@ -293,71 +317,71 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
// MAX_HELP_LEN| // MAX_HELP_LEN|
const common_options = const common_options =
\\ \\
\\--insecure_disable_tls_host_verification \\--insecure-disable-tls-host-verification
\\ Disables host verification on all HTTP requests. This is an \\ Disables host verification on all HTTP requests. This is an
\\ advanced option which should only be set if you understand \\ advanced option which should only be set if you understand
\\ and accept the risk of disabling host verification. \\ and accept the risk of disabling host verification.
\\ \\
\\--obey_robots \\--obey-robots
\\ Fetches and obeys the robots.txt (if available) of the web pages \\ Fetches and obeys the robots.txt (if available) of the web pages
\\ we make requests towards. \\ we make requests towards.
\\ Defaults to false. \\ Defaults to false.
\\ \\
\\--http_proxy The HTTP proxy to use for all HTTP requests. \\--http-proxy The HTTP proxy to use for all HTTP requests.
\\ A username:password can be included for basic authentication. \\ A username:password can be included for basic authentication.
\\ Defaults to none. \\ Defaults to none.
\\ \\
\\--proxy_bearer_token \\--proxy-bearer-token
\\ The <token> to send for bearer authentication with the proxy \\ The <token> to send for bearer authentication with the proxy
\\ Proxy-Authorization: Bearer <token> \\ Proxy-Authorization: Bearer <token>
\\ \\
\\--http_max_concurrent \\--http-max-concurrent
\\ The maximum number of concurrent HTTP requests. \\ The maximum number of concurrent HTTP requests.
\\ Defaults to 10. \\ Defaults to 10.
\\ \\
\\--http_max_host_open \\--http-max-host-open
\\ The maximum number of open connection to a given host:port. \\ The maximum number of open connection to a given host:port.
\\ Defaults to 4. \\ Defaults to 4.
\\ \\
\\--http_connect_timeout \\--http-connect-timeout
\\ The time, in milliseconds, for establishing an HTTP connection \\ The time, in milliseconds, for establishing an HTTP connection
\\ before timing out. 0 means it never times out. \\ before timing out. 0 means it never times out.
\\ Defaults to 0. \\ Defaults to 0.
\\ \\
\\--http_timeout \\--http-timeout
\\ The maximum time, in milliseconds, the transfer is allowed \\ The maximum time, in milliseconds, the transfer is allowed
\\ to complete. 0 means it never times out. \\ to complete. 0 means it never times out.
\\ Defaults to 10000. \\ Defaults to 10000.
\\ \\
\\--http_max_response_size \\--http-max-response-size
\\ Limits the acceptable response size for any request \\ Limits the acceptable response size for any request
\\ (e.g. XHR, fetch, script loading, ...). \\ (e.g. XHR, fetch, script loading, ...).
\\ Defaults to no limit. \\ Defaults to no limit.
\\ \\
\\--log_level The log level: debug, info, warn, error or fatal. \\--log-level The log level: debug, info, warn, error or fatal.
\\ Defaults to \\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++ ++ (if (builtin.mode == .Debug) " info." else "warn.") ++
\\ \\
\\ \\
\\--log_format The log format: pretty or logfmt. \\--log-format The log format: pretty or logfmt.
\\ Defaults to \\ Defaults to
++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++ ++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++
\\ \\
\\ \\
\\--log_filter_scopes \\--log-filter-scopes
\\ Filter out too verbose logs per scope: \\ Filter out too verbose logs per scope:
\\ http, unknown_prop, event, ... \\ http, unknown_prop, event, ...
\\ \\
\\--user_agent_suffix \\--user-agent-suffix
\\ Suffix to append to the Lightpanda/X.Y User-Agent \\ Suffix to append to the Lightpanda/X.Y User-Agent
\\ \\
\\--web_bot_auth_key_file \\--web-bot-auth-key-file
\\ Path to the Ed25519 private key PEM file. \\ Path to the Ed25519 private key PEM file.
\\ \\
\\--web_bot_auth_keyid \\--web-bot-auth-keyid
\\ The JWK thumbprint of your public key. \\ The JWK thumbprint of your public key.
\\ \\
\\--web_bot_auth_domain \\--web-bot-auth-domain
\\ Your domain e.g. yourdomain.com \\ Your domain e.g. yourdomain.com
; ;
@@ -376,16 +400,23 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'. \\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'.
\\ Defaults to no dump. \\ 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
\\ - "js" script and link[as=script, rel=preload] \\ - "js" script and link[as=script, rel=preload]
\\ - "ui" includes img, picture, video, css and svg \\ - "ui" includes img, picture, video, css and svg
\\ - "css" includes style and link[rel=stylesheet] \\ - "css" includes style and link[rel=stylesheet]
\\ - "full" includes js, ui and css \\ - "full" includes js, ui and css
\\ \\
\\--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. \\--with-frames Includes the contents of iframes. Defaults to false.
\\
\\--wait-ms Wait time in milliseconds.
\\ Defaults to 5000.
\\
\\--wait-until Wait until the specified event.
\\ Supported events: load, domcontentloaded, networkidle, done.
\\ Defaults to 'done'.
\\ \\
++ common_options ++ ++ common_options ++
\\ \\
@@ -400,14 +431,19 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\--port Port of the CDP server \\--port Port of the CDP server
\\ Defaults to 9222 \\ Defaults to 9222
\\ \\
\\--advertise-host
\\ The host to advertise, e.g. in the /json/version response.
\\ Useful, for example, when --host is 0.0.0.0.
\\ Defaults to --host value
\\
\\--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).
\\ \\
\\--cdp_max_connections \\--cdp-max-connections
\\ Maximum number of simultaneous CDP connections. \\ Maximum number of simultaneous CDP connections.
\\ Defaults to 16. \\ Defaults to 16.
\\ \\
\\--cdp_max_pending_connections \\--cdp-max-pending-connections
\\ Maximum pending connections in the accept queue. \\ Maximum pending connections in the accept queue.
\\ Defaults to 128. \\ Defaults to 128.
\\ \\
@@ -485,15 +521,15 @@ fn inferMode(opt: []const u8) ?RunMode {
return .fetch; return .fetch;
} }
if (std.mem.eql(u8, opt, "--strip_mode")) { if (std.mem.eql(u8, opt, "--strip-mode") or std.mem.eql(u8, opt, "--strip_mode")) {
return .fetch; return .fetch;
} }
if (std.mem.eql(u8, opt, "--with_base")) { if (std.mem.eql(u8, opt, "--with-base") or std.mem.eql(u8, opt, "--with_base")) {
return .fetch; return .fetch;
} }
if (std.mem.eql(u8, opt, "--with_frames")) { if (std.mem.eql(u8, opt, "--with-frames") or std.mem.eql(u8, opt, "--with_frames")) {
return .fetch; return .fetch;
} }
@@ -541,6 +577,15 @@ fn parseServeArgs(
continue; continue;
} }
if (std.mem.eql(u8, "--advertise-host", opt) or std.mem.eql(u8, "--advertise_host", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
serve.advertise_host = try allocator.dupe(u8, str);
continue;
}
if (std.mem.eql(u8, "--timeout", opt)) { if (std.mem.eql(u8, "--timeout", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" }); log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
@@ -554,27 +599,27 @@ fn parseServeArgs(
continue; continue;
} }
if (std.mem.eql(u8, "--cdp_max_connections", opt)) { if (std.mem.eql(u8, "--cdp-max-connections", opt) or 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 = "--cdp_max_connections" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
serve.cdp_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 = "--cdp_max_connections", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
continue; continue;
} }
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) { if (std.mem.eql(u8, "--cdp-max-pending-connections", opt) or 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 = "--cdp_max_pending_connections" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
serve.cdp_max_pending_connections = 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 = "--cdp_max_pending_connections", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
continue; continue;
@@ -619,8 +664,34 @@ fn parseFetchArgs(
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 = .{};
var wait_ms: u32 = 5000;
var wait_until: WaitUntil = .done;
while (args.next()) |opt| { while (args.next()) |opt| {
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
wait_ms = std.fmt.parseInt(u32, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--wait-until", opt) or std.mem.eql(u8, "--wait_until", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
wait_until = std.meta.stringToEnum(WaitUntil, str) orelse {
log.fatal(.app, "invalid argument value", .{ .arg = opt, .val = str });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--dump", opt)) { if (std.mem.eql(u8, "--dump", opt)) {
var peek_args = args.*; var peek_args = args.*;
if (peek_args.next()) |next_arg| { if (peek_args.next()) |next_arg| {
@@ -639,25 +710,25 @@ fn parseFetchArgs(
if (std.mem.eql(u8, "--noscript", opt)) { if (std.mem.eql(u8, "--noscript", opt)) {
log.warn(.app, "deprecation warning", .{ log.warn(.app, "deprecation warning", .{
.feature = "--noscript argument", .feature = "--noscript argument",
.hint = "use '--strip_mode js' instead", .hint = "use '--strip-mode js' instead",
}); });
strip.js = true; strip.js = true;
continue; continue;
} }
if (std.mem.eql(u8, "--with_base", opt)) { if (std.mem.eql(u8, "--with-base", opt) or std.mem.eql(u8, "--with_base", opt)) {
with_base = true; with_base = true;
continue; continue;
} }
if (std.mem.eql(u8, "--with_frames", opt)) { if (std.mem.eql(u8, "--with-frames", opt) or std.mem.eql(u8, "--with_frames", opt)) {
with_frames = true; with_frames = true;
continue; continue;
} }
if (std.mem.eql(u8, "--strip_mode", opt)) { if (std.mem.eql(u8, "--strip-mode", opt) or std.mem.eql(u8, "--strip_mode", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
@@ -675,7 +746,7 @@ fn parseFetchArgs(
strip.ui = true; strip.ui = true;
strip.css = true; strip.css = true;
} else { } else {
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed }); log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = trimmed });
} }
} }
continue; continue;
@@ -709,6 +780,8 @@ fn parseFetchArgs(
.common = common, .common = common,
.with_base = with_base, .with_base = with_base,
.with_frames = with_frames, .with_frames = with_frames,
.wait_ms = wait_ms,
.wait_until = wait_until,
}; };
} }
@@ -718,102 +791,102 @@ fn parseCommonArg(
args: *std.process.ArgIterator, args: *std.process.ArgIterator,
common: *Common, common: *Common,
) !bool { ) !bool {
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) { if (std.mem.eql(u8, "--insecure-disable-tls-host-verification", opt) or std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
common.tls_verify_host = false; common.tls_verify_host = false;
return true; return true;
} }
if (std.mem.eql(u8, "--obey_robots", opt)) { if (std.mem.eql(u8, "--obey-robots", opt) or std.mem.eql(u8, "--obey_robots", opt)) {
common.obey_robots = true; common.obey_robots = true;
return true; return true;
} }
if (std.mem.eql(u8, "--http_proxy", opt)) { if (std.mem.eql(u8, "--http-proxy", opt) or std.mem.eql(u8, "--http_proxy", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_proxy = try allocator.dupeZ(u8, str); common.http_proxy = try allocator.dupeZ(u8, str);
return true; return true;
} }
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) { if (std.mem.eql(u8, "--proxy-bearer-token", opt) or std.mem.eql(u8, "--proxy_bearer_token", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.proxy_bearer_token = try allocator.dupeZ(u8, str); common.proxy_bearer_token = try allocator.dupeZ(u8, str);
return true; return true;
} }
if (std.mem.eql(u8, "--http_max_concurrent", opt)) { if (std.mem.eql(u8, "--http-max-concurrent", opt) or std.mem.eql(u8, "--http_max_concurrent", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| { common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--http_max_host_open", opt)) { if (std.mem.eql(u8, "--http-max-host-open", opt) or std.mem.eql(u8, "--http_max_host_open", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| { common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--http_connect_timeout", opt)) { if (std.mem.eql(u8, "--http-connect-timeout", opt) or std.mem.eql(u8, "--http_connect_timeout", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--http_timeout", opt)) { if (std.mem.eql(u8, "--http-timeout", opt) or std.mem.eql(u8, "--http_timeout", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| { common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--http_max_response_size", opt)) { if (std.mem.eql(u8, "--http-max-response-size", opt) or std.mem.eql(u8, "--http_max_response_size", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| { common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err }); log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--log_level", opt)) { if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
@@ -821,26 +894,26 @@ fn parseCommonArg(
if (std.mem.eql(u8, str, "error")) { if (std.mem.eql(u8, str, "error")) {
break :blk .err; break :blk .err;
} }
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str }); log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--log_format", opt)) { if (std.mem.eql(u8, "--log-format", opt) or std.mem.eql(u8, "--log_format", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.log_format = std.meta.stringToEnum(log.Format, str) orelse { common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str }); log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
return error.InvalidArgument; return error.InvalidArgument;
}; };
return true; return true;
} }
if (std.mem.eql(u8, "--log_filter_scopes", opt)) { if (std.mem.eql(u8, "--log-filter-scopes", opt) or std.mem.eql(u8, "--log_filter_scopes", opt)) {
if (builtin.mode != .Debug) { if (builtin.mode != .Debug) {
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" }); log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
return false; return false;
@@ -857,7 +930,7 @@ fn parseCommonArg(
var it = std.mem.splitScalar(u8, str, ','); var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| { while (it.next()) |part| {
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse { try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part }); log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = part });
return false; return false;
}); });
} }
@@ -865,14 +938,14 @@ fn parseCommonArg(
return true; return true;
} }
if (std.mem.eql(u8, "--user_agent_suffix", opt)) { if (std.mem.eql(u8, "--user-agent-suffix", opt) or std.mem.eql(u8, "--user_agent_suffix", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
for (str) |c| { for (str) |c| {
if (!std.ascii.isPrint(c)) { if (!std.ascii.isPrint(c)) {
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" }); log.fatal(.app, "not printable character", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
} }
} }
@@ -880,27 +953,27 @@ fn parseCommonArg(
return true; return true;
} }
if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) { if (std.mem.eql(u8, "--web-bot-auth-key-file", opt) or std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.web_bot_auth_key_file = try allocator.dupe(u8, str); common.web_bot_auth_key_file = try allocator.dupe(u8, str);
return true; return true;
} }
if (std.mem.eql(u8, "--web_bot_auth_keyid", opt)) { if (std.mem.eql(u8, "--web-bot-auth-keyid", opt) or std.mem.eql(u8, "--web_bot_auth_keyid", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.web_bot_auth_keyid = try allocator.dupe(u8, str); common.web_bot_auth_keyid = try allocator.dupe(u8, str);
return true; return true;
} }
if (std.mem.eql(u8, "--web_bot_auth_domain", opt)) { if (std.mem.eql(u8, "--web-bot-auth-domain", opt) or std.mem.eql(u8, "--web_bot_auth_domain", opt)) {
const str = args.next() orelse { const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" }); log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument; return error.InvalidArgument;
}; };
common.web_bot_auth_domain = try allocator.dupe(u8, str); common.web_bot_auth_domain = try allocator.dupe(u8, str);

View File

@@ -47,7 +47,15 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
log.err(.app, "listener map failed", .{ .err = err }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { var visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .empty;
var ctx: WalkContext = .{
.xpath_buffer = &xpath_buffer,
.listener_targets = listener_targets,
.visibility_cache = &visibility_cache,
.pointer_events_cache = &pointer_events_cache,
};
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err }); log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -60,7 +68,15 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
log.err(.app, "listener map failed", .{ .err = err }); log.err(.app, "listener map failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| { var visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .empty;
var ctx: WalkContext = .{
.xpath_buffer = &xpath_buffer,
.listener_targets = listener_targets,
.visibility_cache = &visibility_cache,
.pointer_events_cache = &pointer_events_cache,
};
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err }); log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -84,7 +100,22 @@ const NodeData = struct {
node_name: []const u8, node_name: []const u8,
}; };
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void { const WalkContext = struct {
xpath_buffer: *std.ArrayList(u8),
listener_targets: interactive.ListenerTargetMap,
visibility_cache: *Element.VisibilityCache,
pointer_events_cache: *Element.PointerEventsCache,
};
fn walk(
self: @This(),
ctx: *WalkContext,
node: *Node,
parent_name: ?[]const u8,
visitor: anytype,
index: usize,
current_depth: u32,
) !void {
if (current_depth > self.max_depth) return; if (current_depth > self.max_depth) return;
// 1. Skip non-content nodes // 1. Skip non-content nodes
@@ -96,7 +127,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
if (tag == .datalist or tag == .option or tag == .optgroup) return; if (tag == .datalist or tag == .option or tag == .optgroup) return;
// Check visibility using the engine's checkVisibility which handles CSS display: none // Check visibility using the engine's checkVisibility which handles CSS display: none
if (!el.checkVisibility(self.page)) { if (!el.checkVisibilityCached(ctx.visibility_cache, self.page)) {
return; return;
} }
@@ -137,7 +168,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
} }
if (el.is(Element.Html)) |html_el| { if (el.is(Element.Html)) |html_el| {
if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) { if (interactive.classifyInteractivity(self.page, el, html_el, ctx.listener_targets, ctx.pointer_events_cache) != null) {
is_interactive = true; is_interactive = true;
} }
} }
@@ -145,9 +176,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
node_name = "root"; node_name = "root";
} }
const initial_xpath_len = xpath_buffer.items.len; const initial_xpath_len = ctx.xpath_buffer.items.len;
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index); try appendXPathSegment(node, ctx.xpath_buffer.writer(self.arena), index);
const xpath = xpath_buffer.items; const xpath = ctx.xpath_buffer.items;
var name = try axn.getName(self.page, self.arena); var name = try axn.getName(self.page, self.arena);
@@ -165,18 +196,6 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
name = null; name = null;
} }
var data = NodeData{
.id = cdp_node.id,
.axn = axn,
.role = role,
.name = name,
.value = value,
.options = options,
.xpath = xpath,
.is_interactive = is_interactive,
.node_name = node_name,
};
var should_visit = true; var should_visit = true;
if (self.interactive_only) { if (self.interactive_only) {
var keep = false; var keep = false;
@@ -208,6 +227,18 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
var did_visit = false; var did_visit = false;
var should_walk_children = true; var should_walk_children = true;
var data: NodeData = .{
.id = cdp_node.id,
.axn = axn,
.role = role,
.name = name,
.value = value,
.options = options,
.xpath = xpath,
.is_interactive = is_interactive,
.node_name = node_name,
};
if (should_visit) { if (should_visit) {
should_walk_children = try visitor.visit(node, &data); should_walk_children = try visitor.visit(node, &data);
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
@@ -233,7 +264,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
} }
gop.value_ptr.* += 1; gop.value_ptr.* += 1;
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1); try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
} }
} }
@@ -241,11 +272,11 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
try visitor.leave(); try visitor.leave();
} }
xpath_buffer.shrinkRetainingCapacity(initial_xpath_len); ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
} }
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData { fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
var options = std.ArrayListUnmanaged(OptionData){}; var options: std.ArrayList(OptionData) = .empty;
var it = node.childrenIterator(); var it = node.childrenIterator();
while (it.next()) |child| { while (it.next()) |child| {
if (child.is(Element)) |el| { if (child.is(Element)) |el| {

View File

@@ -22,12 +22,11 @@ const net = std.net;
const posix = std.posix; const posix = std.posix;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig"); const log = @import("log.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Config = @import("Config.zig"); const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP; const CDP = @import("cdp/CDP.zig");
const Net = @import("network/websocket.zig"); const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig"); const HttpClient = @import("browser/HttpClient.zig");
@@ -45,7 +44,7 @@ clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !*Server { pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator; const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(allocator, address); const json_version_response = try buildJSONVersionResponse(app);
errdefer allocator.free(json_version_response); errdefer allocator.free(json_version_response);
const self = try allocator.create(Server); const self = try allocator.create(Server);
@@ -212,7 +211,7 @@ pub const Client = struct {
http: *HttpClient, http: *HttpClient,
ws: Net.WsConnection, ws: Net.WsConnection,
fn init( pub fn init(
socket: posix.socket_t, socket: posix.socket_t,
allocator: Allocator, allocator: Allocator,
app: *App, app: *App,
@@ -250,7 +249,7 @@ pub const Client = struct {
self.ws.shutdown(); self.ws.shutdown();
} }
fn deinit(self: *Client) void { pub fn deinit(self: *Client) void {
switch (self.mode) { switch (self.mode) {
.cdp => |*cdp| cdp.deinit(), .cdp => |*cdp| cdp.deinit(),
.http => {}, .http => {},
@@ -302,15 +301,8 @@ pub const Client = struct {
var ms_remaining = self.ws.timeout_ms; var ms_remaining = self.ws.timeout_ms;
while (true) { while (true) {
switch (cdp.pageWait(ms_remaining)) { const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
.cdp_socket => { error.NoPage => {
if (self.readSocket() == false) {
return;
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.no_page => {
const status = http.tick(ms_remaining) catch |err| { const status = http.tick(ms_remaining) catch |err| {
log.err(.app, "http tick", .{ .err = err }); log.err(.app, "http tick", .{ .err = err });
return; return;
@@ -324,6 +316,18 @@ pub const Client = struct {
} }
last_message = milliTimestamp(.monotonic); last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms; ms_remaining = self.ws.timeout_ms;
continue;
},
else => return wait_err,
};
switch (result) {
.cdp_socket => {
if (self.readSocket() == false) {
return;
}
last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
}, },
.done => { .done => {
const now = milliTimestamp(.monotonic); const now = milliTimestamp(.monotonic);
@@ -456,7 +460,7 @@ pub const Client = struct {
fn upgradeConnection(self: *Client, request: []u8) !void { fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request); try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) }; self.mode = .{ .cdp = try CDP.init(self) };
} }
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void { fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
@@ -484,11 +488,17 @@ pub const Client = struct {
// -------- // --------
fn buildJSONVersionResponse( fn buildJSONVersionResponse(
allocator: Allocator, app: *const App,
address: net.Address,
) ![]const u8 { ) ![]const u8 {
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}"; const port = app.config.port();
const body_len = std.fmt.count(body_format, .{address}); const host = app.config.advertiseHost();
if (std.mem.eql(u8, host, "0.0.0.0")) {
log.info(.cdp, "unreachable advertised host", .{
.message = "when --host is set to 0.0.0.0 consider setting --advertise-host to a reachable address",
});
}
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"}}";
const body_len = std.fmt.count(body_format, .{ host, port });
// We send a Connection: Close (and actually close the connection) // We send a Connection: Close (and actually close the connection)
// because chromedp (Go driver) sends a request to /json/version and then // because chromedp (Go driver) sends a request to /json/version and then
@@ -502,23 +512,22 @@ fn buildJSONVersionResponse(
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format; body_format;
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address }); return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
} }
pub const timestamp = @import("datetime.zig").timestamp; pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp; pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = std.testing; const testing = @import("testing.zig");
test "server: buildJSONVersionResponse" { test "server: buildJSONVersionResponse" {
const address = try net.Address.parseIp4("127.0.0.1", 9001); const res = try buildJSONVersionResponse(testing.test_app);
const res = try buildJSONVersionResponse(testing.allocator, address); defer testing.test_app.allocator.free(res);
defer testing.allocator.free(res);
try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++ try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++
"Content-Length: 48\r\n" ++ "Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res); "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}", res);
} }
test "Client: http invalid request" { test "Client: http invalid request" {
@@ -526,7 +535,7 @@ test "Client: http invalid request" {
defer c.deinit(); defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n"); const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++ try testing.expectEqual("HTTP/1.1 413 \r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++ "Content-Length: 17\r\n\r\n" ++
"Request too large", res); "Request too large", res);
@@ -595,7 +604,7 @@ test "Client: http valid handshake" {
"Custom: Header-Value\r\n\r\n"; "Custom: Header-Value\r\n\r\n";
const res = try c.httpRequest(request); const res = try c.httpRequest(request);
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++ "Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++ "Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
@@ -723,7 +732,7 @@ test "server: 404" {
defer c.deinit(); defer c.deinit();
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++ try testing.expectEqual("HTTP/1.1 404 \r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Length: 9\r\n\r\n" ++ "Content-Length: 9\r\n\r\n" ++
"Not found", res); "Not found", res);
@@ -735,7 +744,7 @@ test "server: get /json/version" {
"Content-Length: 48\r\n" ++ "Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++ "Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}"; "{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}";
{ {
// twice on the same connection // twice on the same connection
@@ -743,7 +752,7 @@ test "server: get /json/version" {
defer c.deinit(); defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings(expected_response, res1); try testing.expectEqual(expected_response, res1);
} }
{ {
@@ -752,7 +761,7 @@ test "server: get /json/version" {
defer c.deinit(); defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqualStrings(expected_response, res1); try testing.expectEqual(expected_response, res1);
} }
} }
@@ -770,7 +779,7 @@ fn assertHTTPError(
.{ expected_status, expected_body.len, expected_body }, .{ expected_status, expected_body.len, expected_body },
); );
try testing.expectEqualStrings(expected_response, res); try testing.expectEqual(expected_response, res);
} }
fn assertWebSocketError(close_code: u16, input: []const u8) !void { fn assertWebSocketError(close_code: u16, input: []const u8) !void {
@@ -914,7 +923,7 @@ const TestClient = struct {
"Custom: Header-Value\r\n\r\n"; "Custom: Header-Value\r\n\r\n";
const res = try self.httpRequest(request); const res = try self.httpRequest(request);
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++ try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++ "Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++ "Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);

View File

@@ -19,17 +19,13 @@
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig"); const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool; const ArenaPool = App.ArenaPool;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");

View File

@@ -425,7 +425,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
ls.deinit(); ls.deinit();
} }
const activation_state = ActivationState.create(event, target, page); const activation_state = try ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset // Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented) // and default actions execute (unless prevented)
@@ -820,7 +820,7 @@ const ActivationState = struct {
const Input = Element.Html.Input; const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState { fn create(event: *const Event, target: *Node, page: *Page) !?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) { if (event._type_string.eql(comptime .wrap("click")) == false) {
return null; return null;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -386,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool {
return self.content_type == .text_html; return self.content_type == .text_html;
} }
pub fn isText(mime: *const Mime) bool {
return switch (mime.content_type) {
.text_xml, .text_html, .text_javascript, .text_plain, .text_css => true,
.application_json => true,
else => false,
};
}
// we expect value to be lowercase // we expect value to be lowercase
fn parseContentType(value: []const u8) !struct { ContentType, usize } { fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len; const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;

View File

@@ -27,7 +27,6 @@ const IS_DEBUG = builtin.mode == .Debug;
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig");
const String = @import("../string.zig").String; const String = @import("../string.zig").String;
const Mime = @import("Mime.zig"); const Mime = @import("Mime.zig");
@@ -35,6 +34,7 @@ const Factory = @import("Factory.zig");
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig"); const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig"); const ScriptManager = @import("ScriptManager.zig");
const StyleManager = @import("StyleManager.zig");
const Parser = @import("parser/Parser.zig"); const Parser = @import("parser/Parser.zig");
@@ -42,7 +42,6 @@ const URL = @import("URL.zig");
const Blob = @import("webapi/Blob.zig"); const Blob = @import("webapi/Blob.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 CData = @import("webapi/CData.zig"); const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig"); const HtmlElement = @import("webapi/element/Html.zig");
@@ -58,14 +57,13 @@ const AbstractRange = @import("webapi/AbstractRange.zig");
const MutationObserver = @import("webapi/MutationObserver.zig"); const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
const timestamp = @import("../datetime.zig").timestamp; const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp;
@@ -144,6 +142,7 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it. /// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element.Html) = .{}, _to_load: std.ArrayList(*Element.Html) = .{},
_style_manager: StyleManager,
_script_manager: ScriptManager, _script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec) // List of active live ranges (for mutation updates per DOM spec)
@@ -269,6 +268,7 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._factory = factory, ._factory = factory,
._pending_loads = 1, // always 1 for the ScriptManager ._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame, ._type = if (parent == null) .root else .frame,
._style_manager = undefined,
._script_manager = undefined, ._script_manager = undefined,
._event_manager = EventManager.init(session.page_arena, self), ._event_manager = EventManager.init(session.page_arena, self),
}; };
@@ -296,13 +296,22 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._performance = Performance.init(), ._performance = Performance.init(),
._screen = screen, ._screen = screen,
._visual_viewport = visual_viewport, ._visual_viewport = visual_viewport,
._cross_origin_wrapper = undefined,
}); });
self.window._cross_origin_wrapper = .{ .window = self.window };
self._style_manager = try StyleManager.init(self);
errdefer self._style_manager.deinit();
const browser = session.browser; const browser = session.browser;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit(); errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self); self.js = try browser.env.createContext(self, .{
.identity = &session.identity,
.identity_arena = session.page_arena,
.call_arena = self.call_arena,
});
errdefer self.js.deinit(); errdefer self.js.deinit();
document._page = self; document._page = self;
@@ -356,6 +365,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
} }
self._script_manager.deinit(); self._script_manager.deinit();
self._style_manager.deinit();
session.releaseArena(self.call_arena); session.releaseArena(self.call_arena);
} }
@@ -371,12 +381,9 @@ pub fn getTitle(self: *Page) !?[]const u8 {
return null; return null;
} }
// Add comon headers for a request: // Add common headers for a request:
// * cookies
// * referer // * referer
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void { pub fn headersForRequest(self: *Page, headers: *HttpClient.Headers) !void {
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
// Build the referer // Build the referer
const referer = blk: { const referer = blk: {
if (self.referer_header == null) { if (self.referer_header == null) {
@@ -437,6 +444,12 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
if (is_about_blank or is_blob) { if (is_about_blank or is_blob) {
self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url); self.url = if (is_about_blank) "about:blank" else try self.arena.dupeZ(u8, request_url);
// even though this might be the same _data_ as `default_location`, we
// have to do this to make sure window.location is at a unique _address_.
// If we don't do this, mulitple window._location will have the same
// address and thus be mapped to the same v8::Object in the identity map.
self.window._location = try Location.init(self.url, self);
if (is_blob) { if (is_blob) {
// strip out blob: // strip out blob:
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]); self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
@@ -525,8 +538,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
if (opts.header) |hdr| { if (opts.header) |hdr| {
try headers.add(hdr); try headers.add(hdr);
} }
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, self.url, &headers);
// We dispatch page_navigate event before sending the request. // We dispatch page_navigate event before sending the request.
// It ensures the event page_navigated is not dispatched before this one. // It ensures the event page_navigated is not dispatched before this one.
session.notification.dispatch(.page_navigate, &.{ session.notification.dispatch(.page_navigate, &.{
@@ -553,6 +564,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
.headers = headers, .headers = headers,
.body = opts.body, .body = opts.body,
.cookie_jar = &session.cookie_jar, .cookie_jar = &session.cookie_jar,
.cookie_origin = self.url,
.resource_type = .document, .resource_type = .document,
.notification = self._session.notification, .notification = self._session.notification,
.header_callback = pageHeaderDoneCallback, .header_callback = pageHeaderDoneCallback,
@@ -583,13 +595,34 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp
// page that it's acting on. // page that it's acting on.
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void { fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
const resolved_url, const is_about_blank = blk: { const resolved_url, const is_about_blank = blk: {
if (URL.isCompleteHTTPUrl(request_url)) {
break :blk .{ try arena.dupeZ(u8, request_url), false };
}
if (std.mem.eql(u8, request_url, "about:blank")) { if (std.mem.eql(u8, request_url, "about:blank")) {
// navigate will handle this special case // navigate will handle this special case
break :blk .{ "about:blank", true }; break :blk .{ "about:blank", true };
} }
// request_url isn't a "complete" URL, so it has to be resolved with the
// originator's base. Unless, originator's base is "about:blank", in which
// case we have to walk up the parents and find a real base.
const page_base = base_blk: {
var maybe_not_blank_page = originator;
while (true) {
const maybe_base = maybe_not_blank_page.base();
if (std.mem.eql(u8, maybe_base, "about:blank") == false) {
break :base_blk maybe_base;
}
// The orelse here is probably an invalid case, but there isn't
// anything we can do about it. It should never happen?
maybe_not_blank_page = maybe_not_blank_page.parent orelse break :base_blk "";
}
};
const u = try URL.resolve( const u = try URL.resolve(
arena, arena,
originator.base(), page_base,
request_url, request_url,
.{ .always_dupe = true, .encode = true }, .{ .always_dupe = true, .encode = true },
); );
@@ -995,6 +1028,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
}); });
parser.parse(html); parser.parse(html);
self._parse_state = .complete;
self.documentIsComplete(); self.documentIsComplete();
}, },
else => unreachable, else => unreachable,
@@ -2557,6 +2591,17 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
} }
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
// If a <style> element is being removed, remove its sheet from the list
if (el.is(Element.Html.Style)) |style| {
if (style._sheet) |sheet| {
if (self.document._style_sheets) |sheets| {
sheets.remove(sheet);
}
style._sheet = null;
}
self._style_manager.sheetModified();
}
} }
} }
@@ -2568,8 +2613,10 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
self.domChanged(); self.domChanged();
const dest_connected = target.isConnected(); const dest_connected = target.isConnected();
var it = parent.childrenIterator(); // Use firstChild() instead of iterator to handle cases where callbacks
while (it.next()) |child| { // (like custom element connectedCallback) modify the parent during iteration.
// The iterator captures "next" pointers that can become stale.
while (parent.firstChild()) |child| {
// Check if child was connected BEFORE removing it from parent // Check if child was connected BEFORE removing it from parent
const child_was_connected = child.isConnected(); const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected }); self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
@@ -2581,8 +2628,10 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_
self.domChanged(); self.domChanged();
const dest_connected = parent.isConnected(); const dest_connected = parent.isConnected();
var it = fragment.childrenIterator(); // Use firstChild() instead of iterator to handle cases where callbacks
while (it.next()) |child| { // (like custom element connectedCallback) modify the fragment during iteration.
// The iterator captures "next" pointers that can become stale.
while (fragment.firstChild()) |child| {
// Check if child was connected BEFORE removing it from fragment // Check if child was connected BEFORE removing it from fragment
const child_was_connected = child.isConnected(); const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected }); self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
@@ -3434,7 +3483,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
}; };
if (submit_opts.fire_event) { if (submit_opts.fire_event) {
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self); const submitter_html: ?*HtmlElement = if (submitter_) |s| s.is(HtmlElement) else null;
const submit_event = (try SubmitEvent.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true, .submitter = submitter_html }, self)).asEvent();
// so submit_event is still valid when we check _prevent_default // so submit_event is still valid when we check _prevent_default
submit_event.acquireRef(); submit_event.acquireRef();
@@ -3497,19 +3547,6 @@ pub fn insertText(self: *Page, v: []const u8) !void {
} }
} }
const RequestCookieOpts = struct {
is_http: bool = true,
is_navigation: bool = false,
};
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
return .{
.jar = &self._session.cookie_jar,
.origin = self.url,
.is_http = opts.is_http,
.is_navigation = opts.is_navigation,
};
}
fn asUint(comptime string: anytype) std.meta.Int( fn asUint(comptime string: anytype) std.meta.Int(
.unsigned, .unsigned,
@bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0 @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0
@@ -3532,9 +3569,6 @@ test "WebApi: Page" {
} }
test "WebApi: Frames" { test "WebApi: Frames" {
const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("frames", .{}); try testing.htmlRunner("frames", .{});
} }

238
src/browser/Runner.zig Normal file
View File

@@ -0,0 +1,238 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const log = @import("../log.zig");
const Page = @import("Page.zig");
const Session = @import("Session.zig");
const HttpClient = @import("HttpClient.zig");
const IS_DEBUG = builtin.mode == .Debug;
const Runner = @This();
page: *Page,
session: *Session,
http_client: *HttpClient,
pub const Opts = struct {};
pub fn init(session: *Session, _: Opts) !Runner {
const page = &(session.page orelse return error.NoPage);
return .{
.page = page,
.session = session,
.http_client = session.browser.http_client,
};
}
pub const WaitOpts = struct {
ms: u32,
until: lp.Config.WaitUntil = .done,
};
pub fn wait(self: *Runner, opts: WaitOpts) !void {
_ = try self._wait(false, opts);
}
pub const CDPWaitResult = enum {
done,
cdp_socket,
};
pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
return self._wait(true, opts);
}
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = opts.ms;
const tick_opts = TickOpts{
.ms = 200,
.until = opts.until,
};
while (true) {
const tick_result = self._tick(is_cdp, tick_opts) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = self.page.url,
}),
}
return err;
};
const next_ms = switch (tick_result) {
.ok => |next_ms| next_ms,
.done => return .done,
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
};
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
}
}
pub const TickOpts = struct {
ms: u32,
until: lp.Config.WaitUntil = .done,
};
pub const TickResult = union(enum) {
done,
ok: u32,
};
pub fn tick(self: *Runner, opts: TickOpts) !TickResult {
return switch (try self._tick(false, opts)) {
.ok => |ms| .{ .ok = ms },
.done => .done,
.cdp_socket => unreachable,
};
}
pub const CDPTickResult = union(enum) {
done,
cdp_socket,
ok: u32,
};
pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult {
return self._tick(true, opts);
}
fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
const page = self.page;
const http_client = self.http_client;
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 (comptime is_cdp) == false) {
// 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
const http_result = try http_client.tick(@intCast(opts.ms));
if ((comptime is_cdp) and http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
},
.html, .complete => {
const session = self.session;
if (session.queued_navigation.items.len != 0) {
try session.processQueuedNavigation();
self.page = &session.page.?; // might have changed
return .{ .ok = 0 };
}
const browser = session.browser;
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and (comptime is_cdp == false)) {
// we don't need to consider http_client.intercepted here
// because is_cdp 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);
}
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
}
switch (opts.until) {
.done => {},
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
return .done;
},
.load => if (page._load_state == .complete) {
return .done;
},
.networkidle => if (page._notified_network_idle == .done) {
return .done;
},
}
// We never advertise a wait time of more than 20, there can
// always be new background tasks to run.
if (browser.msToNextMacrotask()) |ms_to_next_task| {
return .{ .ok = @min(ms_to_next_task, 20) };
}
return .done;
}
// We're here because we either have active HTTP
// connections, or is_cdp == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(opts.ms, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
const http_result = try http_client.tick(@intCast(@min(opts.ms, ms_to_wait)));
if ((comptime is_cdp) and http_result == .cdp_socket) {
return .cdp_socket;
}
return .{ .ok = 0 };
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => return .done,
}
}

View File

@@ -28,12 +28,10 @@ const String = @import("../string.zig").String;
const js = @import("js/js.zig"); 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 Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
@@ -138,9 +136,9 @@ fn clearList(list: *std.DoublyLinkedList) void {
} }
} }
fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers { fn getHeaders(self: *ScriptManager) !net_http.Headers {
var headers = try self.client.newHeaders(); var headers = try self.client.newHeaders();
try self.page.headersForRequest(arena, url, &headers); try self.page.headersForRequest(&headers);
return headers; return headers;
} }
@@ -280,9 +278,10 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.blocking = is_blocking, .blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script, .resource_type = .script,
.notification = 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,
@@ -405,8 +404,9 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script, .resource_type = .script,
.notification = 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,
@@ -508,10 +508,11 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
.url = url, .url = url,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.ctx = script, .ctx = script,
.resource_type = .script, .resource_type = .script,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.notification = 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,
@@ -654,7 +655,6 @@ pub const Script = struct {
debug_transfer_aborted: bool = false, debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0, debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false, debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0, debug_transfer_intercept_state: u8 = 0,
debug_transfer_auth_challenge: bool = false, debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0, debug_transfer_easy_id: usize = 0,
@@ -730,7 +730,6 @@ pub const Script = struct {
.a3 = self.debug_transfer_aborted, .a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received, .a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail, .a5 = self.debug_transfer_notified_fail,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state, .a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge, .a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id, .a9 = self.debug_transfer_easy_id,
@@ -739,10 +738,9 @@ pub const Script = struct {
.b3 = transfer.aborted, .b3 = transfer.aborted,
.b4 = transfer.bytes_received, .b4 = transfer.bytes_received,
.b5 = transfer._notified_fail, .b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state), .b7 = @intFromEnum(transfer._intercept_state),
.b8 = transfer._auth_challenge != null, .b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0, .b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
}); });
self.header_callback_called = true; self.header_callback_called = true;
self.debug_transfer_id = transfer.id; self.debug_transfer_id = transfer.id;
@@ -750,10 +748,9 @@ pub const Script = struct {
self.debug_transfer_aborted = transfer.aborted; self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received; self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail; self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state); self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_auth_challenge = transfer._auth_challenge != null; self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0; self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });

View File

@@ -24,11 +24,13 @@ const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const js = @import("js/js.zig"); const js = @import("js/js.zig");
const v8 = js.v8;
const storage = @import("webapi/storage/storage.zig"); const storage = @import("webapi/storage/storage.zig");
const Navigation = @import("webapi/navigation/Navigation.zig"); const Navigation = @import("webapi/navigation/Navigation.zig");
const History = @import("webapi/History.zig"); const History = @import("webapi/History.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
pub const Runner = @import("Runner.zig");
const Browser = @import("Browser.zig"); const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig"); const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");
@@ -65,36 +67,41 @@ page_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root page lifetime. // Origin map for same-origin context sharing. Scoped to the root page lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty, origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Identity tracking for the main world. All main world contexts share this,
// ensuring object identity works across same-origin frames.
identity: js.Identity = .{},
// Shared resources for all pages in this session. // Shared resources for all pages in this session.
// These live for the duration of the page tree (root + frames). // These live for the duration of the page tree (root + frames).
arena_pool: *ArenaPool, arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void = if (IS_DEBUG) .empty else {},
page: ?Page, page: ?Page,
queued_navigation: std.ArrayList(*Page), // Double buffer so that, as we process one list of queued navigations, new entries
// are added to the separate buffer. This ensures that we don't end up with
// endless navigation loops AND that we don't invalidate the list while iterating
// if a new entry gets appended
queued_navigation_1: std.ArrayList(*Page),
queued_navigation_2: std.ArrayList(*Page),
// pointer to either queued_navigation_1 or queued_navigation_2
queued_navigation: *std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing. // Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync // We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation). // about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Page), queued_queued_navigation: std.ArrayList(*Page),
page_id_gen: u32, page_id_gen: u32 = 0,
frame_id_gen: u32, frame_id_gen: u32 = 0,
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 arena_pool = browser.arena_pool; const arena_pool = browser.arena_pool;
const arena = try arena_pool.acquire(); const arena = try arena_pool.acquire(.{ .debug = "Session" });
errdefer arena_pool.release(arena); errdefer arena_pool.release(arena);
const page_arena = try arena_pool.acquire(); const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" });
errdefer arena_pool.release(page_arena); errdefer arena_pool.release(page_arena);
self.* = .{ self.* = .{
@@ -104,17 +111,18 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.page_arena = page_arena, .page_arena = page_arena,
.factory = Factory.init(page_arena), .factory = Factory.init(page_arena),
.history = .{}, .history = .{},
.page_id_gen = 0,
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created. // The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined }, .navigation = .{ ._proto = undefined },
.storage_shed = .{}, .storage_shed = .{},
.browser = browser, .browser = browser,
.queued_navigation = .{}, .queued_navigation = undefined,
.queued_navigation_1 = .{},
.queued_navigation_2 = .{},
.queued_queued_navigation = .{}, .queued_queued_navigation = .{},
.notification = notification, .notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator), .cookie_jar = storage.Cookie.Jar.init(allocator),
}; };
self.queued_navigation = &self.queued_navigation_1;
} }
pub fn deinit(self: *Session) void { pub fn deinit(self: *Session) void {
@@ -171,32 +179,11 @@ pub const GetArenaOpts = struct {
}; };
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator { pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
const allocator = try self.arena_pool.acquire(); return self.arena_pool.acquire(.{ .debug = opts.debug });
if (comptime IS_DEBUG) {
// Use session's arena (not page_arena) since page_arena gets reset between pages
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing and gop.value_ptr.count != 0) {
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
@panic("ArenaPool Double Use");
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
} }
pub fn releaseArena(self: *Session, allocator: Allocator) void { pub fn releaseArena(self: *Session, allocator: Allocator) void {
if (comptime IS_DEBUG) { self.arena_pool.release(allocator);
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
if (comptime builtin.is_test) {
@panic("ArenaPool Double Free");
}
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
} }
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin { pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
@@ -237,18 +224,9 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
/// Reset page_arena and factory for a clean slate. /// Reset page_arena and factory for a clean slate.
/// Called when root page is removed. /// Called when root page is removed.
fn resetPageResources(self: *Session) void { fn resetPageResources(self: *Session) void {
// Check for arena leaks before releasing self.identity.deinit();
if (comptime IS_DEBUG) { self.identity = .{};
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track.clearRetainingCapacity();
}
// All origins should have been released when contexts were destroyed
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0); std.debug.assert(self.origins.count() == 0);
} }
@@ -259,10 +237,9 @@ fn resetPageResources(self: *Session) void {
while (it.next()) |value| { while (it.next()) |value| {
value.*.deinit(app); value.*.deinit(app);
} }
self.origins.clearRetainingCapacity(); self.origins = .empty;
} }
// Release old page_arena and acquire fresh one
self.frame_id_gen = 0; self.frame_id_gen = 0;
self.arena_pool.reset(self.page_arena, 64 * 1024); self.arena_pool.reset(self.page_arena, 64 * 1024);
self.factory = Factory.init(self.page_arena); self.factory = Factory.init(self.page_arena);
@@ -293,12 +270,6 @@ pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null); return &(self.page orelse return null);
} }
pub const WaitResult = enum {
done,
no_page,
cdp_socket,
};
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page { pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null; const page = self.currentPage() orelse return null;
return findPageBy(page, "_frame_id", frame_id); return findPageBy(page, "_frame_id", frame_id);
@@ -319,194 +290,12 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
return null; return null;
} }
pub fn wait(self: *Session, wait_ms: u32) WaitResult { pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
var page = &(self.page orelse return .no_page); return Runner.init(self, opts);
while (true) {
const wait_result = self._wait(page, wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = page.url,
}),
}
return .done;
};
switch (wait_result) {
.done => {
if (self.queued_navigation.items.len == 0) {
return .done;
}
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
},
else => |result| return result,
}
}
}
fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
var timer = try std.time.Timer.start();
var ms_remaining = wait_ms;
const browser = self.browser;
var http_client = browser.http_client;
// 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 (self.queued_navigation.items.len != 0) {
return .done;
}
// The HTML page was parsed. We now either have JS scripts to
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
}
if (page._notified_network_idle.check(total_network_activity == 0)) {
page.notifyNetworkIdle();
}
if (http_active == 0 and exit_when_done) {
// we don't need to consider http_client.intercepted here
// because exit_when_done is true, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
}
var ms = blk: {
// if (wait_ms - ms_remaining < 100) {
// if (comptime builtin.is_test) {
// return .done;
// }
// // Look, we want to exit ASAP, but we don't want
// // to exit so fast that we've run none of the
// // background jobs.
// break :blk 50;
// }
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
break :blk 20;
}
break :blk browser.msToNextMacrotask() orelse return .done;
};
if (ms > ms_remaining) {
// Same as above, except we have a scheduled task,
// it just happens to be too far into the future
// compared to how long we were told to wait.
if (!browser.hasBackgroundTasks()) {
return .done;
}
// _we_ have nothing to run, but v8 is working on
// background tasks. We'll wait for them.
browser.waitForBackgroundTasks();
ms = 20;
}
// We have a task to run in the not-so-distant future.
// You might think we can just sleep until that task is
// ready, but we should continue to run lowPriority tasks
// in the meantime, and that could unblock things. So
// we'll just sleep for a bit, and then restart our wait
// loop to see if anything new can be processed.
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
} else {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
// We should continue to run tasks, so we minimize how long
// we'll poll for network I/O.
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
// to the top of the loop and run macrotasks.
ms_to_wait = 10;
}
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
// data on a socket we aren't handling, return to caller
return .cdp_socket;
}
}
},
.err => |err| {
page._parse_state = .{ .raw_done = @errorName(err) };
return err;
},
.raw_done => {
if (exit_when_done) {
return .done;
}
// we _could_ http_client.tick(ms_to_wait), but this has
// the same result, and I feel is more correct.
return .no_page;
},
}
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= ms_remaining) {
return .done;
}
ms_remaining -= @intCast(ms_elapsed);
}
} }
pub fn scheduleNavigation(self: *Session, page: *Page) !void { pub fn scheduleNavigation(self: *Session, page: *Page) !void {
const list = &self.queued_navigation; const list = self.queued_navigation;
// Check if page is already queued // Check if page is already queued
for (list.items) |existing| { for (list.items) |existing| {
@@ -519,8 +308,13 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void {
return list.append(self.arena, page); return list.append(self.arena, page);
} }
fn processQueuedNavigation(self: *Session) !void { pub fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation; const navigations = self.queued_navigation;
if (self.queued_navigation == &self.queued_navigation_1) {
self.queued_navigation = &self.queued_navigation_2;
} else {
self.queued_navigation = &self.queued_navigation_1;
}
if (self.page.?._queued_navigation != null) { if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the // This is both an optimization and a simplification of sorts. If the
@@ -536,7 +330,6 @@ fn processQueuedNavigation(self: *Session) !void {
defer about_blank_queue.clearRetainingCapacity(); defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank) // First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| { for (navigations.items) |page| {
const qn = page._queued_navigation.?; const qn = page._queued_navigation.?;
@@ -551,7 +344,6 @@ fn processQueuedNavigation(self: *Session) !void {
}; };
} }
// Clear the queue after first pass
navigations.clearRetainingCapacity(); navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank) // Second pass: process synchronous navigations (about:blank)
@@ -561,15 +353,17 @@ fn processQueuedNavigation(self: *Session) !void {
try self.processFrameNavigation(page, qn); try self.processFrameNavigation(page, qn);
} }
// Safety: Remove any about:blank navigations that were queued during the // Safety: Remove any about:blank navigations that were queued during
// second pass to prevent infinite loops // processing to prevent infinite loops. New navigations have been queued
// in the other buffer.
const new_navigations = self.queued_navigation;
var i: usize = 0; var i: usize = 0;
while (i < navigations.items.len) { while (i < new_navigations.items.len) {
const page = navigations.items[i]; const page = new_navigations.items[i];
if (page._queued_navigation) |qn| { if (page._queued_navigation) |qn| {
if (qn.is_about_blank) { if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{}); log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i); _ = self.queued_navigation.swapRemove(i);
continue; continue;
} }
} }
@@ -632,16 +426,6 @@ fn processRootQueuedNavigation(self: *Session) !void {
defer self.arena_pool.release(qn.arena); defer self.arena_pool.release(qn.arena);
// HACK
// Mark as released in tracking BEFORE removePage clears the map.
// We can't call releaseArena() because that would also return the arena
// to the pool, making the memory invalid before we use qn.url/qn.opts.
if (comptime IS_DEBUG) {
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
found.count = 0;
}
}
self.removePage(); self.removePage();
self.page = @as(Page, undefined); self.page = @as(Page, undefined);
@@ -672,3 +456,36 @@ pub fn nextPageId(self: *Session) u32 {
self.page_id_gen = id; self.page_id_gen = id;
return id; return id;
} }
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
// page reset.
pub const FinalizerCallback = struct {
arena: Allocator,
session: *Session,
ptr: *anyopaque,
global: v8.Global,
identity: *js.Identity,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
pub fn deinit(self: *FinalizerCallback) void {
self.zig_finalizer(self.ptr, self.session);
self.session.releaseArena(self.arena);
}
/// Release this item from the identity tracking maps (called after finalizer runs from V8)
pub fn releaseIdentity(self: *FinalizerCallback) void {
const session = self.session;
const id = @intFromPtr(self.ptr);
if (self.identity.identity_map.fetchRemove(id)) |kv| {
var global = kv.value;
v8.v8__Global__Reset(&global);
}
_ = self.identity.finalizer_callbacks.remove(id);
session.releaseArena(self.arena);
}
};

View File

@@ -0,0 +1,855 @@
// 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 log = @import("../log.zig");
const String = @import("../string.zig").String;
const Page = @import("Page.zig");
const CssParser = @import("css/Parser.zig");
const Element = @import("webapi/Element.zig");
const Selector = @import("webapi/selector/Selector.zig");
const SelectorParser = @import("webapi/selector/Parser.zig");
const SelectorList = @import("webapi/selector/List.zig");
const CSSStyleRule = @import("webapi/css/CSSStyleRule.zig");
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CSSStyleProperties = @import("webapi/css/CSSStyleProperties.zig");
const CSSStyleProperty = @import("webapi/css/CSSStyleDeclaration.zig").Property;
const Allocator = std.mem.Allocator;
pub const VisibilityCache = std.AutoHashMapUnmanaged(*Element, bool);
pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
// Tracks visibility-relevant CSS rules from <style> elements.
// Rules are bucketed by their rightmost selector part for fast lookup.
const StyleManager = @This();
const Tag = Element.Tag;
const RuleList = std.MultiArrayList(VisibilityRule);
page: *Page,
arena: Allocator,
// Bucketed rules for fast lookup - keyed by rightmost selector part
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
// Document order counter for tie-breaking equal specificity
next_doc_order: u32 = 0,
// When true, rules need to be rebuilt
dirty: bool = false,
pub fn init(page: *Page) !StyleManager {
return .{
.page = page,
.arena = try page.getArena(.{ .debug = "StyleManager" }),
};
}
pub fn deinit(self: *StyleManager) void {
self.page.releaseArena(self.arena);
}
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
if (sheet._css_rules) |css_rules| {
for (css_rules._rules.items) |rule| {
const style_rule = rule.is(CSSStyleRule) orelse continue;
try self.addRule(style_rule);
}
return;
}
const owner_node = sheet.getOwnerNode() orelse return;
if (owner_node.is(Element.Html.Style)) |style| {
const text = try style.asNode().getTextContentAlloc(self.arena);
var it = CssParser.parseStylesheet(text);
while (it.next()) |parsed_rule| {
try self.addRawRule(parsed_rule.selector, parsed_rule.block);
}
}
}
fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []const u8) !void {
if (selector_text.len == 0) return;
var props = VisibilityProperties{};
var it = CssParser.parseDeclarationsList(block_text);
while (it.next()) |decl| {
const name = decl.name;
const val = decl.value;
if (std.ascii.eqlIgnoreCase(name, "display")) {
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
props.opacity_zero = std.ascii.eqlIgnoreCase(val, "0");
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
props.pointer_events_none = std.ascii.eqlIgnoreCase(val, "none");
}
}
if (!props.isRelevant()) return;
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
for (selectors) |selector| {
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
const bucket_key = getBucketKey(rightmost) orelse continue;
const rule = VisibilityRule{
.props = props,
.selector = selector,
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
};
self.next_doc_order += 1;
switch (bucket_key) {
.id => |id| {
const gop = try self.id_rules.getOrPut(self.arena, id);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.class => |class| {
const gop = try self.class_rules.getOrPut(self.arena, class);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.tag => |tag| {
const gop = try self.tag_rules.getOrPut(self.arena, tag);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.other => {
try self.other_rules.append(self.arena, rule);
},
}
}
}
pub fn sheetRemoved(self: *StyleManager) void {
self.dirty = true;
}
pub fn sheetModified(self: *StyleManager) void {
self.dirty = true;
}
/// Rebuilds the rule list from all document stylesheets.
/// Called lazily when dirty flag is set and rules are needed.
fn rebuildIfDirty(self: *StyleManager) !void {
if (!self.dirty) {
return;
}
self.dirty = false;
errdefer self.dirty = true;
const id_rules_count = self.id_rules.count();
const class_rules_count = self.class_rules.count();
const tag_rules_count = self.tag_rules.count();
const other_rules_count = self.other_rules.len;
self.page._session.arena_pool.resetRetain(self.arena);
self.next_doc_order = 0;
self.id_rules = .empty;
try self.id_rules.ensureTotalCapacity(self.arena, id_rules_count);
self.class_rules = .empty;
try self.class_rules.ensureTotalCapacity(self.arena, class_rules_count);
self.tag_rules = .empty;
try self.tag_rules.ensureTotalCapacity(self.arena, tag_rules_count);
self.other_rules = .{};
try self.other_rules.ensureTotalCapacity(self.arena, other_rules_count);
const sheets = self.page.document._style_sheets orelse return;
for (sheets._sheets.items) |sheet| {
self.parseSheet(sheet) catch |err| {
log.err(.browser, "StyleManager parseSheet", .{ .err = err });
return err;
};
}
}
// Check if an element is hidden based on options.
// By default only checks display:none.
// Walks up the tree to check ancestors.
pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, options: CheckVisibilityOptions) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
// Check cache first (only when checking all properties for caching consistency)
if (cache) |c| {
if (c.get(elem)) |hidden| {
if (hidden) {
return true;
}
current = elem.parentElement();
continue;
}
}
const hidden = self.isElementHidden(elem, options);
// Store in cache
if (cache) |c| {
c.put(self.page.call_arena, elem, hidden) catch {};
}
if (hidden) {
return true;
}
current = elem.parentElement();
}
return false;
}
/// Check if a single element (not ancestors) is hidden.
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
// Track best match per property (value + priority)
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
var display_none: ?bool = null;
var display_priority: u64 = 0;
var visibility_hidden: ?bool = null;
var visibility_priority: u64 = 0;
var opacity_zero: ?bool = null;
var opacity_priority: u64 = 0;
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true; // Early exit for hiding value
}
display_none = false;
display_priority = INLINE_PRIORITY;
}
if (options.check_visibility) {
if (getInlineStyleProperty(el, comptime .wrap("visibility"), self.page)) |property| {
if (property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"))) {
return true;
}
visibility_hidden = false;
visibility_priority = INLINE_PRIORITY;
}
} else {
// This can't be beat. Setting this means that, when checking rules
// we no longer have to check if options.check_visibility is enabled.
// We can just compare the priority.
visibility_priority = INLINE_PRIORITY;
}
if (options.check_opacity) {
if (getInlineStyleProperty(el, comptime .wrap("opacity"), self.page)) |property| {
if (property._value.eql(comptime .wrap("0"))) {
return true;
}
opacity_zero = false;
opacity_priority = INLINE_PRIORITY;
}
} else {
opacity_priority = INLINE_PRIORITY;
}
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
return false;
}
// Helper to check a single rule
const Ctx = struct {
display_none: *?bool,
display_priority: *u64,
visibility_hidden: *?bool,
visibility_priority: *u64,
opacity_zero: *?bool,
opacity_priority: *u64,
el: *Element,
page: *Page,
fn checkRules(ctx: @This(), rules: *const RuleList) void {
if (ctx.display_priority.* == INLINE_PRIORITY and
ctx.visibility_priority.* == INLINE_PRIORITY and
ctx.opacity_priority.* == INLINE_PRIORITY)
{
return;
}
const priorities = rules.items(.priority);
const props_list = rules.items(.props);
const selectors = rules.items(.selector);
for (priorities, props_list, selectors) |p, props, selector| {
// Fast skip using packed u64 priority
if (p <= ctx.display_priority.* and p <= ctx.visibility_priority.* and p <= ctx.opacity_priority.*) {
continue;
}
// Logic for property dominance
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
if (dominated) continue;
if (matchesSelector(ctx.el, selector, ctx.page)) {
// Update best priorities
if (props.display_none != null and p > ctx.display_priority.*) {
ctx.display_none.* = props.display_none;
ctx.display_priority.* = p;
}
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
ctx.visibility_hidden.* = props.visibility_hidden;
ctx.visibility_priority.* = p;
}
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
ctx.opacity_zero.* = props.opacity_zero;
ctx.opacity_priority.* = p;
}
}
}
}
};
const ctx = Ctx{
.display_none = &display_none,
.display_priority = &display_priority,
.visibility_hidden = &visibility_hidden,
.visibility_priority = &visibility_priority,
.opacity_zero = &opacity_zero,
.opacity_priority = &opacity_priority,
.el = el,
.page = self.page,
};
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
ctx.checkRules(&rules);
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
ctx.checkRules(&rules);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
ctx.checkRules(&rules);
}
ctx.checkRules(&self.other_rules);
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
}
/// Check if an element has pointer-events:none.
/// Checks inline style first - if set, skips stylesheet lookup.
/// Walks up the tree to check ancestors.
pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerEventsCache) bool {
self.rebuildIfDirty() catch return false;
var current: ?*Element = el;
while (current) |elem| {
// Check cache first
if (cache) |c| {
if (c.get(elem)) |pe_none| {
if (pe_none) return true;
current = elem.parentElement();
continue;
}
}
const pe_none = self.elementHasPointerEventsNone(elem);
if (cache) |c| {
c.put(self.page.call_arena, elem, pe_none) catch {};
}
if (pe_none) {
return true;
}
current = elem.parentElement();
}
return false;
}
/// Check if a single element (not ancestors) has pointer-events:none.
fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
const page = self.page;
// Check inline style first
if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| {
if (property._value.eql(comptime .wrap("none"))) {
return true;
}
return false;
}
var result: ?bool = null;
var best_priority: u64 = 0;
// Helper to check a single rule
const checkRules = struct {
fn check(rules: *const RuleList, res: *?bool, current_priority: *u64, elem: *Element, p: *Page) void {
if (current_priority.* == INLINE_PRIORITY) return;
const priorities = rules.items(.priority);
const props_list = rules.items(.props);
const selectors = rules.items(.selector);
for (priorities, props_list, selectors) |priority, props, selector| {
if (priority <= current_priority.*) continue;
if (props.pointer_events_none == null) continue;
if (matchesSelector(elem, selector, p)) {
res.* = props.pointer_events_none;
current_priority.* = priority;
}
}
}
}.check;
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
if (self.id_rules.get(id)) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
}
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
while (it.next()) |class| {
if (self.class_rules.get(class)) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
}
}
if (self.tag_rules.get(el.getTag())) |rules| {
checkRules(&rules, &result, &best_priority, el, page);
}
checkRules(&self.other_rules, &result, &best_priority, el, page);
return result orelse false;
}
// Extracts visibility-relevant rules from a CSS rule.
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
// Buckets rules by their rightmost selector part for fast lookup.
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
const selector_text = style_rule._selector_text;
if (selector_text.len == 0) {
return;
}
// Check if the rule has visibility-relevant properties
const style = style_rule._style orelse return;
const props = extractVisibilityProperties(style);
if (!props.isRelevant()) {
return;
}
// Parse the selector list
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
if (selectors.len == 0) {
return;
}
// Create one rule per selector - each has its own specificity
// e.g., "#id, .class { display: none }" becomes two rules with different specificities
for (selectors) |selector| {
// Get the rightmost compound (last segment, or first if no segments)
const rightmost = if (selector.segments.len > 0)
selector.segments[selector.segments.len - 1].compound
else
selector.first;
// Find the bucketing key from rightmost compound
const bucket_key = getBucketKey(rightmost) orelse continue; // skip if dynamic pseudo-class
const rule = VisibilityRule{
.props = props,
.selector = selector,
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
};
self.next_doc_order += 1;
// Add to appropriate bucket
switch (bucket_key) {
.id => |id| {
const gop = try self.id_rules.getOrPut(self.arena, id);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.class => |class| {
const gop = try self.class_rules.getOrPut(self.arena, class);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.tag => |tag| {
const gop = try self.tag_rules.getOrPut(self.arena, tag);
if (!gop.found_existing) gop.value_ptr.* = .{};
try gop.value_ptr.append(self.arena, rule);
},
.other => {
try self.other_rules.append(self.arena, rule);
},
}
}
}
const BucketKey = union(enum) {
id: []const u8,
class: []const u8,
tag: Tag,
other,
};
/// Returns the best bucket key for a compound selector, or null if it contains
/// a dynamic pseudo-class we should skip (hover, active, focus, etc.)
/// Priority: id > class > tag > other
fn getBucketKey(compound: Selector.Compound) ?BucketKey {
var best_key: BucketKey = .other;
for (compound.parts) |part| {
switch (part) {
.id => |id| {
best_key = .{ .id = id };
},
.class => |class| {
if (best_key != .id) {
best_key = .{ .class = class };
}
},
.tag => |tag| {
if (best_key == .other) {
best_key = .{ .tag = tag };
}
},
.tag_name => {
// Custom tag - put in other bucket (can't efficiently look up)
// Keep current best_key if we have something better
},
.pseudo_class => |pc| {
// Skip dynamic pseudo-classes - they depend on interaction state
switch (pc) {
.hover, .active, .focus, .focus_within, .focus_visible, .visited, .target => {
return null; // Skip this selector entirely
},
else => {},
}
},
.universal, .attribute => {},
}
}
return best_key;
}
/// Extracts visibility-relevant properties from a style declaration.
fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties {
var props = VisibilityProperties{};
const decl = style.asCSSStyleDeclaration();
if (decl.findProperty(comptime .wrap("display"))) |property| {
props.display_none = property._value.eql(comptime .wrap("none"));
}
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
props.visibility_hidden = property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"));
}
if (decl.findProperty(comptime .wrap("opacity"))) |property| {
props.opacity_zero = property._value.eql(comptime .wrap("0"));
}
if (decl.findProperty(.wrap("pointer-events"))) |property| {
props.pointer_events_none = property._value.eql(comptime .wrap("none"));
}
return props;
}
// Computes CSS specificity for a selector.
// Returns packed value: (id_count << 20) | (class_count << 10) | element_count
pub fn computeSpecificity(selector: Selector.Selector) u32 {
var ids: u32 = 0;
var classes: u32 = 0; // includes classes, attributes, pseudo-classes
var elements: u32 = 0; // includes elements, pseudo-elements
// Count specificity for first compound
countCompoundSpecificity(selector.first, &ids, &classes, &elements);
// Count specificity for subsequent segments
for (selector.segments) |segment| {
countCompoundSpecificity(segment.compound, &ids, &classes, &elements);
}
// Pack into single u32: (ids << 20) | (classes << 10) | elements
// This gives us 10 bits each, supporting up to 1023 of each type
return (@as(u32, @min(ids, 1023)) << 20) | (@as(u32, @min(classes, 1023)) << 10) | @min(elements, 1023);
}
fn countCompoundSpecificity(compound: Selector.Compound, ids: *u32, classes: *u32, elements: *u32) void {
for (compound.parts) |part| {
switch (part) {
.id => ids.* += 1,
.class => classes.* += 1,
.tag, .tag_name => elements.* += 1,
.universal => {}, // zero specificity
.attribute => classes.* += 1,
.pseudo_class => |pc| {
switch (pc) {
// :where() has zero specificity
.where => {},
// :not(), :is(), :has() take specificity of their most specific argument
.not, .is, .has => |nested| {
var max_nested: u32 = 0;
for (nested) |nested_sel| {
const spec = computeSpecificity(nested_sel);
if (spec > max_nested) max_nested = spec;
}
// Unpack and add to our counts
ids.* += (max_nested >> 20) & 0x3FF;
classes.* += (max_nested >> 10) & 0x3FF;
elements.* += max_nested & 0x3FF;
},
// All other pseudo-classes count as class-level specificity
else => classes.* += 1,
}
},
}
}
}
fn matchesSelector(el: *Element, selector: Selector.Selector, page: *Page) bool {
const node = el.asNode();
return SelectorList.matches(node, selector, node, page);
}
const VisibilityProperties = struct {
display_none: ?bool = null,
visibility_hidden: ?bool = null,
opacity_zero: ?bool = null,
pointer_events_none: ?bool = null,
// returne true if any field in VisibilityProperties is not null
fn isRelevant(self: VisibilityProperties) bool {
return self.display_none != null or
self.visibility_hidden != null or
self.opacity_zero != null or
self.pointer_events_none != null;
}
};
const VisibilityRule = struct {
selector: Selector.Selector, // Single selector, not a list
props: VisibilityProperties,
// Packed priority: (specificity << 32) | doc_order
priority: u64,
};
const CheckVisibilityOptions = struct {
check_opacity: bool = false,
check_visibility: bool = false,
};
// Inline styles always win over stylesheets - use max u64 as sentinel
const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
fn getInlineStyleProperty(el: *Element, property_name: String, page: *Page) ?*CSSStyleProperty {
const style = el.getOrCreateStyle(page) catch |err| {
log.err(.browser, "StyleManager getOrCreateStyle", .{ .err = err });
return null;
};
return style.asCSSStyleDeclaration().findProperty(property_name);
}
const testing = @import("../testing.zig");
test "StyleManager: computeSpecificity: element selector" {
// div -> (0, 0, 1)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .tag = .div }} },
.segments = &.{},
};
try testing.expectEqual(1, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: class selector" {
// .foo -> (0, 1, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: id selector" {
// #bar -> (1, 0, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .id = "bar" }} },
.segments = &.{},
};
try testing.expectEqual(1 << 20, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: combined selector" {
// div.foo#bar -> (1, 1, 1)
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .tag = .div },
.{ .class = "foo" },
.{ .id = "bar" },
} },
.segments = &.{},
};
try testing.expectEqual((1 << 20) | (1 << 10) | 1, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: universal selector" {
// * -> (0, 0, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{.universal} },
.segments = &.{},
};
try testing.expectEqual(0, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: multiple classes" {
// .a.b.c -> (0, 3, 0)
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .class = "a" },
.{ .class = "b" },
.{ .class = "c" },
} },
.segments = &.{},
};
try testing.expectEqual(3 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: descendant combinator" {
// div span -> (0, 0, 2)
const selector = Selector.Selector{
.first = .{ .parts = &.{.{ .tag = .div }} },
.segments = &.{
.{ .combinator = .descendant, .compound = .{ .parts = &.{.{ .tag = .span }} } },
},
};
try testing.expectEqual(2, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :where() has zero specificity" {
// :where(.foo) -> (0, 0, 0) regardless of what's inside
const inner_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .where = &.{inner_selector} } },
} },
.segments = &.{},
};
try testing.expectEqual(0, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :not() takes inner specificity" {
// :not(.foo) -> (0, 1, 0) - takes specificity of .foo
const inner_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .not = &.{inner_selector} } },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: :is() takes most specific inner" {
// :is(.foo, #bar) -> (1, 0, 0) - takes the most specific (#bar)
const class_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .class = "foo" }} },
.segments = &.{},
};
const id_selector = Selector.Selector{
.first = .{ .parts = &.{.{ .id = "bar" }} },
.segments = &.{},
};
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .{ .is = &.{ class_selector, id_selector } } },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 20, computeSpecificity(selector));
}
test "StyleManager: computeSpecificity: pseudo-class (general)" {
// :hover -> (0, 1, 0) - pseudo-classes count as class-level
const selector = Selector.Selector{
.first = .{ .parts = &.{
.{ .pseudo_class = .hover },
} },
.segments = &.{},
};
try testing.expectEqual(1 << 10, computeSpecificity(selector));
}
test "StyleManager: document order tie-breaking" {
// When specificity is equal, higher doc_order (later in document) wins
const beats = struct {
fn f(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
}
}.f;
// Higher specificity always wins regardless of doc_order
try testing.expect(beats(2, 0, 1, 10));
try testing.expect(!beats(1, 10, 2, 0));
// Equal specificity: higher doc_order wins
try testing.expect(beats(1, 5, 1, 3)); // doc_order 5 > 3
try testing.expect(!beats(1, 3, 1, 5)); // doc_order 3 < 5
// Equal specificity and doc_order: no win
try testing.expect(!beats(1, 5, 1, 5));
}

View File

@@ -204,7 +204,7 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
return buf.items[0 .. buf.items.len - 1 :0]; return buf.items[0 .. buf.items.len - 1 :0];
} }
const EncodeSet = enum { path, query, userinfo }; const EncodeSet = enum { path, query, userinfo, fragment };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed // Check if encoding is needed
@@ -256,8 +256,10 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
';', '=' => encode_set == .userinfo, ';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these // Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo, '/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries but not in paths or userinfo // '?' is allowed in queries only
'?' => encode_set != .query, '?' => encode_set != .query,
// '#' is allowed in fragments only
'#' => encode_set != .fragment,
// Everything else needs encoding (including space) // Everything else needs encoding (including space)
else => true, else => true,
}; };
@@ -323,14 +325,22 @@ pub fn getPassword(raw: [:0]const u8) []const u8 {
} }
pub fn getPathname(raw: [:0]const u8) []const u8 { pub fn getPathname(raw: [:0]const u8) []const u8 {
const protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0; const protocol_end = std.mem.indexOf(u8, raw, "://");
const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len;
// Handle scheme:path URLs like about:blank (no "://")
if (protocol_end == null) {
const colon_pos = std.mem.indexOfScalar(u8, raw, ':') orelse return "";
const path = raw[colon_pos + 1 ..];
const query_or_hash = std.mem.indexOfAny(u8, path, "?#") orelse path.len;
return path[0..query_or_hash];
}
const path_start = std.mem.indexOfScalarPos(u8, raw, protocol_end.? + 3, '/') orelse raw.len;
const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len; const query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len;
if (path_start >= query_or_hash_start) { if (path_start >= query_or_hash_start) {
if (std.mem.indexOf(u8, raw, "://") != null) return "/"; return "/";
return "";
} }
return raw[path_start..query_or_hash_start]; return raw[path_start..query_or_hash_start];
@@ -347,25 +357,38 @@ pub fn isHTTPS(raw: [:0]const u8) bool {
pub fn getHostname(raw: [:0]const u8) []const u8 { pub fn getHostname(raw: [:0]const u8) []const u8 {
const host = getHost(raw); const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host; const port_sep = findPortSeparator(host) orelse return host;
return host[0..pos]; return host[0..port_sep];
} }
pub fn getPort(raw: [:0]const u8) []const u8 { pub fn getPort(raw: [:0]const u8) []const u8 {
const host = getHost(raw); const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return ""; const port_sep = findPortSeparator(host) orelse return "";
return host[port_sep + 1 ..];
}
if (pos + 1 >= host.len) { // Finds the colon separating host from port, handling IPv6 bracket notation.
return ""; // For IPv6 like "[::1]:8080", returns position of ":" after "]".
// For IPv6 like "[::1]" (no port), returns null.
// For regular hosts, returns position of last ":" if followed by digits.
fn findPortSeparator(host: []const u8) ?usize {
if (host.len > 0 and host[0] == '[') {
// IPv6: find closing bracket, port separator must be after it
const bracket_end = std.mem.indexOfScalar(u8, host, ']') orelse return null;
if (bracket_end + 1 < host.len and host[bracket_end + 1] == ':') {
return bracket_end + 1;
}
return null;
} }
// Regular host: find last colon and verify it's followed by digits
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return null;
if (pos + 1 >= host.len) return null;
for (host[pos + 1 ..]) |c| { for (host[pos + 1 ..]) |c| {
if (c < '0' or c > '9') { if (c < '0' or c > '9') return null;
return "";
}
} }
return pos;
return host[pos + 1 ..];
} }
pub fn getSearch(raw: [:0]const u8) []const u8 { pub fn getSearch(raw: [:0]const u8) []const u8 {
@@ -393,21 +416,12 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
return null; return null;
} }
var authority_start = scheme_end + 3; const auth = parseAuthority(raw) orelse return null;
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: { const has_user_info = auth.has_user_info;
authority_start += pos + 1; const authority_end = auth.host_end;
break :blk true;
} else false;
// Find end of authority (start of path/query/fragment or end of string)
const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#");
const authority_end = if (authority_end_relative) |end|
authority_start + end
else
raw.len;
// Check for port in the host:port section // Check for port in the host:port section
const host_part = raw[authority_start..authority_end]; const host_part = auth.getHost(raw);
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| { if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
const port = host_part[colon_pos_in_host + 1 ..]; const port = host_part[colon_pos_in_host + 1 ..];
@@ -448,31 +462,18 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
} }
fn getUserInfo(raw: [:0]const u8) ?[]const u8 { fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; const auth = parseAuthority(raw) orelse return null;
if (!auth.has_user_info) return null;
// User info is from authority_start to host_start - 1 (excluding the @)
const scheme_end = std.mem.indexOf(u8, raw, "://").?;
const authority_start = scheme_end + 3; const authority_start = scheme_end + 3;
return raw[authority_start .. auth.host_start - 1];
const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null;
const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len;
const full_pos = authority_start + pos;
if (full_pos < path_start) {
return raw[authority_start..full_pos];
}
return null;
} }
pub fn getHost(raw: [:0]const u8) []const u8 { pub fn getHost(raw: [:0]const u8) []const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return ""; const auth = parseAuthority(raw) orelse return "";
return auth.getHost(raw);
var authority_start = scheme_end + 3;
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
authority_start += pos + 1;
}
const authority = raw[authority_start..];
const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority;
return authority[0..path_start];
} }
// Returns true if these two URLs point to the same document. // Returns true if these two URLs point to the same document.
@@ -587,11 +588,13 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato
const search = getSearch(current); const search = getSearch(current);
const hash = getHash(current); const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .path);
// Add / prefix if not present and value is not empty // Add / prefix if not present and value is not empty
const pathname = if (value.len > 0 and value[0] != '/') const pathname = if (encoded.len > 0 and encoded[0] != '/')
try std.fmt.allocPrint(allocator, "/{s}", .{value}) try std.fmt.allocPrint(allocator, "/{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -602,11 +605,13 @@ pub fn setSearch(current: [:0]const u8, value: []const u8, allocator: Allocator)
const pathname = getPathname(current); const pathname = getPathname(current);
const hash = getHash(current); const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .query);
// Add ? prefix if not present and value is not empty // Add ? prefix if not present and value is not empty
const search = if (value.len > 0 and value[0] != '?') const search = if (encoded.len > 0 and value[0] != '?')
try std.fmt.allocPrint(allocator, "?{s}", .{value}) try std.fmt.allocPrint(allocator, "?{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -617,11 +622,13 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const pathname = getPathname(current); const pathname = getPathname(current);
const search = getSearch(current); const search = getSearch(current);
const encoded = try percentEncodeSegment(allocator, value, .fragment);
// Add # prefix if not present and value is not empty // Add # prefix if not present and value is not empty
const hash = if (value.len > 0 and value[0] != '#') const hash = if (encoded.len > 0 and encoded[0] != '#')
try std.fmt.allocPrint(allocator, "#{s}", .{value}) try std.fmt.allocPrint(allocator, "#{s}", .{encoded})
else else
value; encoded;
return buildUrl(allocator, protocol, host, pathname, search, hash); return buildUrl(allocator, protocol, host, pathname, search, hash);
} }
@@ -745,6 +752,47 @@ pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
return result.items; return result.items;
} }
const AuthorityInfo = struct {
host_start: usize,
host_end: usize,
has_user_info: bool,
fn getHost(self: AuthorityInfo, raw: []const u8) []const u8 {
return raw[self.host_start..self.host_end];
}
};
// Parses the authority component of a URL, correctly handling userinfo.
// Returns null if the URL doesn't have a valid scheme (no "://").
// SECURITY: Only looks for @ within the authority portion (before /?#)
// to prevent path-based @ injection attacks.
fn parseAuthority(raw: []const u8) ?AuthorityInfo {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const authority_start = scheme_end + 3;
// Find end of authority FIRST (start of path/query/fragment or end of string)
const authority_end = if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |end|
authority_start + end
else
raw.len;
// Only look for @ within the authority portion, not in path/query/fragment
const authority_portion = raw[authority_start..authority_end];
if (std.mem.indexOf(u8, authority_portion, "@")) |pos| {
return .{
.host_start = authority_start + pos + 1,
.host_end = authority_end,
.has_user_info = true,
};
}
return .{
.host_start = authority_start,
.host_end = authority_end,
.has_user_info = false,
};
}
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"));
@@ -1413,4 +1461,112 @@ test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page")); try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page")); try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url")); try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
// SECURITY: @ in path must NOT be treated as userinfo separator
try testing.expectEqualSlices(u8, "evil.example.com", getHost("http://evil.example.com/@victim.example.com/"));
try testing.expectEqualSlices(u8, "evil.example.com", getHost("https://evil.example.com/path/@victim.example.com"));
// IPv6 addresses
try testing.expectEqualSlices(u8, "[::1]:8080", getHost("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "[::1]", getHost("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHost("https://[2001:db8::1]/"));
}
test "URL: getHostname" {
// Regular hosts
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]:8080/path"));
// IPv6 without port - must return full bracket notation
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/"));
}
test "URL: getPort" {
// Regular hosts
try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "", getPort("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "8080", getPort("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "3000", getPort("http://[2001:db8::1]:3000/"));
// IPv6 without port - colons inside brackets must not be treated as port separator
try testing.expectEqualSlices(u8, "", getPort("http://[::1]/path"));
try testing.expectEqualSlices(u8, "", getPort("https://[2001:db8::1]/"));
}
test "URL: setPathname percent-encodes" {
// Use arena allocator to match production usage (setPathname makes intermediate allocations)
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
// Spaces must be encoded as %20
const result1 = try setPathname("http://a/", "c d", allocator);
try testing.expectEqualSlices(u8, "http://a/c%20d", result1);
// Already-encoded sequences must not be double-encoded
const result2 = try setPathname("https://example.com/path", "/already%20encoded", allocator);
try testing.expectEqualSlices(u8, "https://example.com/already%20encoded", result2);
// Query and hash must be preserved
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
}
test "URL: getOrigin" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: ?[]const u8,
};
const cases = [_]Case{
// Basic HTTP/HTTPS origins
.{ .url = "http://example.com/path", .expected = "http://example.com" },
.{ .url = "https://example.com/path", .expected = "https://example.com" },
.{ .url = "https://example.com:8080/path", .expected = "https://example.com:8080" },
// Default ports should be stripped
.{ .url = "http://example.com:80/path", .expected = "http://example.com" },
.{ .url = "https://example.com:443/path", .expected = "https://example.com" },
// User info should be stripped from origin
.{ .url = "http://user:pass@example.com/path", .expected = "http://example.com" },
.{ .url = "https://user@example.com:8080/path", .expected = "https://example.com:8080" },
// Non-HTTP schemes return null
.{ .url = "ftp://example.com/path", .expected = null },
.{ .url = "file:///path/to/file", .expected = null },
.{ .url = "about:blank", .expected = null },
// Query and fragment should not affect origin
.{ .url = "https://example.com?query=1", .expected = "https://example.com" },
.{ .url = "https://example.com#fragment", .expected = "https://example.com" },
.{ .url = "https://example.com/path?q=1#frag", .expected = "https://example.com" },
// SECURITY: @ in path must NOT be treated as userinfo separator
// This would be a Same-Origin Policy bypass if mishandled
.{ .url = "http://evil.example.com/@victim.example.com/", .expected = "http://evil.example.com" },
.{ .url = "https://evil.example.com/path/@victim.example.com/steal", .expected = "https://evil.example.com" },
.{ .url = "http://evil.example.com/@victim.example.com:443/", .expected = "http://evil.example.com" },
// @ in query/fragment must also not affect origin
.{ .url = "https://example.com/path?user=foo@bar.com", .expected = "https://example.com" },
.{ .url = "https://example.com/path#user@host", .expected = "https://example.com" },
};
for (cases) |case| {
const result = try getOrigin(testing.arena_allocator, case.url);
if (case.expected) |expected| {
try testing.expectString(expected, result.?);
} else {
try testing.expectEqual(null, result);
}
}
} }

View File

@@ -23,6 +23,8 @@ const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
pub fn click(node: *DOMNode, page: *Page) !void { pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType; const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -102,3 +104,34 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
}; };
} }
} }
pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode {
var timer = try std.time.Timer.start();
var runner = try session.runner(.{});
try runner.wait(.{ .ms = timeout_ms, .until = .load });
while (true) {
const page = runner.page;
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
return error.InvalidSelector;
};
if (element) |el| {
return el.asNode();
}
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
if (elapsed >= timeout_ms) {
return error.Timeout;
}
switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) {
.done => return error.Timeout,
.ok => |recommended_sleep_ms| {
if (recommended_sleep_ms > 0) {
// guanrateed to be <= 20ms
std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms);
}
},
}
}
}

View File

@@ -293,3 +293,191 @@ fn isBang(token: Tokenizer.Token) bool {
else => false, else => false,
}; };
} }
pub const Rule = struct {
selector: []const u8,
block: []const u8,
};
pub fn parseStylesheet(input: []const u8) RulesIterator {
return RulesIterator.init(input);
}
pub const RulesIterator = struct {
input: []const u8,
stream: TokenStream,
has_skipped_at_rule: bool = false,
pub fn init(input: []const u8) RulesIterator {
return .{
.input = input,
.stream = TokenStream.init(input),
};
}
pub fn next(self: *RulesIterator) ?Rule {
var selector_start: ?usize = null;
var selector_end: ?usize = null;
while (true) {
const peeked = self.stream.peek() orelse return null;
if (peeked.token == .curly_bracket_block) {
if (selector_start == null) {
self.skipBlock();
continue;
}
const open_brace = self.stream.next() orelse return null;
const block_start = open_brace.end;
var block_end = block_start;
var depth: usize = 1;
while (true) {
const span = self.stream.next() orelse {
block_end = self.input.len;
break;
};
if (span.token == .curly_bracket_block) {
depth += 1;
} else if (span.token == .close_curly_bracket) {
depth -= 1;
if (depth == 0) {
block_end = span.start;
break;
}
}
}
var selector = self.input[selector_start.?..selector_end.?];
selector = std.mem.trim(u8, selector, &std.ascii.whitespace);
return .{
.selector = selector,
.block = self.input[block_start..block_end],
};
}
if (peeked.token == .at_keyword) {
self.has_skipped_at_rule = true;
self.skipAtRule();
selector_start = null;
selector_end = null;
continue;
}
if (selector_start == null and (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token))) {
_ = self.stream.next();
continue;
}
const span = self.stream.next() orelse return null;
if (!isWhitespaceOrComment(span.token)) {
if (selector_start == null) selector_start = span.start;
selector_end = span.end;
}
}
}
fn skipBlock(self: *RulesIterator) void {
const span = self.stream.next() orelse return;
if (span.token != .curly_bracket_block) return;
var depth: usize = 1;
while (true) {
const next_span = self.stream.next() orelse return;
if (next_span.token == .curly_bracket_block) {
depth += 1;
} else if (next_span.token == .close_curly_bracket) {
depth -= 1;
if (depth == 0) return;
}
}
}
fn skipAtRule(self: *RulesIterator) void {
_ = self.stream.next(); // consume @keyword
var depth: usize = 0;
var saw_block = false;
while (true) {
const peeked = self.stream.peek() orelse return;
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
if (span.token == .curly_bracket_block) {
depth += 1;
saw_block = true;
} else if (span.token == .close_curly_bracket) {
if (depth > 0) depth -= 1;
if (saw_block and depth == 0) return;
}
}
}
};
const testing = std.testing;
test "RulesIterator: single rule" {
var it = RulesIterator.init(".test { color: red; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: multiple rules" {
var it = RulesIterator.init("h1 { margin: 0; } p { padding: 10px; }");
var rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("h1", rule.selector);
try testing.expectEqualStrings(" margin: 0; ", rule.block);
rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("p", rule.selector);
try testing.expectEqualStrings(" padding: 10px; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: skips at-rules without block" {
var it = RulesIterator.init("@import url('style.css'); .test { color: red; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: skips at-rules with block" {
var it = RulesIterator.init("@media screen { .test { color: blue; } } .test2 { color: green; }");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test2", rule.selector);
try testing.expectEqualStrings(" color: green; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: comments and whitespace" {
var it = RulesIterator.init(" /* comment */ .test /* comment */ { /* comment */ color: red; } \n\t");
const rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings(".test", rule.selector);
try testing.expectEqualStrings(" /* comment */ color: red; ", rule.block);
try testing.expectEqual(@as(?Rule, null), it.next());
}
test "RulesIterator: top-level semicolons" {
var it = RulesIterator.init("*{}; ; p{}");
var rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("*", rule.selector);
rule = it.next() orelse return error.MissingRule;
try testing.expectEqualStrings("p", rule.selector);
try testing.expectEqual(@as(?Rule, null), it.next());
}

460
src/browser/forms.zig Normal file
View File

@@ -0,0 +1,460 @@
// 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 TreeWalker = @import("webapi/TreeWalker.zig");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Allocator = std.mem.Allocator;
pub const SelectOption = struct {
value: []const u8,
text: []const u8,
pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void {
try jw.beginObject();
try jw.objectField("value");
try jw.write(self.value);
try jw.objectField("text");
try jw.write(self.text);
try jw.endObject();
}
};
pub const FormField = struct {
backendNodeId: ?u32 = null,
node: *Node,
tag_name: []const u8,
name: ?[]const u8,
input_type: ?[]const u8,
required: bool,
disabled: bool,
value: ?[]const u8,
placeholder: ?[]const u8,
options: []SelectOption,
pub fn jsonStringify(self: *const FormField, jw: anytype) !void {
try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
try jw.objectField("tagName");
try jw.write(self.tag_name);
if (self.name) |v| {
try jw.objectField("name");
try jw.write(v);
}
if (self.input_type) |v| {
try jw.objectField("inputType");
try jw.write(v);
}
try jw.objectField("required");
try jw.write(self.required);
try jw.objectField("disabled");
try jw.write(self.disabled);
if (self.value) |v| {
try jw.objectField("value");
try jw.write(v);
}
if (self.placeholder) |v| {
try jw.objectField("placeholder");
try jw.write(v);
}
if (self.options.len > 0) {
try jw.objectField("options");
try jw.beginArray();
for (self.options) |opt| {
try opt.jsonStringify(jw);
}
try jw.endArray();
}
try jw.endObject();
}
};
pub const FormInfo = struct {
backendNodeId: ?u32 = null,
node: *Node,
action: ?[]const u8,
method: ?[]const u8,
fields: []FormField,
pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void {
try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
if (self.action) |v| {
try jw.objectField("action");
try jw.write(v);
}
if (self.method) |v| {
try jw.objectField("method");
try jw.write(v);
}
try jw.objectField("fields");
try jw.beginArray();
for (self.fields) |field| {
try field.jsonStringify(jw);
}
try jw.endArray();
try jw.endObject();
}
};
/// Populate backendNodeId on each form and its fields by registering
/// their nodes in the given registry. Works with both CDP and MCP registries.
pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void {
for (forms_data) |*form| {
const form_registered = try registry.register(form.node);
form.backendNodeId = form_registered.id;
for (form.fields) |*field| {
const field_registered = try registry.register(field.node);
field.backendNodeId = field_registered.id;
}
}
}
/// Collect all forms and their fields under `root`.
/// Uses Form.getElements() to include fields outside the <form> that
/// reference it via the form="id" attribute, matching browser behavior.
/// `arena` must be an arena allocator — returned slices borrow its memory.
pub fn collectForms(
arena: Allocator,
root: *Node,
page: *Page,
) ![]FormInfo {
var forms: std.ArrayList(FormInfo) = .empty;
var tw = TreeWalker.Full.init(root, .{});
while (tw.next()) |node| {
const form = node.is(Element.Html.Form) orelse continue;
const el = form.asElement();
const fields = try collectFormFields(arena, form, page);
if (fields.len == 0) continue;
const action_attr = el.getAttributeSafe(comptime .wrap("action"));
const method_str = form.getMethod();
try forms.append(arena, .{
.node = node,
.action = if (action_attr) |a| if (a.len > 0) a else null else null,
.method = method_str,
.fields = fields,
});
}
return forms.items;
}
fn collectFormFields(
arena: Allocator,
form: *Element.Html.Form,
page: *Page,
) ![]FormField {
var fields: std.ArrayList(FormField) = .empty;
var elements = try form.getElements(page);
var it = try elements.iterator();
while (it.next()) |el| {
const node = el.asNode();
const is_disabled = el.isDisabled();
if (el.is(Element.Html.Input)) |input| {
if (input._input_type == .hidden) continue;
if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue;
try fields.append(arena, .{
.node = node,
.tag_name = "input",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = input._input_type.toString(),
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = input.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{},
});
continue;
}
if (el.is(Element.Html.TextArea)) |textarea| {
try fields.append(arena, .{
.node = node,
.tag_name = "textarea",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = textarea.getValue(),
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
.options = &.{},
});
continue;
}
if (el.is(Element.Html.Select)) |select| {
const options = try collectSelectOptions(arena, node, page);
try fields.append(arena, .{
.node = node,
.tag_name = "select",
.name = el.getAttributeSafe(comptime .wrap("name")),
.input_type = null,
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
.disabled = is_disabled,
.value = select.getValue(page),
.placeholder = null,
.options = options,
});
continue;
}
// Button elements from getElements() - skip (not fillable)
}
return fields.items;
}
fn collectSelectOptions(
arena: Allocator,
select_node: *Node,
page: *Page,
) ![]SelectOption {
var options: std.ArrayList(SelectOption) = .empty;
const Option = Element.Html.Option;
var tw = TreeWalker.Full.init(select_node, .{});
while (tw.next()) |node| {
const el = node.is(Element) orelse continue;
const option = el.is(Option) orelse continue;
try options.append(arena, .{
.value = option.getValue(page),
.text = option.getText(page),
});
}
return options.items;
}
const testing = @import("../testing.zig");
fn testForms(html: []const u8) ![]FormInfo {
const page = try testing.test_session.createPage();
const doc = page.window._document;
const div = try doc.createElement("div", null, page);
try page.parseHtmlAsChildren(div.asNode(), html);
return collectForms(page.call_arena, div.asNode(), page);
}
test "browser.forms: login form" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/login" method="POST">
\\ <input type="email" name="email" required placeholder="Email">
\\ <input type="password" name="password" required>
\\ <input type="submit" value="Log In">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual("/login", forms[0].action.?);
try testing.expectEqual("post", forms[0].method.?);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expectEqual("email", forms[0].fields[0].name.?);
try testing.expectEqual("email", forms[0].fields[0].input_type.?);
try testing.expect(forms[0].fields[0].required);
try testing.expect(!forms[0].fields[0].disabled);
try testing.expectEqual("password", forms[0].fields[1].name.?);
}
test "browser.forms: form with select" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <select name="color">
\\ <option value="red">Red</option>
\\ <option value="blue">Blue</option>
\\ </select>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("select", forms[0].fields[0].tag_name);
try testing.expectEqual(2, forms[0].fields[0].options.len);
try testing.expectEqual("red", forms[0].fields[0].options[0].value);
try testing.expectEqual("Red", forms[0].fields[0].options[0].text);
}
test "browser.forms: form with textarea" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form method="POST">
\\ <textarea name="message" placeholder="Your message"></textarea>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("textarea", forms[0].fields[0].tag_name);
try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?);
}
test "browser.forms: empty form skipped" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/empty">
\\ <p>No fields here</p>
\\</form>
);
try testing.expectEqual(0, forms.len);
}
test "browser.forms: hidden inputs excluded" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="hidden" name="csrf" value="token123">
\\ <input type="text" name="username">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual("username", forms[0].fields[0].name.?);
}
test "browser.forms: multiple forms" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/search" method="GET">
\\ <input type="text" name="q" placeholder="Search">
\\</form>
\\<form action="/login" method="POST">
\\ <input type="email" name="email">
\\ <input type="password" name="pass">
\\</form>
);
try testing.expectEqual(2, forms.len);
try testing.expectEqual(1, forms[0].fields.len);
try testing.expectEqual(2, forms[1].fields.len);
}
test "browser.forms: disabled fields flagged" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="enabled_field">
\\ <input type="text" name="disabled_field" disabled>
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expect(!forms[0].fields[0].disabled);
try testing.expect(forms[0].fields[1].disabled);
}
test "browser.forms: disabled fieldset" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <fieldset disabled>
\\ <input type="text" name="in_disabled_fieldset">
\\ </fieldset>
\\ <input type="text" name="outside_fieldset">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expect(forms[0].fields[0].disabled);
try testing.expect(!forms[0].fields[1].disabled);
}
test "browser.forms: external field via form attribute" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<input type="text" name="external" form="myform">
\\<form id="myform" action="/submit">
\\ <input type="text" name="internal">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
}
test "browser.forms: checkbox and radio return value attribute" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="checkbox" name="agree" value="yes" checked>
\\ <input type="radio" name="color" value="red">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(2, forms[0].fields.len);
try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?);
try testing.expectEqual("yes", forms[0].fields[0].value.?);
try testing.expectEqual("radio", forms[0].fields[1].input_type.?);
try testing.expectEqual("red", forms[0].fields[1].value.?);
}
test "browser.forms: form without action or method" {
defer testing.reset();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="q">
\\</form>
);
try testing.expectEqual(1, forms.len);
try testing.expectEqual(null, forms[0].action);
try testing.expectEqual("get", forms[0].method.?);
try testing.expectEqual(1, forms[0].fields.len);
}

View File

@@ -36,6 +36,7 @@ pub const InteractivityType = enum {
}; };
pub const InteractiveElement = struct { pub const InteractiveElement = struct {
backendNodeId: ?u32 = null,
node: *Node, node: *Node,
tag_name: []const u8, tag_name: []const u8,
role: ?[]const u8, role: ?[]const u8,
@@ -55,6 +56,11 @@ pub const InteractiveElement = struct {
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void { pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject(); try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
try jw.objectField("tagName"); try jw.objectField("tagName");
try jw.write(self.tag_name); try jw.write(self.tag_name);
@@ -123,6 +129,15 @@ pub const InteractiveElement = struct {
} }
}; };
/// Populate backendNodeId on each interactive element by registering
/// their nodes in the given registry. Works with both CDP and MCP registries.
pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void {
for (elements) |*el| {
const registered = try registry.register(el.node);
el.backendNodeId = registered.id;
}
}
/// Collect all interactive elements under `root`. /// Collect all interactive elements under `root`.
pub fn collectInteractiveElements( pub fn collectInteractiveElements(
root: *Node, root: *Node,
@@ -133,6 +148,8 @@ pub fn collectInteractiveElements(
// so classify and getListenerTypes are both O(1) per element. // so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena); const listener_targets = try buildListenerTargetMap(page, arena);
var css_cache: Element.PointerEventsCache = .empty;
var results: std.ArrayList(InteractiveElement) = .empty; var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{}); var tw = TreeWalker.Full.init(root, .{});
@@ -146,7 +163,7 @@ pub fn collectInteractiveElements(
else => {}, else => {},
} }
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue; const itype = classifyInteractivity(page, el, html_el, listener_targets, &css_cache) orelse continue;
const listener_types = getListenerTypes( const listener_types = getListenerTypes(
el.asEventTarget(), el.asEventTarget(),
@@ -160,7 +177,7 @@ pub fn collectInteractiveElements(
.name = try getAccessibleName(el, arena), .name = try getAccessibleName(el, arena),
.interactivity_type = itype, .interactivity_type = itype,
.listener_types = listener_types, .listener_types = listener_types,
.disabled = isDisabled(el), .disabled = el.isDisabled(),
.tab_index = html_el.getTabIndex(), .tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")), .id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")), .class = el.getAttributeSafe(comptime .wrap("class")),
@@ -210,10 +227,14 @@ pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap
} }
pub fn classifyInteractivity( pub fn classifyInteractivity(
page: *Page,
el: *Element, el: *Element,
html_el: *Element.Html, html_el: *Element.Html,
listener_targets: ListenerTargetMap, listener_targets: ListenerTargetMap,
cache: ?*Element.PointerEventsCache,
) ?InteractivityType { ) ?InteractivityType {
if (el.hasPointerEventsNone(cache, page)) return null;
// 1. Native interactive by tag // 1. Native interactive by tag
switch (el.getTag()) { switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native, .button, .summary, .details, .select, .textarea => return .native,
@@ -406,36 +427,6 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
// strip out trailing space // strip out trailing space
return arr.items[0 .. arr.items.len - 1]; return arr.items[0 .. arr.items.len - 1];
} }
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 { fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| { if (el.is(Element.Html.Input)) |input| {
@@ -554,6 +545,11 @@ test "browser.interactive: disabled by fieldset" {
try testing.expect(!elements[1].disabled); try testing.expect(!elements[1].disabled);
} }
test "browser.interactive: pointer-events none" {
const elements = try testInteractive("<button style=\"pointer-events: none;\">Click me</button>");
try testing.expectEqual(0, elements.len);
}
test "browser.interactive: non-interactive div" { test "browser.interactive: non-interactive div" {
const elements = try testInteractive("<div>Just text</div>"); const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len); try testing.expectEqual(0, elements.len);

View File

@@ -128,7 +128,7 @@ 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 = 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 = try res;
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res); this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else { } else {
this = try local.mapZigInstanceToJs(new_this_handle, res); this = try local.mapZigInstanceToJs(new_this_handle, res);
@@ -505,6 +505,7 @@ pub const Function = struct {
pub const Opts = struct { pub const Opts = struct {
noop: bool = false, noop: bool = false,
static: bool = false, static: bool = false,
deletable: bool = true,
dom_exception: bool = false, dom_exception: bool = false,
as_typed_array: bool = false, as_typed_array: bool = false,
null_as_undefined: bool = false, null_as_undefined: bool = false,

View File

@@ -22,7 +22,6 @@ const log = @import("../../log.zig");
const js = @import("js.zig"); const js = @import("js.zig");
const Env = @import("Env.zig"); const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig"); const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig"); const Scheduler = @import("Scheduler.zig");
@@ -63,7 +62,9 @@ templates: []*const v8.FunctionTemplate,
// Arena for the lifetime of the context // Arena for the lifetime of the context
arena: Allocator, arena: Allocator,
// The page.call_arena // The call_arena for this context. For main world contexts this is
// page.call_arena. For isolated world contexts this is a separate arena
// owned by the IsolatedWorld.
call_arena: Allocator, call_arena: Allocator,
// Because calls can be nested (i.e.a function calling a callback), // Because calls can be nested (i.e.a function calling a callback),
@@ -79,6 +80,16 @@ local: ?*const js.Local = null,
origin: *Origin, origin: *Origin,
// Identity tracking for this context. For main world contexts, this points to
// Session's Identity. For isolated world contexts (CDP inspector), this points
// to IsolatedWorld's Identity. This ensures same-origin frames share object
// identity while isolated worlds have separate identity tracking.
identity: *js.Identity,
// Allocator to use for identity map operations. For main world contexts this is
// session.page_arena, for isolated worlds it's the isolated world's arena.
identity_arena: Allocator,
// Unlike other v8 types, like functions or objects, modules are not shared // Unlike other v8 types, like functions or objects, modules are not shared
// across origins. // across origins.
global_modules: std.ArrayList(v8.Global) = .empty, global_modules: std.ArrayList(v8.Global) = .empty,
@@ -185,9 +196,8 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc }); lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
const origin = try self.session.getOrCreateOrigin(key); const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
try origin.takeover(self.origin);
self.session.releaseOrigin(self.origin);
self.origin = origin; self.origin = origin;
{ {
@@ -203,16 +213,16 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
} }
pub fn trackGlobal(self: *Context, global: v8.Global) !void { pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.origin.trackGlobal(global); return self.identity.globals.append(self.identity_arena, global);
} }
pub fn trackTemp(self: *Context, global: v8.Global) !void { pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.origin.trackTemp(global); return self.identity.temps.put(self.identity_arena, global.data_ptr, global);
} }
pub fn weakRef(self: *Context, obj: anytype) void { pub fn weakRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj); const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);
@@ -224,7 +234,7 @@ pub fn weakRef(self: *Context, obj: anytype) void {
pub fn safeWeakRef(self: *Context, obj: anytype) void { pub fn safeWeakRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj); const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);
@@ -237,7 +247,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
pub fn strongRef(self: *Context, obj: anytype) void { pub fn strongRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj); const resolved = js.Local.resolveValue(obj);
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse { const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);
@@ -247,6 +257,48 @@ pub fn strongRef(self: *Context, obj: anytype) void {
v8.v8__Global__ClearWeak(&fc.global); v8.v8__Global__ClearWeak(&fc.global);
} }
pub const IdentityResult = struct {
value_ptr: *v8.Global,
found_existing: bool,
};
pub fn addIdentity(self: *Context, ptr: usize) !IdentityResult {
const gop = try self.identity.identity_map.getOrPut(self.identity_arena, ptr);
return .{
.value_ptr = gop.value_ptr,
.found_existing = gop.found_existing,
};
}
pub fn releaseTemp(self: *Context, global: v8.Global) void {
if (self.identity.temps.fetchRemove(global.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
pub fn createFinalizerCallback(
self: *Context,
global: v8.Global,
ptr: *anyopaque,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
) !*Session.FinalizerCallback {
const session = self.session;
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
errdefer session.releaseArena(arena);
const fc = try arena.create(Session.FinalizerCallback);
fc.* = .{
.arena = arena,
.session = session,
.ptr = ptr,
.global = global,
.zig_finalizer = zig_finalizer,
// Store identity pointer for cleanup when V8 GCs the object
.identity = self.identity,
};
return fc;
}
// Any operation on the context have to be made from a local. // Any operation on the context have to be made from a local.
pub fn localScope(self: *Context, ls: *js.Local.Scope) void { pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate; const isolate = self.isolate;

View File

@@ -26,7 +26,6 @@ const App = @import("../../App.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const bridge = @import("bridge.zig"); const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Context = @import("Context.zig"); const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig"); const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig"); const Platform = @import("Platform.zig");
@@ -254,8 +253,15 @@ pub fn deinit(self: *Env) void {
allocator.destroy(self.isolate_params); allocator.destroy(self.isolate_params);
} }
pub fn createContext(self: *Env, page: *Page) !*Context { pub const ContextParams = struct {
const context_arena = try self.app.arena_pool.acquire(); identity: *js.Identity,
identity_arena: Allocator,
call_arena: Allocator,
debug_name: []const u8 = "Context",
};
pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
const context_arena = try self.app.arena_pool.acquire(.{ .debug = params.debug_name });
errdefer self.app.arena_pool.release(context_arena); errdefer self.app.arena_pool.release(context_arena);
const isolate = self.isolate; const isolate = self.isolate;
@@ -300,33 +306,43 @@ pub fn createContext(self: *Env, page: *Page) !*Context {
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
} }
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
const context_id = self.context_id; const context_id = self.context_id;
self.context_id = context_id + 1; self.context_id = context_id + 1;
const origin = try page._session.getOrCreateOrigin(null); const session = page._session;
errdefer page._session.releaseOrigin(origin); const origin = try session.getOrCreateOrigin(null);
errdefer session.releaseOrigin(origin);
const context = try context_arena.create(Context); const context = try context_arena.create(Context);
context.* = .{ context.* = .{
.env = self, .env = self,
.page = page, .page = page,
.session = page._session,
.origin = origin, .origin = origin,
.id = context_id, .id = context_id,
.session = session,
.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 = params.call_arena,
.microtask_queue = microtask_queue, .microtask_queue = microtask_queue,
.script_manager = &page._script_manager, .script_manager = &page._script_manager,
.scheduler = .init(context_arena), .scheduler = .init(context_arena),
.identity = params.identity,
.identity_arena = params.identity_arena,
}; };
try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global);
{
// Multiple contexts can be created for the same Window (via CDP). We only
// need to register the first one.
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window));
if (gop.found_existing == false) {
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
gop.value_ptr.* = global_global;
}
}
// 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

View File

@@ -210,10 +210,10 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) { if (comptime is_global) {
try ctx.trackGlobal(global); try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} }; return .{ .handle = global, .temps = {} };
} }
try ctx.trackTemp(global); try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin }; return .{ .handle = global, .temps = &ctx.identity.temps };
} }
pub fn tempWithThis(self: *const Function, value: anytype) !Temp { pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
@@ -237,7 +237,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type { fn G(comptime global_type: GlobalType) type {
return struct { return struct {
handle: v8.Global, handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void, temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const Self = @This(); const Self = @This();
@@ -257,7 +257,10 @@ fn G(comptime global_type: GlobalType) type {
} }
pub fn release(self: *const Self) void { pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle); if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
} }
}; };
} }

View File

@@ -0,0 +1,75 @@
// 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/>.
// Identity manages the mapping between Zig instances and their v8::Object wrappers.
// This provides object identity semantics - the same Zig instance always maps to
// the same JS object within a given Identity scope.
//
// Main world contexts share a single Identity (on Session), ensuring that
// `window.top.document === top's document` works across same-origin frames.
//
// Isolated worlds (CDP inspector) have their own Identity, ensuring their
// v8::Global wrappers don't leak into the main world.
const std = @import("std");
const js = @import("js.zig");
const Session = @import("../Session.zig");
const v8 = js.v8;
const Identity = @This();
// Maps Zig instance pointers to their v8::Global(Object) wrappers.
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Tracked global v8 objects that need to be released on cleanup.
globals: std.ArrayList(v8.Global) = .empty,
// Temporary v8 globals that can be released early. Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Finalizer callbacks for weak references. Key is @intFromPtr of the Zig instance.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty,
pub fn deinit(self: *Identity) void {
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
}
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
}

View File

@@ -17,7 +17,6 @@
// 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 Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const string = @import("../../string.zig"); const string = @import("../../string.zig");
@@ -33,7 +32,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8; const v8 = js.v8;
const CallOpts = Caller.CallOpts; const CallOpts = Caller.CallOpts;
const Allocator = std.mem.Allocator;
// Where js.Context has a lifetime tied to the page, and holds the // Where js.Context has a lifetime tied to the page, and holds the
// v8::Global<v8::Context>, this has a much shorter lifetime and holds a // v8::Global<v8::Context>, this has a much shorter lifetime and holds a
@@ -202,20 +200,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
// we can just grab it from the identity_map) // we can just grab it from the identity_map)
pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object { pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, value: anytype) !js.Object {
const ctx = self.ctx; const ctx = self.ctx;
const origin_arena = ctx.origin.arena; const context_arena = ctx.arena;
const T = @TypeOf(value); const T = @TypeOf(value);
switch (@typeInfo(T)) { switch (@typeInfo(T)) {
.@"struct" => { .@"struct" => {
// Struct, has to be placed on the heap // Struct, has to be placed on the heap
const heap = try origin_arena.create(T); const heap = try context_arena.create(T);
heap.* = value; heap.* = value;
return self.mapZigInstanceToJs(js_obj_handle, heap); return self.mapZigInstanceToJs(js_obj_handle, heap);
}, },
.pointer => |ptr| { .pointer => |ptr| {
const resolved = resolveValue(value); const resolved = resolveValue(value);
const gop = try ctx.origin.addIdentity(@intFromPtr(resolved.ptr)); const gop = try ctx.addIdentity(@intFromPtr(resolved.ptr));
if (gop.found_existing) { if (gop.found_existing) {
// we've seen this instance before, return the same object // we've seen this instance before, return the same object
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self); return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
@@ -244,7 +242,7 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// The TAO contains the pointer to our Zig instance as // The TAO contains the pointer to our Zig instance as
// well as any meta data we'll need to use it later. // well as any meta data we'll need to use it later.
// See the TaggedOpaque struct for more details. // See the TaggedOpaque struct for more details.
const tao = try origin_arena.create(TaggedOpaque); const tao = try context_arena.create(TaggedOpaque);
tao.* = .{ tao.* = .{
.value = resolved.ptr, .value = resolved.ptr,
.prototype_chain = resolved.prototype_chain.ptr, .prototype_chain = resolved.prototype_chain.ptr,
@@ -276,10 +274,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
// Instead, we check if the base has finalizer. The assumption // Instead, we check if the base has finalizer. The assumption
// here is that if a resolve type has a finalizer, then the base // here is that if a resolve type has a finalizer, then the base
// should have a finalizer too. // should have a finalizer too.
const fc = try ctx.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?); const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
{ {
errdefer fc.deinit(); errdefer fc.deinit();
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc); try ctx.identity.finalizer_callbacks.put(ctx.identity_arena, @intFromPtr(resolved.ptr), fc);
} }
conditionallyReference(value); conditionallyReference(value);

View File

@@ -16,19 +16,21 @@
// 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/>.
// Origin represents the shared Zig<->JS bridge state for all contexts within // Origin represents the security token for contexts within the same origin.
// the same origin. Multiple contexts (frames) from the same origin share a // Multiple contexts (frames) from the same origin share a single Origin,
// single Origin, ensuring that JS objects maintain their identity across frames. // which provides the V8 SecurityToken that allows cross-context access.
//
// Note: Identity tracking (mapping Zig instances to v8::Objects) is managed
// separately via js.Identity - Session has the main world Identity, and
// IsolatedWorlds have their own Identity instances.
const std = @import("std"); const std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const App = @import("../../App.zig"); const App = @import("../../App.zig");
const Session = @import("../Session.zig");
const v8 = js.v8; const v8 = js.v8;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Origin = @This(); const Origin = @This();
@@ -38,40 +40,12 @@ arena: Allocator,
// The key, e.g. lightpanda.io:443 // The key, e.g. lightpanda.io:443
key: []const u8, key: []const u8,
// Security token - all contexts in this realm must use the same v8::Value instance // Security token - all contexts in this origin must use the same v8::Value instance
// as their security token for V8 to allow cross-context access // as their security token for V8 to allow cross-context access
security_token: v8.Global, security_token: v8.Global,
// Serves two purposes. Like `global_objects`, this is used to free
// every Global(Object) we've created during the lifetime of the realm.
// More importantly, it serves as an identity map - for a given Zig
// instance, we map it to the same Global(Object).
// The key is the @intFromPtr of the Zig value
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Some web APIs have to manage opaque values. Ideally, they use an
// js.Object, but the js.Object has no lifetime guarantee beyond the
// current call. They can call .persist() on their js.Object to get
// a `Global(Object)`. We need to track these to free them.
// This used to be a map and acted like identity_map; the key was
// the @intFromPtr(js_obj.handle). But v8 can re-use address. Without
// a reliable way to know if an object has already been persisted,
// we now simply persist every time persist() is called.
globals: std.ArrayList(v8.Global) = .empty,
// Temp variants stored in HashMaps for O(1) early cleanup.
// Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Any type that is stored in the identity_map which has a finalizer declared
// will have its finalizer stored here. This is only used when shutting down
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
taken_over: std.ArrayList(*Origin),
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire(); const arena = try app.arena_pool.acquire(.{ .debug = "Origin" });
errdefer app.arena_pool.release(arena); errdefer app.arena_pool.release(arena);
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;
@@ -88,175 +62,12 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
.rc = 1, .rc = 1,
.arena = arena, .arena = arena,
.key = owned_key, .key = owned_key,
.temps = .empty,
.globals = .empty,
.taken_over = .empty,
.security_token = token_global, .security_token = token_global,
}; };
return self; return self;
} }
pub fn deinit(self: *Origin, app: *App) void { pub fn deinit(self: *Origin, app: *App) void {
for (self.taken_over.items) |o| {
o.deinit(app);
}
// Call finalizers before releasing anything
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |finalizer| {
finalizer.*.deinit();
}
}
v8.v8__Global__Reset(&self.security_token); v8.v8__Global__Reset(&self.security_token);
{
var it = self.identity_map.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
}
app.arena_pool.release(self.arena); app.arena_pool.release(self.arena);
} }
pub fn trackGlobal(self: *Origin, global: v8.Global) !void {
return self.globals.append(self.arena, global);
}
pub const IdentityResult = struct {
value_ptr: *v8.Global,
found_existing: bool,
};
pub fn addIdentity(self: *Origin, ptr: usize) !IdentityResult {
const gop = try self.identity_map.getOrPut(self.arena, ptr);
return .{
.value_ptr = gop.value_ptr,
.found_existing = gop.found_existing,
};
}
pub fn trackTemp(self: *Origin, global: v8.Global) !void {
return self.temps.put(self.arena, global.data_ptr, global);
}
pub fn releaseTemp(self: *Origin, global: v8.Global) void {
if (self.temps.fetchRemove(global.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
/// Release an item from the identity_map (called after finalizer runs from V8)
pub fn release(self: *Origin, item: *anyopaque) void {
var global = self.identity_map.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
v8.v8__Global__Reset(&global.value);
// The item has been finalized, remove it from the finalizer callback so that
// we don't try to call it again on shutdown.
const kv = self.finalizer_callbacks.fetchRemove(@intFromPtr(item)) orelse {
if (comptime IS_DEBUG) {
std.debug.assert(false);
}
return;
};
const fc = kv.value;
fc.session.releaseArena(fc.arena);
}
pub fn createFinalizerCallback(
self: *Origin,
session: *Session,
global: v8.Global,
ptr: *anyopaque,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
) !*FinalizerCallback {
const arena = try session.getArena(.{ .debug = "FinalizerCallback" });
errdefer session.releaseArena(arena);
const fc = try arena.create(FinalizerCallback);
fc.* = .{
.arena = arena,
.origin = self,
.session = session,
.ptr = ptr,
.global = global,
.zig_finalizer = zig_finalizer,
};
return fc;
}
pub fn takeover(self: *Origin, original: *Origin) !void {
const arena = self.arena;
try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
for (original.globals.items) |obj| {
self.globals.appendAssumeCapacity(obj);
}
original.globals.clearRetainingCapacity();
{
try self.temps.ensureUnusedCapacity(arena, original.temps.count());
var it = original.temps.iterator();
while (it.next()) |kv| {
try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.temps.clearRetainingCapacity();
}
{
try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
var it = original.finalizer_callbacks.iterator();
while (it.next()) |kv| {
kv.value_ptr.*.origin = self;
try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.finalizer_callbacks.clearRetainingCapacity();
}
{
try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
var it = original.identity_map.iterator();
while (it.next()) |kv| {
try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
original.identity_map.clearRetainingCapacity();
}
try self.taken_over.append(self.arena, original);
}
// A type that has a finalizer can have its finalizer called one of two ways.
// The first is from V8 via the WeakCallback we give to weakRef. But that isn't
// guaranteed to fire, so we track this in finalizer_callbacks and call them on
// origin shutdown.
pub const FinalizerCallback = struct {
arena: Allocator,
origin: *Origin,
session: *Session,
ptr: *anyopaque,
global: v8.Global,
zig_finalizer: *const fn (ptr: *anyopaque, session: *Session) void,
pub fn deinit(self: *FinalizerCallback) void {
self.zig_finalizer(self.ptr, self.session);
self.session.releaseArena(self.arena);
}
};

View File

@@ -16,6 +16,7 @@
// 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 std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const v8 = js.v8; const v8 = js.v8;
@@ -63,10 +64,10 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) { if (comptime is_global) {
try ctx.trackGlobal(global); try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} }; return .{ .handle = global, .temps = {} };
} }
try ctx.trackTemp(global); try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin }; return .{ .handle = global, .temps = &ctx.identity.temps };
} }
pub const Temp = G(.temp); pub const Temp = G(.temp);
@@ -80,7 +81,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type { fn G(comptime global_type: GlobalType) type {
return struct { return struct {
handle: v8.Global, handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void, temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const Self = @This(); const Self = @This();
@@ -96,7 +97,10 @@ fn G(comptime global_type: GlobalType) type {
} }
pub fn release(self: *const Self) void { pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle); if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
} }
}; };
} }

View File

@@ -25,7 +25,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8; const v8 = js.v8;
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
const Snapshot = @This(); const Snapshot = @This();
@@ -137,7 +136,7 @@ pub fn create() !Snapshot {
defer v8.v8__HandleScope__DESTRUCT(&handle_scope); defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST // Create templates (constructors only) FIRST
var templates: [JsApis.len]*v8.FunctionTemplate = undefined; var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000); @setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate); templates[i] = generateConstructor(JsApi, isolate);
@@ -419,7 +418,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in // via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the // Zig and returned from a function call, which is why we need the
// FunctionTemplate. // FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate { fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: { const callback = blk: {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func; break :blk JsApi.constructor.func;
@@ -429,7 +428,7 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
break :blk illegalConstructorCallback; break :blk illegalConstructorCallback;
}; };
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?); const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{ {
const internal_field_count = comptime countInternalFields(JsApi); const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) { if (internal_field_count > 0) {
@@ -482,10 +481,15 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
} }
// 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: *const v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
// Create a signature that validates the receiver is an instance of this template.
// This prevents crashes when JavaScript extracts a getter/method and calls it
// with the wrong `this` (e.g., documentGetter.call(null)).
const signature = v8.v8__Signature__New(isolate, 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;
@@ -497,23 +501,47 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) { switch (definition) {
bridge.Accessor => { bridge.Accessor => {
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));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?); const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
const setter_callback = if (value.setter) |setter|
v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
}).?
else
null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) { if (value.setter == null) {
if (value.static) { attribute |= v8.ReadOnly;
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback); }
} else { if (value.deletable == false) {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback); attribute |= v8.DontDelete;
} }
if (value.static) {
// Static accessors: use Template's SetAccessorProperty
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else { } else {
if (comptime IS_DEBUG) { v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
std.debug.assert(value.static == false); .key = js_name,
} .getter = getter_callback,
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?); .setter = setter_callback,
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback); .attribute = attribute,
});
} }
}, },
bridge.Function => { bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?); // For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.func,
.length = value.arity,
.signature = func_signature,
}).?;
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.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);
@@ -551,7 +579,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
has_named_index_getter = true; has_named_index_getter = true;
}, },
bridge.Iterator => { bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?); const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
const js_name = if (value.async) const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate) v8.v8__Symbol__GetAsyncIterator(isolate)
else else

View File

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

View File

@@ -300,10 +300,10 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) { if (comptime is_global) {
try ctx.trackGlobal(global); try ctx.trackGlobal(global);
return .{ .handle = global, .origin = {} }; return .{ .handle = global, .temps = {} };
} }
try ctx.trackTemp(global); try ctx.trackTemp(global);
return .{ .handle = global, .origin = ctx.origin }; return .{ .handle = global, .temps = &ctx.identity.temps };
} }
pub fn toZig(self: Value, comptime T: type) !T { pub fn toZig(self: Value, comptime T: type) !T {
@@ -361,7 +361,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type { fn G(comptime global_type: GlobalType) type {
return struct { return struct {
handle: v8.Global, handle: v8.Global,
origin: if (global_type == .temp) *js.Origin else void, temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const Self = @This(); const Self = @This();
@@ -381,7 +381,10 @@ fn G(comptime global_type: GlobalType) type {
} }
pub fn release(self: *const Self) void { pub fn release(self: *const Self) void {
self.origin.releaseTemp(self.handle); if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
} }
}; };
} }

View File

@@ -18,16 +18,12 @@
const std = @import("std"); const std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const v8 = js.v8; const v8 = js.v8;
const Caller = @import("Caller.zig"); const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const Origin = @import("Origin.zig");
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -117,13 +113,12 @@ pub fn Builder(comptime T: type) type {
.from_v8 = struct { .from_v8 = struct {
fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void { fn wrap(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?; const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const fc: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr)); const fc: *Session.FinalizerCallback = @ptrCast(@alignCast(ptr));
const origin = fc.origin;
const value_ptr = fc.ptr; const value_ptr = fc.ptr;
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) { if (fc.identity.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
func(@ptrCast(@alignCast(value_ptr)), false, fc.session); func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
origin.release(value_ptr); fc.releaseIdentity();
} else { } else {
// A bit weird, but v8 _requires_ that we release it // A bit weird, but v8 _requires_ that we release it
// If we don't. We'll 100% crash. // If we don't. We'll 100% crash.
@@ -200,6 +195,7 @@ pub const Function = struct {
pub const Accessor = struct { pub const Accessor = struct {
static: bool = false, static: bool = false,
deletable: bool = true,
cache: ?Caller.Function.Opts.Caching = null, 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,
@@ -208,6 +204,7 @@ pub const Accessor = struct {
var accessor = Accessor{ var accessor = Accessor{
.cache = opts.cache, .cache = opts.cache,
.static = opts.static, .static = opts.static,
.deletable = opts.deletable,
}; };
if (@typeInfo(@TypeOf(getter)) != .null) { if (@typeInfo(@TypeOf(getter)) != .null) {
@@ -852,6 +849,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/TextEvent.zig"), @import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/InputEvent.zig"), @import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/SubmitEvent.zig"),
@import("../webapi/event/FormDataEvent.zig"),
@import("../webapi/MessageChannel.zig"), @import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"), @import("../webapi/MessagePort.zig"),
@import("../webapi/media/MediaError.zig"), @import("../webapi/media/MediaError.zig"),
@@ -901,6 +900,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/canvas/OffscreenCanvas.zig"), @import("../webapi/canvas/OffscreenCanvas.zig"),
@import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"), @import("../webapi/canvas/OffscreenCanvasRenderingContext2D.zig"),
@import("../webapi/SubtleCrypto.zig"), @import("../webapi/SubtleCrypto.zig"),
@import("../webapi/CryptoKey.zig"),
@import("../webapi/Selection.zig"), @import("../webapi/Selection.zig"),
@import("../webapi/ImageData.zig"), @import("../webapi/ImageData.zig"),
}); });

View File

@@ -25,6 +25,7 @@ pub const Env = @import("Env.zig");
pub const bridge = @import("bridge.zig"); pub const bridge = @import("bridge.zig");
pub const Caller = @import("Caller.zig"); pub const Caller = @import("Caller.zig");
pub const Origin = @import("Origin.zig"); pub const Origin = @import("Origin.zig");
pub const Identity = @import("Identity.zig");
pub const Context = @import("Context.zig"); pub const Context = @import("Context.zig");
pub const Local = @import("Local.zig"); pub const Local = @import("Local.zig");
pub const Inspector = @import("Inspector.zig"); pub const Inspector = @import("Inspector.zig");

54
src/browser/links.zig Normal file
View File

@@ -0,0 +1,54 @@
// 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 Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Page = @import("Page.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Allocator = std.mem.Allocator;
/// Collect all links (href attributes from anchor tags) under `root`.
/// Returns a slice of strings allocated with `arena`.
pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 {
var links: std.ArrayList([]const u8) = .empty;
if (Selector.querySelectorAll(root, "a[href]", page)) |list| {
defer list.deinit(page._session);
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(page) catch |err| {
@import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err });
continue;
};
if (href.len > 0) {
try links.append(arena, href);
}
}
}
} else |err| {
@import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err });
return err;
}
return links.items;
}

View File

@@ -21,7 +21,6 @@ const std = @import("std");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const URL = @import("URL.zig"); const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig"); const TreeWalker = @import("webapi/TreeWalker.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace; const isAllWhitespace = @import("../string.zig").isAllWhitespace;

View File

@@ -15,10 +15,10 @@
a1.play(); a1.play();
cb.push(a1.playState); cb.push(a1.playState);
}); });
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb)); testing.onload(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
</script> </script>
<script id=startTime> <!-- <script id=startTime>
let a2 = document.createElement('div').animate(null, null); let a2 = document.createElement('div').animate(null, null);
// startTime defaults to null // startTime defaults to null
testing.expectEqual(null, a2.startTime); testing.expectEqual(null, a2.startTime);
@@ -39,7 +39,7 @@
// onfinish callback should be scheduled and called asynchronously // onfinish callback should be scheduled and called asynchronously
a3.onfinish = function() { calls.push('finish'); }; a3.onfinish = function() { calls.push('finish'); };
a3.play(); a3.play();
testing.eventually(() => testing.expectEqual(['finish'], calls)); testing.onload(() => testing.expectEqual(['finish'], calls));
</script> </script>
<script id=pause> <script id=pause>
@@ -52,7 +52,7 @@
a4.pause(); a4.pause();
cb4.push(a4.playState) cb4.push(a4.playState)
}); });
testing.eventually(() => testing.expectEqual(['running', 'paused'], cb4)); testing.onload(() => testing.expectEqual(['running', 'paused'], cb4));
</script> </script>
<script id=finish> <script id=finish>
@@ -65,5 +65,6 @@
cb5.push(a5.playState); cb5.push(a5.playState);
a5.play(); a5.play();
}); });
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5)); testing.onload(() => testing.expectEqual(['idle', 'finished'], cb5));
</script> </script>
-->

View File

@@ -125,6 +125,19 @@
</script> </script>
<script id="CanvasRenderingContext2D#canvas">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual(ctx.canvas, element);
// Setting dimensions via ctx.canvas should update the element.
ctx.canvas.width = 40;
ctx.canvas.height = 25;
testing.expectEqual(element.width, 40);
testing.expectEqual(element.height, 25);
}
</script>
<script id="getter"> <script id="getter">
{ {
const element = document.createElement("canvas"); const element = document.createElement("canvas");

View File

@@ -71,7 +71,7 @@
document.fonts.load("italic bold 16px Roboto"); document.fonts.load("italic bold 16px Roboto");
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, loading); testing.expectEqual(true, loading);
testing.expectEqual(true, loadingdone); testing.expectEqual(true, loadingdone);
}); });

View File

@@ -419,3 +419,117 @@
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width); testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
} }
</script> </script>
<script id="CSSStyleSheet_insertRule_deleteRule">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectEqual(0, sheet.cssRules.length);
sheet.insertRule('.test { color: green; }', 0);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
testing.expectEqual('green', sheet.cssRules[0].style.color);
sheet.deleteRule(0);
testing.expectEqual(0, sheet.cssRules.length);
let caught = false;
try {
sheet.deleteRule(5);
} catch (e) {
caught = true;
testing.expectEqual('IndexSizeError', e.name);
}
testing.expectTrue(caught);
}
</script>
<script id="CSSStyleSheet_insertRule_default_index">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectEqual(0, sheet.cssRules.length);
// Call without index, should default to 0
sheet.insertRule('.test-default { color: blue; }');
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test-default', sheet.cssRules[0].selectorText);
// Insert another rule without index, should default to 0 and push the first one to index 1
sheet.insertRule('.test-at-0 { color: red; }');
testing.expectEqual(2, sheet.cssRules.length);
testing.expectEqual('.test-at-0', sheet.cssRules[0].selectorText);
testing.expectEqual('.test-default', sheet.cssRules[1].selectorText);
}
</script>
<script id="CSSStyleSheet_insertRule_semicolon">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
// Should not throw even with trailing semicolon
sheet.insertRule('*{};');
testing.expectEqual(1, sheet.cssRules.length);
}
</script>
<script id="CSSStyleSheet_insertRule_multiple_rules">
{
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
let caught = false;
try {
sheet.insertRule('a { color: red; } b { color: blue; }');
} catch (e) {
caught = true;
testing.expectEqual('SyntaxError', e.name);
}
testing.expectTrue(caught);
testing.expectEqual(0, sheet.cssRules.length);
}
</script>
<script id="CSSStyleSheet_replaceSync">
{
const sheet = new CSSStyleSheet();
testing.expectEqual(0, sheet.cssRules.length);
sheet.replaceSync('.test { color: blue; }');
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.test', sheet.cssRules[0].selectorText);
testing.expectEqual('blue', sheet.cssRules[0].style.color);
let replacedAsync = false;
testing.async(async () => {
const result = await sheet.replace('.async-test { margin: 10px; }');
testing.expectTrue(result === sheet);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.async-test', sheet.cssRules[0].selectorText);
replacedAsync = true;
});
testing.onload(() => testing.expectTrue(replacedAsync));
}
</script>
<script id="CSSStyleRule_cssText">
{
const sheet = new CSSStyleSheet();
sheet.replaceSync('.test { color: red; margin: 10px; }');
// Check serialization format
const cssText = sheet.cssRules[0].cssText;
testing.expectTrue(cssText.includes('.test { '));
testing.expectTrue(cssText.includes('color: red;'));
testing.expectTrue(cssText.includes('margin: 10px;'));
testing.expectTrue(cssText.includes('}'));
}
</script>

View File

@@ -342,3 +342,4 @@
testing.expectEqual('html', doc.lastChild.nodeName); testing.expectEqual('html', doc.lastChild.nodeName);
} }
</script> </script>

View File

@@ -131,7 +131,7 @@
document.open(); document.open();
}, 5); }, 5);
testing.eventually(() => { testing.onload(() => {
// The element should be gone now // The element should be gone now
const afterOpen = document.getElementById('will_be_removed'); const afterOpen = document.getElementById('will_be_removed');
testing.expectEqual(null, afterOpen); testing.expectEqual(null, afterOpen);

View File

@@ -0,0 +1,226 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<!-
<script id="inline_display_none">
{
const el = document.createElement("div");
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
el.style.display = "none";
testing.expectEqual(false, el.checkVisibility());
el.style.display = "block";
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="inline_visibility_hidden">
{
const el = document.createElement("div");
document.body.appendChild(el);
el.style.visibility = "hidden";
// Without visibilityProperty option, visibility:hidden is not checked
testing.expectEqual(true, el.checkVisibility());
// With visibilityProperty: true, visibility:hidden is detected
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
el.style.visibility = "collapse";
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
el.style.visibility = "visible";
testing.expectEqual(true, el.checkVisibility({ visibilityProperty: true }));
el.remove();
}
</script>
<script id="inline_opacity_zero">
{
const el = document.createElement("div");
document.body.appendChild(el);
el.style.opacity = "0";
// Without checkOpacity option, opacity:0 is not checked
testing.expectEqual(true, el.checkVisibility());
// With checkOpacity: true, opacity:0 is detected
testing.expectEqual(false, el.checkVisibility({ checkOpacity: true }));
el.style.opacity = "0.5";
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
el.style.opacity = "1";
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
el.remove();
}
</script>
<script id="parent_hidden_hides_child">
{
const parent = document.createElement("div");
const child = document.createElement("span");
parent.appendChild(child);
document.body.appendChild(parent);
testing.expectEqual(true, child.checkVisibility());
// display:none on parent hides children (no option needed)
parent.style.display = "none";
testing.expectEqual(false, child.checkVisibility());
// visibility:hidden on parent - needs visibilityProperty option
parent.style.display = "block";
parent.style.visibility = "hidden";
testing.expectEqual(true, child.checkVisibility()); // without option
testing.expectEqual(false, child.checkVisibility({ visibilityProperty: true }));
// opacity:0 on parent - needs checkOpacity option
parent.style.visibility = "visible";
parent.style.opacity = "0";
testing.expectEqual(true, child.checkVisibility()); // without option
testing.expectEqual(false, child.checkVisibility({ checkOpacity: true }));
parent.remove();
}
</script>
<style id="style-basic">
.hidden-by-class { display: none; }
.visible-by-class { display: block; }
</style>
<script id="style_tag_basic">
{
const el = document.createElement("div");
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
el.className = "hidden-by-class";
testing.expectEqual(false, el.checkVisibility());
el.className = "visible-by-class";
testing.expectEqual(true, el.checkVisibility());
el.className = "";
el.remove();
}
</script>
<style id="style-specificity">
.spec-hidden { display: none; }
#spec-visible { display: block; }
</style>
<script id="specificity_id_beats_class">
{
const el = document.createElement("div");
el.id = "spec-visible";
el.className = "spec-hidden";
document.body.appendChild(el);
// ID selector (#spec-visible: display:block) should beat class selector (.spec-hidden: display:none)
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<style id="style-order-1">
.order-test { display: none; }
</style>
<style id="style-order-2">
.order-test { display: block; }
</style>
<script id="rule_order_later_wins">
{
const el = document.createElement("div");
el.className = "order-test";
document.body.appendChild(el);
// Second style block should win (display: block)
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<style id="style-override">
.should-be-hidden { display: none; }
</style>
<script id="inline_overrides_stylesheet">
{
const el = document.createElement("div");
el.className = "should-be-hidden";
document.body.appendChild(el);
testing.expectEqual(false, el.checkVisibility());
// Inline style should override
el.style.display = "block";
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="dynamic_style_element">
{
const el = document.createElement("div");
el.className = "dynamic-style-test";
document.body.appendChild(el);
testing.expectEqual(true, el.checkVisibility());
// Add a style element
const style = document.createElement("style");
style.textContent = ".dynamic-style-test { display: none; }";
document.head.appendChild(style);
testing.expectEqual(false, el.checkVisibility());
// Remove the style element
style.remove();
testing.expectEqual(true, el.checkVisibility());
el.remove();
}
</script>
<script id="deep_nesting">
{
const levels = 5;
let current = document.body;
const elements = [];
for (let i = 0; i < levels; i++) {
const el = document.createElement("div");
current.appendChild(el);
elements.push(el);
current = el;
}
// All should be visible
for (let i = 0; i < levels; i++) {
testing.expectEqual(true, elements[i].checkVisibility());
}
// Hide middle element
elements[2].style.display = "none";
// Elements 0, 1 should still be visible
testing.expectEqual(true, elements[0].checkVisibility());
testing.expectEqual(true, elements[1].checkVisibility());
// Elements 2, 3, 4 should be hidden
testing.expectEqual(false, elements[2].checkVisibility());
testing.expectEqual(false, elements[3].checkVisibility());
testing.expectEqual(false, elements[4].checkVisibility());
elements[0].remove();
}
</script>

View File

@@ -532,6 +532,6 @@
testing.expectEqual(true, result); testing.expectEqual(true, result);
}); });
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched)); testing.onload(() => testing.expectEqual(true, asyncBlockDispatched));
} }
</script> </script>

View File

@@ -463,3 +463,44 @@
}); });
} }
</script> </script>
<!-- Test: requestSubmit(submitter) sets SubmitEvent.submitter -->
<form id="test_form_submitter" action="/should-not-navigate6" method="get">
<button id="submitter_btn" type="submit">Save</button>
</form>
<script id="requestSubmit_sets_submitter">
{
const form = $('#test_form_submitter');
const btn = $('#submitter_btn');
let capturedSubmitter = undefined;
form.addEventListener('submit', (e) => {
e.preventDefault();
capturedSubmitter = e.submitter;
});
form.requestSubmit(btn);
testing.expectEqual(btn, capturedSubmitter);
}
</script>
<!-- Test: requestSubmit() without submitter sets submitter to the form element -->
<form id="test_form_submitter2" action="/should-not-navigate7" method="get">
<input type="text" name="q" value="test">
</form>
<script id="requestSubmit_default_submitter_is_form">
{
const form = $('#test_form_submitter2');
let capturedSubmitter = undefined;
form.addEventListener('submit', (e) => {
e.preventDefault();
capturedSubmitter = e.submitter;
});
form.requestSubmit();
testing.expectEqual(form, capturedSubmitter);
}
</script>

View File

@@ -29,10 +29,12 @@
testing.expectEqual('', img.src); testing.expectEqual('', img.src);
testing.expectEqual('', img.alt); testing.expectEqual('', img.alt);
testing.expectEqual('', img.currentSrc);
img.src = 'test.png'; img.src = 'test.png';
// src property returns resolved absolute URL // src property returns resolved absolute URL
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src); testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.png', img.currentSrc);
// 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'));
@@ -137,7 +139,7 @@
}); });
}); });
testing.eventually(() => testing.expectEqual(true, result)); testing.onload(() => testing.expectEqual(true, result));
} }
</script> </script>
@@ -148,7 +150,7 @@
const img = document.createElement("img"); const img = document.createElement("img");
img.addEventListener("load", () => { fired = true; }); img.addEventListener("load", () => { fired = true; });
document.body.appendChild(img); document.body.appendChild(img);
testing.eventually(() => testing.expectEqual(false, fired)); testing.onload(() => testing.expectEqual(false, fired));
} }
</script> </script>
@@ -161,7 +163,7 @@
document.body.appendChild(img); document.body.appendChild(img);
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png"; img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
testing.eventually(() => testing.expectEqual(true, result)); testing.onload(() => testing.expectEqual(true, result));
} }
</script> </script>

View File

@@ -210,7 +210,7 @@
}); });
input.setSelectionRange(1, 4); input.setSelectionRange(1, 4);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(5, eventCount); testing.expectEqual(5, eventCount);
testing.expectEqual('selectionchange', lastEvent.type); testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(input, lastEvent.target); testing.expectEqual(input, lastEvent.target);
@@ -247,7 +247,7 @@
input.select(); input.select();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, eventCount); testing.expectEqual(1, eventCount);
testing.expectEqual('select', lastEvent.type); testing.expectEqual('select', lastEvent.type);
testing.expectEqual(input, lastEvent.target); testing.expectEqual(input, lastEvent.target);

View File

@@ -54,7 +54,7 @@
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.addEventListener('load', () => { fired = true; }); link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link); document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired)); testing.onload(() => testing.expectEqual(false, fired));
} }
</script> </script>
@@ -66,7 +66,7 @@
link.href = 'https://lightpanda.io/opensource-browser/15'; link.href = 'https://lightpanda.io/opensource-browser/15';
link.addEventListener('load', () => { fired = true; }); link.addEventListener('load', () => { fired = true; });
document.head.appendChild(link); document.head.appendChild(link);
testing.eventually(() => testing.expectEqual(false, fired)); testing.onload(() => testing.expectEqual(false, fired));
} }
</script> </script>
@@ -81,7 +81,7 @@
// then set href. // then set href.
link.href = 'https://lightpanda.io/opensource-browser/15'; link.href = 'https://lightpanda.io/opensource-browser/15';
testing.eventually(() => testing.expectEqual(true, result)); testing.onload(() => testing.expectEqual(true, result));
} }
</script> </script>
@@ -98,7 +98,7 @@
}); });
testing.eventually(() => { testing.onload(() => {
results.forEach((r) => { results.forEach((r) => {
testing.expectEqual(true, r); testing.expectEqual(true, r);
}); });

View File

@@ -236,9 +236,11 @@
{ {
const audio = document.createElement('audio'); const audio = document.createElement('audio');
testing.expectEqual('', audio.src); testing.expectEqual('', audio.src);
testing.expectEqual('', audio.currentSrc);
audio.src = 'test.mp3'; audio.src = 'test.mp3';
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src); testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.src);
testing.expectEqual(testing.BASE_URL + 'element/html/test.mp3', audio.currentSrc);
} }
</script> </script>

View File

@@ -8,14 +8,14 @@
script1.async = false; script1.async = false;
script1.src = "dynamic1.js"; script1.src = "dynamic1.js";
document.getElementsByTagName('head')[0].appendChild(script1); document.getElementsByTagName('head')[0].appendChild(script1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, loaded1); testing.expectEqual(1, loaded1);
}); });
</script> </script>
<script id=no_double_execute> <script id=no_double_execute>
document.getElementsByTagName('head')[0].appendChild(script1); document.getElementsByTagName('head')[0].appendChild(script1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, loaded1); testing.expectEqual(1, loaded1);
}); });
</script> </script>
@@ -25,7 +25,7 @@
const script2a = document.createElement('script'); const script2a = document.createElement('script');
script2a.src = "dynamic2.js"; script2a.src = "dynamic2.js";
document.getElementsByTagName('head')[0].appendChild(script2a); document.getElementsByTagName('head')[0].appendChild(script2a);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(2, loaded2); testing.expectEqual(2, loaded2);
}); });
</script> </script>
@@ -38,7 +38,7 @@
</script> </script>
<script id=src_after_append> <script id=src_after_append>
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(2, loaded2); testing.expectEqual(2, loaded2);
}); });
</script> </script>

View File

@@ -48,7 +48,7 @@
s6.type = 'module'; s6.type = 'module';
s6.textContent = 'window.module_executed = true;'; s6.textContent = 'window.module_executed = true;';
document.head.appendChild(s6); document.head.appendChild(s6);
testing.eventually(() => { testing.onload(() => {
testing.expectTrue(window.module_executed); testing.expectTrue(window.module_executed);
}); });
</script> </script>

View File

@@ -21,7 +21,7 @@
testing.expectEqual(testing.BASE_URL + 'element/html/script/empty.js', s.src); testing.expectEqual(testing.BASE_URL + 'element/html/script/empty.js', s.src);
document.head.appendChild(s); document.head.appendChild(s);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, dom_load); testing.expectEqual(true, dom_load);
testing.expectEqual(true, attribute_load); testing.expectEqual(true, attribute_load);
}); });

View File

@@ -427,7 +427,7 @@
div.setAttribute('slot', 'content'); div.setAttribute('slot', 'content');
host.appendChild(div); host.appendChild(div);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls); testing.expectEqual(1, calls);
}); });
} }
@@ -455,7 +455,7 @@
div.setAttribute('slot', 'other'); div.setAttribute('slot', 'other');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls); testing.expectEqual(1, calls);
}); });
} }
@@ -483,7 +483,7 @@
div.remove(); div.remove();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls); testing.expectEqual(1, calls);
}); });
} }
@@ -511,7 +511,7 @@
div.slot = 'other'; div.slot = 'other';
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls); testing.expectEqual(1, calls);
}); });
} }

View File

@@ -128,6 +128,20 @@
}); });
}); });
testing.eventually(() => testing.expectEqual(true, result)); testing.onload(() => testing.expectEqual(true, result));
}
</script>
<script id="style-tag-content-parsing">
{
const style = document.createElement("style");
style.textContent = '.content-test { padding: 5px; }';
document.head.appendChild(style);
const sheet = style.sheet;
testing.expectTrue(sheet instanceof CSSStyleSheet);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.content-test', sheet.cssRules[0].selectorText);
testing.expectEqual('5px', sheet.cssRules[0].style.padding);
} }
</script> </script>

View File

@@ -256,7 +256,7 @@
textarea.select(); textarea.select();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, eventCount); testing.expectEqual(1, eventCount);
testing.expectEqual('select', lastEvent.type); testing.expectEqual('select', lastEvent.type);
testing.expectEqual(textarea, lastEvent.target); testing.expectEqual(textarea, lastEvent.target);
@@ -295,7 +295,7 @@
}); });
textarea.setSelectionRange(1, 4); textarea.setSelectionRange(1, 4);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(5, eventCount); testing.expectEqual(5, eventCount);
testing.expectEqual('selectionchange', lastEvent.type); testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(textarea, lastEvent.target); testing.expectEqual(textarea, lastEvent.target);

View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<head>
<title>element.replaceChildren Tests</title>
</head>
<body>
<div id="test">Original content</div>
</body>
<script id=error_replace_with_self>
{
// Test that element.replaceChildren(element) throws HierarchyRequestError
const doc = document.implementation.createHTMLDocument("title");
testing.expectError('HierarchyRequest', () => {
doc.body.replaceChildren(doc.body);
});
}
</script>
<script id=error_replace_with_ancestor>
{
// Test that replacing with an ancestor throws HierarchyRequestError
const doc = document.implementation.createHTMLDocument("title");
const child = doc.createElement('div');
doc.body.appendChild(child);
testing.expectError('HierarchyRequest', () => {
child.replaceChildren(doc.body);
});
}
</script>
<script id=replace_children_basic>
{
// Test basic element.replaceChildren
const doc = document.implementation.createHTMLDocument("title");
const child1 = doc.createElement('div');
const child2 = doc.createElement('span');
doc.body.appendChild(child1);
doc.body.replaceChildren(child2);
testing.expectEqual(1, doc.body.childNodes.length);
testing.expectEqual(child2, doc.body.firstChild);
testing.expectEqual(null, child1.parentNode);
}
</script>
<script id=replace_children_empty>
{
// Test element.replaceChildren with no arguments removes all children
const doc = document.implementation.createHTMLDocument("title");
doc.body.appendChild(doc.createElement('div'));
doc.body.appendChild(doc.createElement('span'));
doc.body.replaceChildren();
testing.expectEqual(0, doc.body.childNodes.length);
}
</script>
<script id=replace_children_fragment>
{
// Test element.replaceChildren with DocumentFragment
const doc = document.implementation.createHTMLDocument("title");
const frag = doc.createDocumentFragment();
frag.appendChild(doc.createElement('div'));
frag.appendChild(doc.createElement('span'));
doc.body.replaceChildren(frag);
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('DIV', doc.body.firstChild.tagName);
testing.expectEqual('SPAN', doc.body.lastChild.tagName);
testing.expectEqual(0, frag.childNodes.length);
}
</script>
<script id=error_fragment_replace_with_self>
{
// Test that replacing with a fragment containing self throws
const doc = document.implementation.createHTMLDocument("title");
const frag = doc.createDocumentFragment();
const child = doc.createElement('div');
frag.appendChild(child);
testing.expectError('HierarchyRequest', () => {
child.replaceChildren(frag);
});
}
</script>
<script id=replace_children_text>
{
// Test element.replaceChildren with text
const doc = document.implementation.createHTMLDocument("title");
doc.body.appendChild(doc.createElement('div'));
doc.body.replaceChildren('Hello', 'World');
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('Hello', doc.body.firstChild.textContent);
testing.expectEqual('World', doc.body.lastChild.textContent);
}
</script>
<script id=replace_children_mixed>
{
// Test element.replaceChildren with mixed nodes and text
const doc = document.implementation.createHTMLDocument("title");
const span = doc.createElement('span');
span.textContent = 'middle';
doc.body.replaceChildren('start', span, 'end');
testing.expectEqual(3, doc.body.childNodes.length);
testing.expectEqual('start', doc.body.childNodes[0].textContent);
testing.expectEqual('SPAN', doc.body.childNodes[1].tagName);
testing.expectEqual('end', doc.body.childNodes[2].textContent);
}
</script>
<script id=replace_children_reparents>
{
// Test that replaceChildren properly reparents nodes from another parent
const doc = document.implementation.createHTMLDocument("title");
const div1 = doc.createElement('div');
const div2 = doc.createElement('div');
const child = doc.createElement('span');
div1.appendChild(child);
testing.expectEqual(div1, child.parentNode);
div2.replaceChildren(child);
testing.expectEqual(div2, child.parentNode);
testing.expectEqual(0, div1.childNodes.length);
}
</script>

View File

@@ -242,7 +242,7 @@
<script id=abortsignal_timeout> <script id=abortsignal_timeout>
var s3 = AbortSignal.timeout(10); var s3 = AbortSignal.timeout(10);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, s3.aborted); testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason); testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => { testing.expectError('Error: TimeoutError', () => {

View File

@@ -61,7 +61,7 @@
window.postMessage('test data', '*'); window.postMessage('test data', '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('test data', receivedEvent.data); testing.expectEqual('test data', receivedEvent.data);
testing.expectEqual(window, receivedEvent.source); testing.expectEqual(window, receivedEvent.source);
testing.expectEqual('message', receivedEvent.type); testing.expectEqual('message', receivedEvent.type);
@@ -81,7 +81,7 @@
const testObj = { type: 'test', value: 123, nested: { key: 'value' } }; const testObj = { type: 'test', value: 123, nested: { key: 'value' } };
window.postMessage(testObj, '*'); window.postMessage(testObj, '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(testObj, receivedData); testing.expectEqual(testObj, receivedData);
}); });
} }
@@ -111,7 +111,7 @@
window.postMessage(42, '*'); window.postMessage(42, '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(42, received); testing.expectEqual(42, received);
}); });
} }
@@ -129,7 +129,7 @@
const arr = [1, 2, 3, 'test']; const arr = [1, 2, 3, 'test'];
window.postMessage(arr, '*'); window.postMessage(arr, '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(arr, received); testing.expectEqual(arr, received);
}); });
} }
@@ -146,7 +146,7 @@
window.postMessage(null, '*'); window.postMessage(null, '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(null, received); testing.expectEqual(null, received);
}); });
} }
@@ -163,7 +163,7 @@
window.postMessage('test', '*'); window.postMessage('test', '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('http://127.0.0.1:9582', receivedOrigin); testing.expectEqual('http://127.0.0.1:9582', receivedOrigin);
}); });
} }

View File

@@ -12,7 +12,7 @@
window.postMessage('trigger', '*'); window.postMessage('trigger', '*');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(2, count); testing.expectEqual(2, count);
}); });
} }

View File

@@ -28,7 +28,7 @@
$('#f2').src = 'support/sub2.html'; $('#f2').src = 'support/sub2.html';
testing.expectEqual(true, true); testing.expectEqual(true, true);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(undefined, window[20]); testing.expectEqual(undefined, window[20]);
testing.expectEqual(window, window[1].top); testing.expectEqual(window, window[1].top);
@@ -84,7 +84,7 @@
f3.src = 'invalid'; // still fires load! f3.src = 'invalid'; // still fires load!
document.documentElement.appendChild(f3); document.documentElement.appendChild(f3);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('f1_onload_loaded', window.f1_onload); testing.expectEqual('f1_onload_loaded', window.f1_onload);
testing.expectEqual(true, f3_load_event); testing.expectEqual(true, f3_load_event);
}); });
@@ -98,7 +98,7 @@
f4.src = "about:blank"; f4.src = "about:blank";
document.documentElement.appendChild(f4); document.documentElement.appendChild(f4);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual("<html><head></head><body></body></html>", f4.contentDocument.documentElement.outerHTML); testing.expectEqual("<html><head></head><body></body></html>", f4.contentDocument.documentElement.outerHTML);
}); });
} }
@@ -112,35 +112,47 @@
document.documentElement.appendChild(f5); document.documentElement.appendChild(f5);
f5.src = "about:blank"; f5.src = "about:blank";
testing.eventually(() => { testing.onload(() => {
testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML); testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML);
}); });
} }
</script> </script>
<script id=link_click> <script id=link_click type=module>
testing.async(async (restore) => { const state = await testing.async();
await new Promise((resolve) => {
let count = 0; let count = 0;
let f6 = document.createElement('iframe'); let f6 = document.createElement('iframe');
f6.id = 'f6'; f6.id = 'f6';
f6.addEventListener('load', () => { f6.addEventListener('load', () => {
if (++count == 2) { if (++count == 2) {
resolve(); state.resolve();
return; return;
} }
f6.contentDocument.querySelector('#link').click(); f6.contentDocument.querySelector('#link').click();
}); });
f6.src = "support/with_link.html";
document.documentElement.appendChild(f6); f6.src = 'support/with_link.html';
}); document.documentElement.appendChild(f6);
restore();
await state.done(() => {
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML); testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
}); });
</script> </script>
<script id=about_blank_nav>
{
let i = document.createElement('iframe');
document.documentElement.appendChild(i);
i.contentWindow.location.href = 'support/page.html';
testing.onload(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', i.contentDocument.documentElement.outerHTML);
});
}
</script>
<script id=count> <script id=count>
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(8, window.length); testing.expectEqual(9, window.length);
}); });
</script> </script>

View File

@@ -7,7 +7,6 @@
{ {
let reply = null; let reply = null;
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data; reply = e.data;
}); });
@@ -17,7 +16,7 @@
iframe.contentWindow.postMessage('ping', '*'); iframe.contentWindow.postMessage('ping', '*');
}); });
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('pong', reply.data); testing.expectEqual('pong', reply.data);
testing.expectEqual(testing.ORIGIN, reply.origin); testing.expectEqual(testing.ORIGIN, reply.origin);
}); });

View File

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

View File

@@ -5,7 +5,7 @@
<a id=l1 target=f1 href=support/page.html></a> <a id=l1 target=f1 href=support/page.html></a>
<script id=anchor> <script id=anchor>
$('#l1').click(); $('#l1').click();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML); testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML);
}); });
</script> </script>
@@ -21,7 +21,7 @@
form.action = 'support/page.html'; form.action = 'support/page.html';
form.submit(); form.submit();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', frame2.contentDocument.documentElement.outerHTML); testing.expectEqual('<html><head></head><body>a-page\n</body></html>', frame2.contentDocument.documentElement.outerHTML);
}); });
} }
@@ -35,7 +35,7 @@
<script id=formtarget> <script id=formtarget>
{ {
$('#submit1').click(); $('#submit1').click();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#f3').contentDocument.documentElement.outerHTML); testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#f3').contentDocument.documentElement.outerHTML);
}); });
} }

View File

@@ -8,7 +8,7 @@
// If support/history.html has a failed assertion, it'll log the error and // If support/history.html has a failed assertion, it'll log the error and
// stop the script. If it succeeds, it'll set support_history_completed // stop the script. If it succeeds, it'll set support_history_completed
// which we can use here to assume everything passed. // which we can use here to assume everything passed.
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, window.support_history_completed); testing.expectEqual(true, window.support_history_completed);
testing.expectEqual(true, window.support_history_popstateEventFired); testing.expectEqual(true, window.support_history_popstateEventFired);
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState); testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);

View File

@@ -14,7 +14,7 @@
observer.observe(target); observer.observe(target);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, callbackCalled); testing.expectEqual(true, callbackCalled);
testing.expectEqual(1, entries.length); testing.expectEqual(1, entries.length);
@@ -41,7 +41,7 @@
count += 1; count += 1;
}).observe(div); }).observe(div);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(0, count); testing.expectEqual(0, count);
}); });
} }
@@ -56,7 +56,7 @@
}).observe(div1); }).observe(div1);
div2.appendChild(div1); div2.appendChild(div1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, count); testing.expectEqual(1, count);
}); });
} }

View File

@@ -12,7 +12,7 @@
observer.observe(target); observer.observe(target);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, callCount); testing.expectEqual(1, callCount);
observer.disconnect(); observer.disconnect();
@@ -22,7 +22,7 @@
const observer2 = new IntersectionObserver(() => {}); const observer2 = new IntersectionObserver(() => {});
observer2.observe(target); observer2.observe(target);
testing.eventually(() => { testing.onload(() => {
observer2.disconnect(); observer2.disconnect();
testing.expectEqual(1, callCount); testing.expectEqual(1, callCount);
}); });

View File

@@ -19,7 +19,7 @@
observer.observe(target1); observer.observe(target1);
observer.observe(target2); observer.observe(target2);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(2, entryCount); testing.expectEqual(2, entryCount);
testing.expectTrue(seenTargets.has(target1)); testing.expectTrue(seenTargets.has(target1));
testing.expectTrue(seenTargets.has(target2)); testing.expectTrue(seenTargets.has(target2));

View File

@@ -20,7 +20,7 @@
observer.unobserve(target1); observer.unobserve(target1);
observer.observe(target2); observer.observe(target2);
testing.eventually(() => { testing.onload(() => {
// Should only see target2, not target1 // Should only see target2, not target1
testing.expectEqual(1, seenTargets.length); testing.expectEqual(1, seenTargets.length);
testing.expectEqual(target2, seenTargets[0]); testing.expectEqual(target2, seenTargets[0]);

View File

@@ -12,5 +12,5 @@
let replaced = false; let replaced = false;
css.replace('body{}').then(() => replaced = true); css.replace('body{}').then(() => replaced = true);
testing.eventually(() => testing.expectEqual(true, replaced)); testing.onload(() => testing.expectEqual(true, replaced));
</script> </script>

View File

@@ -11,5 +11,5 @@
cb.push('finished'); cb.push('finished');
cb.push(x == a1); cb.push(x == a1);
}); });
testing.eventually(() => testing.expectEqual(['finished', true], cb)); testing.onload(() => testing.expectEqual(['finished', true], cb));
</script> </script>

View File

@@ -11,7 +11,7 @@
count += 1; count += 1;
}).observe(div); }).observe(div);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(0, count); testing.expectEqual(0, count);
}); });
} }
@@ -27,7 +27,7 @@
}).observe(div1); }).observe(div1);
div2.appendChild(div1); div2.appendChild(div1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, count); testing.expectEqual(1, count);
}); });
} }
@@ -51,7 +51,7 @@
observer.observe(div1); observer.observe(div1);
testing.expectEqual(0, count); testing.expectEqual(0, count);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, count); testing.expectEqual(1, count);
}); });
} }
@@ -75,7 +75,7 @@
testing.expectEqual(0, count); testing.expectEqual(0, count);
observer.unobserve(div1); observer.unobserve(div1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(0, count); testing.expectEqual(0, count);
}); });
} }
@@ -100,7 +100,7 @@
testing.expectEqual(0, count); testing.expectEqual(0, count);
observer.disconnect(); observer.disconnect();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(0, count); testing.expectEqual(0, count);
}); });
} }
@@ -117,7 +117,7 @@
document.body.appendChild(div1); document.body.appendChild(div1);
new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1); new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(125, entry.boundingClientRect.x); testing.expectEqual(125, entry.boundingClientRect.x);
testing.expectEqual(1, entry.intersectionRatio); testing.expectEqual(1, entry.intersectionRatio);
testing.expectEqual(125, entry.intersectionRect.x); testing.expectEqual(125, entry.intersectionRect.x);
@@ -150,7 +150,7 @@
observer.observe(div); observer.observe(div);
capture.push('post-observe'); capture.push('post-observe');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual([ testing.expectEqual([
'pre-append', 'pre-append',
'post-append', 'post-append',

View File

@@ -31,7 +31,7 @@
<script id=timeout> <script id=timeout>
var s3 = AbortSignal.timeout(10); var s3 = AbortSignal.timeout(10);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, s3.aborted); testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason); testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => { testing.expectError('Error: TimeoutError', () => {

View File

@@ -28,7 +28,7 @@
popstateEventState = event.state; popstateEventState = event.state;
}); });
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, popstateEventFired); testing.expectEqual(true, popstateEventFired);
testing.expectEqual(state, popstateEventState); testing.expectEqual(state, popstateEventState);
}) })

View File

@@ -14,7 +14,7 @@
popstateEventState = event.state; popstateEventState = event.state;
}; };
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, popstateEventFired); testing.expectEqual(true, popstateEventFired);
testing.expectEqual(state, popstateEventState); testing.expectEqual(state, popstateEventState);
}) })

View File

@@ -24,5 +24,5 @@
// inline script should ignore defer and async attributes. If we don't do // inline script should ignore defer and async attributes. If we don't do
// this correctly, we'd end up in an infinite loop // this correctly, we'd end up in an infinite loop
// https://github.com/lightpanda-io/browser/issues/1014 // https://github.com/lightpanda-io/browser/issues/1014
testing.eventually(() => testing.expectEqual(2, dyn1_loaded)); testing.onload(() => testing.expectEqual(2, dyn1_loaded));
</script> </script>

View File

@@ -94,7 +94,7 @@
lp.appendChild(div); lp.appendChild(div);
testing.expectEqual(slot, div.assignedSlot); testing.expectEqual(slot, div.assignedSlot);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls) testing.expectEqual(1, calls)
}); });
} }
@@ -113,7 +113,7 @@
const div = $('#s2'); const div = $('#s2');
div.removeAttribute('slot'); div.removeAttribute('slot');
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls) testing.expectEqual(1, calls)
}); });
} }
@@ -132,7 +132,7 @@
const div = $('#s3'); const div = $('#s3');
div.slot = 'other'; div.slot = 'other';
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls) testing.expectEqual(1, calls)
}); });
} }
@@ -154,7 +154,7 @@
div.slot = 'other'; div.slot = 'other';
lp.appendChild(div); lp.appendChild(div);
div.slot = 'slot-1' div.slot = 'slot-1'
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls) testing.expectEqual(1, calls)
}); });
} }
@@ -172,7 +172,7 @@
}); });
$('#s5').remove(); $('#s5').remove();
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, calls) testing.expectEqual(1, calls)
}); });
} }

View File

@@ -16,7 +16,7 @@
start = timestamp; start = timestamp;
} }
requestAnimationFrame(step); requestAnimationFrame(step);
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, start > 0) testing.expectEqual(true, start > 0)
}); });
@@ -24,23 +24,23 @@
start = 0; start = 0;
}); });
cancelAnimationFrame(request_id); cancelAnimationFrame(request_id);
testing.eventually(() => testing.expectEqual(true, start > 0)); testing.onload(() => testing.expectEqual(true, start > 0));
</script> </script>
<script id=setTimeout> <script id=setTimeout>
let longCall = false; let longCall = false;
window.setTimeout(() => {longCall = true}, 5001); window.setTimeout(() => {longCall = true}, 5001);
testing.eventually(() => testing.expectEqual(false, longCall)); testing.onload(() => testing.expectEqual(false, longCall));
let wst1 = 0; let wst1 = 0;
window.setTimeout(() => {wst1 += 1}, 1); window.setTimeout(() => {wst1 += 1}, 1);
testing.eventually(() => testing.expectEqual(1, wst1)); testing.onload(() => testing.expectEqual(1, wst1));
let wst2 = 1; let wst2 = 1;
window.setTimeout((a, b) => { window.setTimeout((a, b) => {
wst2 = a + b; wst2 = a + b;
}, 1, 2, 3); }, 1, 2, 3);
testing.eventually(() => testing.expectEqual(5, wst2)); testing.onload(() => testing.expectEqual(5, wst2));
</script> </script>
<script id=eventTarget> <script id=eventTarget>
@@ -70,7 +70,7 @@
<script id=queueMicroTask> <script id=queueMicroTask>
var qm = false; var qm = false;
window.queueMicrotask(() => {qm = true }); window.queueMicrotask(() => {qm = true });
testing.eventually(() => testing.expectEqual(true, qm)); testing.onload(() => testing.expectEqual(true, qm));
</script> </script>
<script id=DOMContentLoaded> <script id=DOMContentLoaded>
@@ -79,7 +79,7 @@
window.addEventListener('DOMContentLoaded', (e) => { window.addEventListener('DOMContentLoaded', (e) => {
dcl = e.target == document; dcl = e.target == document;
}); });
testing.eventually(() => testing.expectEqual(true, dcl)); testing.onload(() => testing.expectEqual(true, dcl));
</script> </script>
<script id=window.onload> <script id=window.onload>
@@ -97,7 +97,7 @@
window.onload = callback; window.onload = callback;
testing.expectEqual(callback, window.onload); testing.expectEqual(callback, window.onload);
testing.eventually(() => testing.expectEqual(true, isDocumentTarget)); testing.onload(() => testing.expectEqual(true, isDocumentTarget));
</script> </script>
<script id=reportError> <script id=reportError>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body>
<div id="existing">Already here</div>
<script>
setTimeout(function() {
var el = document.createElement("div");
el.id = "delayed";
el.textContent = "Appeared after delay";
document.body.appendChild(el);
}, 20);
</script>
</body>
</html>

View File

@@ -734,3 +734,101 @@
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries); testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
} }
</script> </script>
<script id=formDataEventFires>
{
// formdata event fires on the form when FormData is constructed with a form
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'field';
input.value = 'hello';
form.appendChild(input);
let eventFired = false;
let receivedFormData = null;
form.addEventListener('formdata', (e) => {
eventFired = true;
receivedFormData = e.formData;
});
const fd = new FormData(form);
testing.expectEqual(true, eventFired);
testing.expectEqual(fd, receivedFormData);
}
</script>
<script id=formDataEventNotFiredWithoutForm>
{
// formdata event should NOT fire when FormData is constructed without a form
const fd = new FormData();
fd.append('a', '1');
testing.expectEqual('1', fd.get('a'));
}
</script>
<script id=formDataEventBubbles>
{
// formdata event should bubble
const container = document.createElement('div');
const form = document.createElement('form');
container.appendChild(form);
document.body.appendChild(container);
const input = document.createElement('input');
input.name = 'x';
input.value = '1';
form.appendChild(input);
let bubbled = false;
container.addEventListener('formdata', () => {
bubbled = true;
});
const fd = new FormData(form);
testing.expectEqual(true, bubbled);
document.body.removeChild(container);
}
</script>
<script id=formDataEventNotCancelable>
{
// formdata event should not be cancelable
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'key';
input.value = 'val';
form.appendChild(input);
let cancelable = null;
form.addEventListener('formdata', (e) => {
cancelable = e.cancelable;
});
const fd = new FormData(form);
testing.expectEqual(false, cancelable);
}
</script>
<script id=formDataEventModifyFormData>
{
// Listeners can modify formData during the event
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'original';
input.value = 'data';
form.appendChild(input);
form.addEventListener('formdata', (e) => {
e.formData.append('added', 'by-listener');
});
const fd = new FormData(form);
testing.expectEqual('data', fd.get('original'));
testing.expectEqual('by-listener', fd.get('added'));
}
</script>

View File

@@ -28,3 +28,40 @@
d1.appendChild(p2); d1.appendChild(p2);
assertChildren(['p1', 'p2'], d1); assertChildren(['p1', 'p2'], d1);
</script> </script>
<div id=d3></div>
<script id=appendChild_fragment_mutation>
// Test that appendChild with DocumentFragment handles synchronous callbacks
// (like custom element connectedCallback) that modify the fragment during iteration.
// This reproduces a bug where the iterator captures "next" node pointers
// before processing, but callbacks can remove those nodes from the fragment.
const d3 = $('#d3');
const fragment = document.createDocumentFragment();
// Create custom element whose connectedCallback modifies the fragment
let bElement = null;
class ModifyingElement extends HTMLElement {
connectedCallback() {
// When this element is connected, remove 'b' from the fragment
if (bElement && bElement.parentNode === fragment) {
fragment.removeChild(bElement);
}
}
}
customElements.define('modifying-element', ModifyingElement);
const a = document.createElement('modifying-element');
a.id = 'a';
const b = document.createElement('span');
b.id = 'b';
bElement = b;
fragment.appendChild(a);
fragment.appendChild(b);
// This should not crash - appendChild should handle the modification gracefully
d3.appendChild(fragment);
// 'a' should be in d3, 'b' was removed by connectedCallback and is now detached
assertChildren(['a'], d3);
testing.expectEqual(null, b.parentNode);
</script>

View File

@@ -12,7 +12,7 @@
document.body.appendChild(iframe); document.body.appendChild(iframe);
iframe.src = blob_url; iframe.src = blob_url;
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('Hello Blob', iframe.contentDocument.getElementById('test').textContent); testing.expectEqual('Hello Blob', iframe.contentDocument.getElementById('test').textContent);
}); });
} }
@@ -33,7 +33,7 @@
document.body.appendChild(iframe2); document.body.appendChild(iframe2);
iframe2.src = url2; iframe2.src = url2;
testing.eventually(() => { testing.onload(() => {
testing.expectEqual('First', iframe1.contentDocument.body.textContent); testing.expectEqual('First', iframe1.contentDocument.body.textContent);
testing.expectEqual('Second', iframe2.contentDocument.body.textContent); testing.expectEqual('Second', iframe2.contentDocument.body.textContent);
}); });

View File

@@ -9,7 +9,7 @@
call1 = true; call1 = true;
}); });
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(document, ex1.target); testing.expectEqual(document, ex1.target);
testing.expectEqual('DOMContentLoaded', ex1.type); testing.expectEqual('DOMContentLoaded', ex1.type);
testing.expectEqual(true, call1); testing.expectEqual(true, call1);

View File

@@ -86,7 +86,7 @@
// With buffered: true, existing marks should be delivered // With buffered: true, existing marks should be delivered
observer.observe({ type: "mark", buffered: true }); observer.observe({ type: "mark", buffered: true });
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(true, receivedEntries !== null); testing.expectEqual(true, receivedEntries !== null);
testing.expectEqual(2, receivedEntries.length); testing.expectEqual(2, receivedEntries.length);
testing.expectEqual("early1", receivedEntries[0].name); testing.expectEqual("early1", receivedEntries[0].name);

View File

@@ -582,7 +582,7 @@
document.removeEventListener('selectionchange', listener); document.removeEventListener('selectionchange', listener);
textNode.textContent = "The quick brown fox"; textNode.textContent = "The quick brown fox";
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(14, eventCount); testing.expectEqual(14, eventCount);
testing.expectEqual('selectionchange', lastEvent.type); testing.expectEqual('selectionchange', lastEvent.type);
testing.expectEqual(document, lastEvent.target); testing.expectEqual(document, lastEvent.target);

View File

@@ -4,6 +4,7 @@
let eventuallies = []; let eventuallies = [];
let async_capture = null; let async_capture = null;
let current_script_id = null; let current_script_id = null;
let async_pending = 0;
function expectTrue(actual) { function expectTrue(actual) {
expectEqual(true, actual); expectEqual(true, actual);
@@ -52,10 +53,10 @@
throw new Error('no error'); throw new Error('no error');
} }
function eventually(cb) { function onload(cb) {
const script_id = _currentScriptId(); const script_id = _currentScriptId();
if (!script_id) { if (!script_id) {
throw new Error('testing.eventually called outside of a script'); throw new Error('testing.onload called outside of a script');
} }
eventuallies.push({ eventuallies.push({
callback: cb, callback: cb,
@@ -64,6 +65,25 @@
} }
async function async(cb) { async function async(cb) {
if (cb == undefined) {
let resolve = null
const promise = new Promise((r) => { resolve = r});
async_pending += 1;
return {
promise: promise,
resolve: resolve,
capture: {script_id: document.currentScript.id, stack: new Error().stack},
done: async function(cb) {
await this.promise;
async_pending -= 1;
async_capture = this.capture;
cb();
async_capture = false;
}
};
}
let capture = {script_id: document.currentScript.id, stack: new Error().stack}; let capture = {script_id: document.currentScript.id, stack: new Error().stack};
await cb(() => { async_capture = capture; }); await cb(() => { async_capture = capture; });
async_capture = null; async_capture = null;
@@ -74,6 +94,10 @@
throw new Error('Failed'); throw new Error('Failed');
} }
if (async_pending > 0) {
return false;
}
for (let e of eventuallies) { for (let e of eventuallies) {
current_script_id = e.script_id; current_script_id = e.script_id;
e.callback(); e.callback();
@@ -97,6 +121,8 @@
throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`); throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
} }
} }
return true;
} }
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/"); const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
@@ -110,7 +136,7 @@
expectEqual: expectEqual, expectEqual: expectEqual,
expectError: expectError, expectError: expectError,
withError: withError, withError: withError,
eventually: eventually, onload: onload,
IS_TEST_RUNNER: IS_TEST_RUNNER, IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1', HOST: '127.0.0.1',
ORIGIN: 'http://127.0.0.1:9582', ORIGIN: 'http://127.0.0.1:9582',

View File

@@ -591,6 +591,35 @@
testing.expectEqual('/new/path', url.pathname); testing.expectEqual('/new/path', url.pathname);
} }
// Pathname setter must percent-encode spaces and special characters
{
const url = new URL('http://a/');
url.pathname = 'c d';
testing.expectEqual('http://a/c%20d', url.href);
}
{
const url = new URL('https://example.com/path');
url.pathname = '/path with spaces/file name';
testing.expectEqual('https://example.com/path%20with%20spaces/file%20name', url.href);
testing.expectEqual('/path%20with%20spaces/file%20name', url.pathname);
}
// Already-encoded sequences should not be double-encoded
{
const url = new URL('https://example.com/path');
url.pathname = '/already%20encoded';
testing.expectEqual('https://example.com/already%20encoded', url.href);
}
// This is the exact check the URL polyfill uses to decide if native URL is sufficient
{
const url = new URL('b', 'http://a');
url.pathname = 'c d';
testing.expectEqual('http://a/c%20d', url.href);
testing.expectEqual(true, !!url.searchParams);
}
{ {
const url = new URL('https://example.com/path'); const url = new URL('https://example.com/path');
url.search = '?a=b'; url.search = '?a=b';
@@ -656,6 +685,20 @@
testing.expectEqual('', url.hash); testing.expectEqual('', url.hash);
} }
{
const url = new URL('https://example.com/path');
url.hash = '#a b';
testing.expectEqual('https://example.com/path#a%20b', url.href);
testing.expectEqual('#a%20b', url.hash);
}
{
const url = new URL('https://example.com/path');
url.hash = 'a b';
testing.expectEqual('https://example.com/path#a%20b', url.href);
testing.expectEqual('#a%20b', url.hash);
}
{ {
const url = new URL('https://example.com/path?a=b'); const url = new URL('https://example.com/path?a=b');
url.search = ''; url.search = '';
@@ -673,6 +716,20 @@
testing.expectEqual(null, url.searchParams.get('a')); testing.expectEqual(null, url.searchParams.get('a'));
} }
{
const url = new URL('https://example.com/path?a=b');
const sp = url.searchParams;
testing.expectEqual('b', sp.get('a'));
url.search = 'c=d b';
testing.expectEqual('d b', url.searchParams.get('c'));
testing.expectEqual(null, url.searchParams.get('a'));
url.search = 'c d=d b';
testing.expectEqual('d b', url.searchParams.get('c d'));
testing.expectEqual(null, url.searchParams.get('c'));
}
{ {
const url = new URL('https://example.com/path?a=b'); const url = new URL('https://example.com/path?a=b');
const sp = url.searchParams; const sp = url.searchParams;
@@ -798,3 +855,19 @@
testing.expectEqual(true, url2.startsWith('blob:')); testing.expectEqual(true, url2.startsWith('blob:'));
} }
</script> </script>
<script id="about:blank">
{
const url = new URL('about:blank');
testing.expectEqual('about:blank', url.href);
testing.expectEqual('null', url.origin);
testing.expectEqual('about:', url.protocol);
testing.expectEqual('blank', url.pathname);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
testing.expectEqual('', url.host);
testing.expectEqual('', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('', url.search);
}
</script>

View File

@@ -10,7 +10,7 @@
testing.expectEqual(window, e.currentTarget); testing.expectEqual(window, e.currentTarget);
} }
testing.eventually(() => { testing.onload(() => {
testing.expectEqual(1, called); testing.expectEqual(1, called);
}); });
</script> </script>

View File

@@ -7,7 +7,7 @@
// Verify: handler fires, "event" parameter is a proper Event, and handler is a function. // Verify: handler fires, "event" parameter is a proper Event, and handler is a function.
let loadEvent = null; let loadEvent = null;
testing.eventually(() => { testing.onload(() => {
testing.expectEqual("function", typeof document.body.onload); testing.expectEqual("function", typeof document.body.onload);
testing.expectTrue(loadEvent instanceof Event); testing.expectTrue(loadEvent instanceof Event);
testing.expectEqual("load", loadEvent.type); testing.expectEqual("load", loadEvent.type);

View File

@@ -7,7 +7,7 @@
// Verify: handler fires exactly once, and body.onload reflects to window.onload. // Verify: handler fires exactly once, and body.onload reflects to window.onload.
let called = 0; let called = 0;
testing.eventually(() => { testing.onload(() => {
// The attribute handler should have fired exactly once. // The attribute handler should have fired exactly once.
testing.expectEqual(1, called); testing.expectEqual(1, called);

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe src=support/frame1.html></iframe>
<script id=post_message type=module>
const state = await testing.async();
{
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
{
let iframe2 = document.createElement('iframe');
iframe2.src = ALT_BASE + 'window/support/frame1.html';
document.documentElement.appendChild(iframe2);
}
{
let iframe3 = document.createElement('iframe');
iframe3.src = ALT_BASE + 'window/support/frame2.html';
document.documentElement.appendChild(iframe3);
}
let captures = [];
window.addEventListener('message', (e) => {
captures.push(e.data);
if (captures.length == 3) {
state.resolve();
}
});
await state.done(() => {
const expected_urls = [
testing.BASE_URL + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame2.html',
];
// No strong order guarantee for messaages, and we don't care about the order
// so long as it's the correct data.
testing.expectEqual(expected_urls.sort(), captures.map((c) => {return c.url}).sort());
captures.forEach((c) => {
if (c.url.includes(testing.BASE_URL)) {
testing.expectEqual(false, c.document_is_undefined);
} else {
testing.expectEqual(true, c.document_is_undefined);
}
});
});
}
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.parent.postMessage({
url: location.toString(),
document_is_undefined: window.parent.document === undefined,
}, '*')
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.top.postMessage({
url: location.toString(),
document_is_undefined: window.top.document === undefined,
}, '*')
</script>

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