1 Commits

Author SHA1 Message Date
Nitya Timalsina
129e8e8340 Restructure README with table of contents, benchmarks table, and expanded documentation
Add comprehensive table of contents, convert benchmark data to table format, expand use cases section with AI agents/scraping/testing examples, add architecture diagram reference, reorganize build/test sections with collapsible details, and include FAQ section. Improve formatting throughout with horizontal rules and better section hierarchy.
2026-03-20 05:59:34 -06:00
150 changed files with 2539 additions and 6240 deletions

View File

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

View File

@@ -27,7 +27,7 @@ jobs:
- uses: ./.github/actions/install
- name: zig build release
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
uses: actions/upload-artifact@v7
@@ -61,6 +61,6 @@ jobs:
- name: run end to end integration tests
run: |
./lightpanda serve --log-level error & echo $! > LPD.pid
./lightpanda serve --log_level error & echo $! > LPD.pid
go run integration/main.go
kill `cat LPD.pid`

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.3.7
ARG ZIG_V8=v0.3.4
ARG TARGETPLATFORM
RUN apt-get update -yq && \
@@ -53,7 +53,8 @@ RUN zig build -Doptimize=ReleaseFast \
# build release
RUN zig build -Doptimize=ReleaseFast \
-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
@@ -74,4 +75,4 @@ EXPOSE 9222/tcp
# 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).
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,3 +4,11 @@ License names used in this document are as per [SPDX License
List](https://spdx.org/licenses/).
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: build-v8-snapshot
@printf "\033[36mBuilding (release fast)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode
build-dev:
@printf "\033[36mBuilding (debug)...\033[0m\n"
@$(ZIG) build || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Run the server in release mode

369
README.md
View File

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

29
SUMMARY.md Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

101
build.zig
View File

@@ -17,37 +17,24 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
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;
}
};
const Build = std.Build;
pub fn build(b: *Build) !void {
const target = b.standardTargetOptions(.{});
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 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();
opts.addOption([]const u8, "version", b.fmt("{f}", .{version}));
opts.addOption([]const u8, "version", manifest.version);
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
opts.addOption(?[]const u8, "git_version", git_version orelse null);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
@@ -109,11 +96,6 @@ pub fn build(b: *Build) !void {
}
const run_step = b.step("run", "Run the app");
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);
}
{
@@ -719,50 +701,27 @@ fn buildCurl(
return lib;
}
/// Returns `MAJOR.MINOR.PATCH-dev` when `git describe` fails.
fn resolveVersion(b: *std.Build) std.SemanticVersion {
const version_string = b.option([]const u8, "version_string", "Override the version of this build");
if (version_string) |semver_string| {
return std.SemanticVersion.parse(semver_string) catch |err| {
std.debug.panic("Expected -Dversion-string={s} to be a semantic version: {}", .{ semver_string, err });
const Manifest = struct {
version: []const u8,
minimum_zig_version: []const u8,
fn init(b: *std.Build) Manifest {
const input = @embedFile("build.zig.zon");
var diagnostics: std.zon.parse.Diagnostics = .{};
defer diagnostics.deinit(b.allocator);
return std.zon.parse.fromSlice(Manifest, b.allocator, input, &diagnostics, .{
.free_on_error = true,
.ignore_unknown_fields = true,
}) catch |err| {
switch (err) {
error.OutOfMemory => @panic("OOM"),
error.ParseZon => {
std.debug.print("Parse diagnostics:\n{f}\n", .{diagnostics});
std.process.exit(1);
},
}
};
}
const pre_version = b.option([]const u8, "pre_version", "Override the pre version of this build");
const pre = blk: {
if (pre_version) |pre| {
break :blk pre;
}
break :blk lightpanda_version.pre;
};
// If it's a stable release (no pre or build metadata in build.zig.zon), use it as is
if (pre == null and lightpanda_version.build == null) return lightpanda_version;
// For dev/nightly versions, calculate the commit count and hash
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return lightpanda_version;
const commit_hash = std.mem.trim(u8, git_hash_raw, " \n\r");
const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return lightpanda_version;
const commit_count = std.mem.trim(u8, git_count_raw, " \n\r");
return .{
.major = lightpanda_version.major,
.minor = lightpanda_version.minor,
.patch = lightpanda_version.patch,
.pre = b.fmt("{s}.{s}", .{ 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,
.version = "1.0.0-dev",
.version = "0.0.0",
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -17,15 +17,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArenaPool = @This();
const IS_DEBUG = builtin.mode == .Debug;
allocator: Allocator,
retain_bytes: usize,
free_list_len: u16 = 0,
@@ -33,17 +30,10 @@ free_list: ?*Entry = null,
free_list_max: u16,
entry_pool: std.heap.MemoryPool(Entry),
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 {
next: ?*Entry,
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 {
@@ -52,26 +42,10 @@ pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) Arena
.free_list_max = free_list_max,
.retain_bytes = retain_bytes,
.entry_pool = .init(allocator),
._leak_track = if (IS_DEBUG) .empty else {},
};
}
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) {
std.debug.print("ArenaPool leak detected: '{s}' count={d}\n", .{ kv.key_ptr.*, 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;
while (entry) |e| {
entry = e.next;
@@ -80,21 +54,13 @@ pub fn deinit(self: *ArenaPool) void {
self.entry_pool.deinit();
}
pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator {
pub fn acquire(self: *ArenaPool) !Allocator {
self.mutex.lock();
defer self.mutex.unlock();
if (self.free_list) |entry| {
self.free_list = entry.next;
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();
}
@@ -102,16 +68,8 @@ pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !Allocator {
entry.* = .{
.next = null,
.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();
}
@@ -125,19 +83,6 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
self.mutex.lock();
defer self.mutex.unlock();
if (IS_DEBUG) {
if (self._leak_track.getPtr(entry.debug)) |count| {
count.* -= 1;
if (count.* < 0) {
std.debug.print("ArenaPool double-free detected: '{s}'\n", .{entry.debug});
@panic("ArenaPool: double-free detected");
}
} else {
std.debug.print("ArenaPool release of untracked arena: '{s}'\n", .{entry.debug});
@panic("ArenaPool: release of untracked arena");
}
}
const free_list_len = self.free_list_len;
if (free_list_len == self.free_list_max) {
arena.deinit();
@@ -155,18 +100,13 @@ pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
_ = 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;
test "arena pool - basic acquire and use" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire(.{ .debug = "test" });
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 64);
@memset(buf, 0xAB);
try testing.expectEqual(@as(u8, 0xAB), buf[0]);
@@ -178,14 +118,14 @@ test "arena pool - reuse entry after release" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc1 = try pool.acquire(.{ .debug = "test" });
const alloc1 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
pool.release(alloc1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
// The same entry should be returned from the free list.
const alloc2 = try pool.acquire(.{ .debug = "test" });
const alloc2 = try pool.acquire();
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
try testing.expectEqual(alloc1.ptr, alloc2.ptr);
@@ -196,9 +136,9 @@ test "arena pool - multiple concurrent arenas" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(.{ .debug = "test2" });
const a3 = try pool.acquire(.{ .debug = "test3" });
const a1 = try pool.acquire();
const a2 = try pool.acquire();
const a3 = try pool.acquire();
// All three must be distinct arenas.
try testing.expect(a1.ptr != a2.ptr);
@@ -221,8 +161,8 @@ test "arena pool - free list respects max limit" {
var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16);
defer pool.deinit();
const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(.{ .debug = "test2" });
const a1 = try pool.acquire();
const a2 = try pool.acquire();
pool.release(a1);
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
@@ -236,7 +176,7 @@ test "arena pool - reset clears memory without releasing" {
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
defer pool.deinit();
const alloc = try pool.acquire(.{ .debug = "test" });
const alloc = try pool.acquire();
const buf = try alloc.alloc(u8, 128);
@memset(buf, 0xFF);
@@ -260,8 +200,8 @@ test "arena pool - deinit with entries in free list" {
// detected by the test allocator).
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
const a1 = try pool.acquire(.{ .debug = "test1" });
const a2 = try pool.acquire(.{ .debug = "test2" });
const a1 = try pool.acquire();
const a2 = try pool.acquire();
_ = try a1.alloc(u8, 256);
_ = try a2.alloc(u8, 512);
pool.release(a1);

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/CDP.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig");
@@ -45,7 +45,7 @@ clients_pool: std.heap.MemoryPool(Client),
pub fn init(app: *App, address: net.Address) !*Server {
const allocator = app.allocator;
const json_version_response = try buildJSONVersionResponse(app);
const json_version_response = try buildJSONVersionResponse(allocator, address);
errdefer allocator.free(json_version_response);
const self = try allocator.create(Server);
@@ -212,7 +212,7 @@ pub const Client = struct {
http: *HttpClient,
ws: Net.WsConnection,
pub fn init(
fn init(
socket: posix.socket_t,
allocator: Allocator,
app: *App,
@@ -250,7 +250,7 @@ pub const Client = struct {
self.ws.shutdown();
}
pub fn deinit(self: *Client) void {
fn deinit(self: *Client) void {
switch (self.mode) {
.cdp => |*cdp| cdp.deinit(),
.http => {},
@@ -302,8 +302,15 @@ pub const Client = struct {
var ms_remaining = self.ws.timeout_ms;
while (true) {
const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
error.NoPage => {
switch (cdp.pageWait(ms_remaining)) {
.cdp_socket => {
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| {
log.err(.app, "http tick", .{ .err = err });
return;
@@ -317,18 +324,6 @@ pub const Client = struct {
}
last_message = milliTimestamp(.monotonic);
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 => {
const now = milliTimestamp(.monotonic);
@@ -461,7 +456,7 @@ pub const Client = struct {
fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self) };
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
}
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
@@ -489,17 +484,11 @@ pub const Client = struct {
// --------
fn buildJSONVersionResponse(
app: *const App,
allocator: Allocator,
address: net.Address,
) ![]const u8 {
const port = app.config.port();
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 });
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}";
const body_len = std.fmt.count(body_format, .{address});
// We send a Connection: Close (and actually close the connection)
// because chromedp (Go driver) sends a request to /json/version and then
@@ -513,22 +502,23 @@ fn buildJSONVersionResponse(
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
body_format;
return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
}
pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = @import("testing.zig");
const testing = std.testing;
test "server: buildJSONVersionResponse" {
const res = try buildJSONVersionResponse(testing.test_app);
defer testing.test_app.allocator.free(res);
const address = try net.Address.parseIp4("127.0.0.1", 9001);
const res = try buildJSONVersionResponse(testing.allocator, address);
defer testing.allocator.free(res);
try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++
try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}", res);
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res);
}
test "Client: http invalid request" {
@@ -536,7 +526,7 @@ test "Client: http invalid request" {
defer c.deinit();
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
try testing.expectEqual("HTTP/1.1 413 \r\n" ++
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 17\r\n\r\n" ++
"Request too large", res);
@@ -605,7 +595,7 @@ test "Client: http valid handshake" {
"Custom: Header-Value\r\n\r\n";
const res = try c.httpRequest(request);
try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
@@ -733,7 +723,7 @@ test "server: 404" {
defer c.deinit();
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
try testing.expectEqual("HTTP/1.1 404 \r\n" ++
try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++
"Connection: Close\r\n" ++
"Content-Length: 9\r\n\r\n" ++
"Not found", res);
@@ -745,7 +735,7 @@ test "server: get /json/version" {
"Content-Length: 48\r\n" ++
"Connection: Close\r\n" ++
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}";
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}";
{
// twice on the same connection
@@ -753,7 +743,7 @@ test "server: get /json/version" {
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqual(expected_response, res1);
try testing.expectEqualStrings(expected_response, res1);
}
{
@@ -762,7 +752,7 @@ test "server: get /json/version" {
defer c.deinit();
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
try testing.expectEqual(expected_response, res1);
try testing.expectEqualStrings(expected_response, res1);
}
}
@@ -780,7 +770,7 @@ fn assertHTTPError(
.{ expected_status, expected_body.len, expected_body },
);
try testing.expectEqual(expected_response, res);
try testing.expectEqualStrings(expected_response, res);
}
fn assertWebSocketError(close_code: u16, input: []const u8) !void {
@@ -924,7 +914,7 @@ const TestClient = struct {
"Custom: Header-Value\r\n\r\n";
const res = try self.httpRequest(request);
try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: upgrade\r\n" ++
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);

View File

@@ -848,28 +848,14 @@ fn processMessages(self: *Client) !bool {
}
}
// When the server sends "Connection: close" and closes the TLS
// connection without a close_notify alert, BoringSSL reports
// RecvError. If we already received valid HTTP headers, this is
// a normal end-of-body (the connection closure signals the end
// of the response per HTTP/1.1 when there is no Content-Length).
// We must check this before endTransfer, which may reset the
// easy handle.
const is_conn_close_recv = blk: {
const err = msg.err orelse break :blk false;
if (err != error.RecvError) break :blk false;
const hdr = msg.conn.getResponseHeader("connection", 0) orelse break :blk false;
break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
};
// release it ASAP so that it's available; some done_callbacks
// will load more resources.
self.endTransfer(transfer);
defer transfer.deinit();
if (msg.err != null and !is_conn_close_recv) {
requestFailed(transfer, msg.err.?, true);
if (msg.err) |err| {
requestFailed(transfer, err, true);
} else blk: {
// make sure the transfer can't be immediately aborted from a callback
// since we still need it here.

View File

@@ -386,14 +386,6 @@ pub fn isHTML(self: *const Mime) bool {
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
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;

View File

@@ -35,7 +35,6 @@ const Factory = @import("Factory.zig");
const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig");
const ScriptManager = @import("ScriptManager.zig");
const StyleManager = @import("StyleManager.zig");
const Parser = @import("parser/Parser.zig");
@@ -61,7 +60,6 @@ const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
@@ -146,7 +144,6 @@ _blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
/// A call to `documentIsComplete` (which calls `_documentIsComplete`) resets it.
_to_load: std.ArrayList(*Element.Html) = .{},
_style_manager: StyleManager,
_script_manager: ScriptManager,
// List of active live ranges (for mutation updates per DOM spec)
@@ -272,7 +269,6 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._factory = factory,
._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame,
._style_manager = undefined,
._script_manager = undefined,
._event_manager = EventManager.init(session.page_arena, self),
};
@@ -300,22 +296,13 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._performance = Performance.init(),
._screen = screen,
._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;
self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self);
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, .{
.identity = &session.identity,
.identity_arena = session.page_arena,
.call_arena = self.call_arena,
});
self.js = try browser.env.createContext(self);
errdefer self.js.deinit();
document._page = self;
@@ -369,7 +356,6 @@ pub fn deinit(self: *Page, abort_http: bool) void {
}
self._script_manager.deinit();
self._style_manager.deinit();
session.releaseArena(self.call_arena);
}
@@ -451,12 +437,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
if (is_about_blank or is_blob) {
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) {
// strip out blob:
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
@@ -603,34 +583,13 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp
// page that it's acting on.
fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, nt: Navigation) !void {
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")) {
// navigate will handle this special case
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(
arena,
page_base,
originator.base(),
request_url,
.{ .always_dupe = true, .encode = true },
);
@@ -2598,17 +2557,6 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
}
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();
}
}
}
@@ -2620,10 +2568,8 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
self.domChanged();
const dest_connected = target.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks
// (like custom element connectedCallback) modify the parent during iteration.
// The iterator captures "next" pointers that can become stale.
while (parent.firstChild()) |child| {
var it = parent.childrenIterator();
while (it.next()) |child| {
// Check if child was connected BEFORE removing it from parent
const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
@@ -2635,10 +2581,8 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_
self.domChanged();
const dest_connected = parent.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks
// (like custom element connectedCallback) modify the fragment during iteration.
// The iterator captures "next" pointers that can become stale.
while (fragment.firstChild()) |child| {
var it = fragment.childrenIterator();
while (it.next()) |child| {
// Check if child was connected BEFORE removing it from fragment
const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
@@ -3490,8 +3434,7 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
};
if (submit_opts.fire_event) {
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();
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
// so submit_event is still valid when we check _prevent_default
submit_event.acquireRef();
@@ -3589,6 +3532,9 @@ test "WebApi: Page" {
}
test "WebApi: Frames" {
const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("frames", .{});
}

View File

@@ -1,241 +0,0 @@
// 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 App = @import("../App.zig");
const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.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

@@ -24,13 +24,11 @@ const log = @import("../log.zig");
const App = @import("../App.zig");
const js = @import("js/js.zig");
const v8 = js.v8;
const storage = @import("webapi/storage/storage.zig");
const Navigation = @import("webapi/navigation/Navigation.zig");
const History = @import("webapi/History.zig");
const Page = @import("Page.zig");
pub const Runner = @import("Runner.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig");
@@ -67,41 +65,36 @@ page_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
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.
// These live for the duration of the page tree (root + frames).
arena_pool: *ArenaPool,
// In Debug, we use this to see if anything fails to release an arena back to
// the pool.
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
owner: []const u8,
count: usize,
}) else void = if (IS_DEBUG) .empty else {},
page: ?Page,
// 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),
queued_navigation: std.ArrayList(*Page),
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Page),
page_id_gen: u32 = 0,
frame_id_gen: u32 = 0,
page_id_gen: u32,
frame_id_gen: u32,
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
const allocator = browser.app.allocator;
const arena_pool = browser.arena_pool;
const arena = try arena_pool.acquire(.{ .debug = "Session" });
const arena = try arena_pool.acquire();
errdefer arena_pool.release(arena);
const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" });
const page_arena = try arena_pool.acquire();
errdefer arena_pool.release(page_arena);
self.* = .{
@@ -111,18 +104,17 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.page_arena = page_arena,
.factory = Factory.init(page_arena),
.history = .{},
.page_id_gen = 0,
.frame_id_gen = 0,
// The prototype (EventTarget) for Navigation is created when a Page is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.queued_navigation = undefined,
.queued_navigation_1 = .{},
.queued_navigation_2 = .{},
.queued_navigation = .{},
.queued_queued_navigation = .{},
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
self.queued_navigation = &self.queued_navigation_1;
}
pub fn deinit(self: *Session) void {
@@ -179,11 +171,32 @@ pub const GetArenaOpts = struct {
};
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
return self.arena_pool.acquire(.{ .debug = opts.debug });
const allocator = try self.arena_pool.acquire();
if (comptime IS_DEBUG) {
// Use session's arena (not page_arena) since page_arena gets reset between pages
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
if (gop.found_existing and gop.value_ptr.count != 0) {
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
@panic("ArenaPool Double Use");
}
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
}
return allocator;
}
pub fn releaseArena(self: *Session, allocator: Allocator) void {
self.arena_pool.release(allocator);
if (comptime IS_DEBUG) {
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
if (found.count != 1) {
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
if (comptime builtin.is_test) {
@panic("ArenaPool Double Free");
}
return;
}
found.count = 0;
}
return self.arena_pool.release(allocator);
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
@@ -224,9 +237,18 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
/// Reset page_arena and factory for a clean slate.
/// Called when root page is removed.
fn resetPageResources(self: *Session) void {
self.identity.deinit();
self.identity = .{};
// Check for arena leaks before releasing
if (comptime IS_DEBUG) {
var it = self._arena_pool_leak_track.valueIterator();
while (it.next()) |value_ptr| {
if (value_ptr.count > 0) {
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
}
}
self._arena_pool_leak_track.clearRetainingCapacity();
}
// All origins should have been released when contexts were destroyed
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
@@ -237,9 +259,10 @@ fn resetPageResources(self: *Session) void {
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins = .empty;
self.origins.clearRetainingCapacity();
}
// Release old page_arena and acquire fresh one
self.frame_id_gen = 0;
self.arena_pool.reset(self.page_arena, 64 * 1024);
self.factory = Factory.init(self.page_arena);
@@ -270,6 +293,12 @@ pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null);
}
pub const WaitResult = enum {
done,
no_page,
cdp_socket,
};
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
const page = self.currentPage() orelse return null;
return findPageBy(page, "_frame_id", frame_id);
@@ -290,12 +319,194 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
return null;
}
pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
return Runner.init(self, opts);
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
var page = &(self.page orelse return .no_page);
while (true) {
const wait_result = self._wait(page, wait_ms) catch |err| {
switch (err) {
error.JsError => {}, // already logged (with hopefully more context)
else => log.err(.browser, "session wait", .{
.err = err,
.url = page.url,
}),
}
return .done;
};
switch (wait_result) {
.done => {
if (self.queued_navigation.items.len == 0) {
return .done;
}
self.processQueuedNavigation() catch return .done;
page = &self.page.?; // might have changed
},
else => |result| return result,
}
}
}
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 {
const list = self.queued_navigation;
const list = &self.queued_navigation;
// Check if page is already queued
for (list.items) |existing| {
@@ -308,13 +519,8 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void {
return list.append(self.arena, page);
}
pub fn processQueuedNavigation(self: *Session) !void {
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;
}
fn processQueuedNavigation(self: *Session) !void {
const navigations = &self.queued_navigation;
if (self.page.?._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the
@@ -330,6 +536,7 @@ pub fn processQueuedNavigation(self: *Session) !void {
defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank)
// These cannot cause re-entrant navigation scheduling
for (navigations.items) |page| {
const qn = page._queued_navigation.?;
@@ -344,6 +551,7 @@ pub fn processQueuedNavigation(self: *Session) !void {
};
}
// Clear the queue after first pass
navigations.clearRetainingCapacity();
// Second pass: process synchronous navigations (about:blank)
@@ -353,17 +561,15 @@ pub fn processQueuedNavigation(self: *Session) !void {
try self.processFrameNavigation(page, qn);
}
// Safety: Remove any about:blank navigations that were queued during
// processing to prevent infinite loops. New navigations have been queued
// in the other buffer.
const new_navigations = self.queued_navigation;
// Safety: Remove any about:blank navigations that were queued during the
// second pass to prevent infinite loops
var i: usize = 0;
while (i < new_navigations.items.len) {
const page = new_navigations.items[i];
while (i < navigations.items.len) {
const page = navigations.items[i];
if (page._queued_navigation) |qn| {
if (qn.is_about_blank) {
log.warn(.page, "recursive about blank", .{});
_ = self.queued_navigation.swapRemove(i);
log.warn(.page, "recursive about blank", .{});
_ = navigations.swapRemove(i);
continue;
}
}
@@ -426,6 +632,16 @@ fn processRootQueuedNavigation(self: *Session) !void {
defer self.arena_pool.release(qn.arena);
// HACK
// Mark as released in tracking BEFORE removePage clears the map.
// We can't call releaseArena() because that would also return the arena
// to the pool, making the memory invalid before we use qn.url/qn.opts.
if (comptime IS_DEBUG) {
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
found.count = 0;
}
}
self.removePage();
self.page = @as(Page, undefined);
@@ -456,36 +672,3 @@ pub fn nextPageId(self: *Session) u32 {
self.page_id_gen = 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

@@ -1,855 +0,0 @@
// 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];
}
const EncodeSet = enum { path, query, userinfo, fragment };
const EncodeSet = enum { path, query, userinfo };
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
// Check if encoding is needed
@@ -256,10 +256,8 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
';', '=' => encode_set == .userinfo,
// Separators: userinfo must encode these
'/', ':', '@' => encode_set == .userinfo,
// '?' is allowed in queries only
// '?' is allowed in queries but not in paths or userinfo
'?' => encode_set != .query,
// '#' is allowed in fragments only
'#' => encode_set != .fragment,
// Everything else needs encoding (including space)
else => true,
};
@@ -325,22 +323,14 @@ pub fn getPassword(raw: [:0]const u8) []const u8 {
}
pub fn getPathname(raw: [:0]const u8) []const u8 {
const protocol_end = std.mem.indexOf(u8, raw, "://");
// 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 protocol_end = std.mem.indexOf(u8, raw, "://") orelse 0;
const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') 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) {
return "/";
if (std.mem.indexOf(u8, raw, "://") != null) return "/";
return "";
}
return raw[path_start..query_or_hash_start];
@@ -404,6 +394,10 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
}
var authority_start = scheme_end + 3;
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: {
authority_start += pos + 1;
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..], "/?#");
@@ -412,12 +406,6 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
else
raw.len;
// We mustn't search the `@` after the first path separator.
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..authority_end], "@")) |pos| blk: {
authority_start += pos + 1;
break :blk true;
} else false;
// Check for port in the host:port section
const host_part = raw[authority_start..authority_end];
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
@@ -463,15 +451,8 @@ fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const authority_start = scheme_end + 3;
// We mustn't search the `@` after the first path separator.
const path_start = blk: {
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
break :blk authority_start + idx;
}
break :blk raw.len;
};
const pos = std.mem.indexOfScalar(u8, raw[authority_start..path_start], '@') orelse return null;
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) {
@@ -485,20 +466,13 @@ pub fn getHost(raw: [:0]const u8) []const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return "";
var authority_start = scheme_end + 3;
// We mustn't search the `@` after the first path separator.
const path_start = blk: {
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
break :blk authority_start + idx;
}
break :blk raw.len;
};
if (std.mem.indexOf(u8, raw[authority_start..path_start], "@")) |pos| {
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
authority_start += pos + 1;
}
return raw[authority_start..path_start];
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.
@@ -613,13 +587,11 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato
const search = getSearch(current);
const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .path);
// Add / prefix if not present and value is not empty
const pathname = if (encoded.len > 0 and encoded[0] != '/')
try std.fmt.allocPrint(allocator, "/{s}", .{encoded})
const pathname = if (value.len > 0 and value[0] != '/')
try std.fmt.allocPrint(allocator, "/{s}", .{value})
else
encoded;
value;
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
@@ -630,13 +602,11 @@ pub fn setSearch(current: [:0]const u8, value: []const u8, allocator: Allocator)
const pathname = getPathname(current);
const hash = getHash(current);
const encoded = try percentEncodeSegment(allocator, value, .query);
// Add ? prefix if not present and value is not empty
const search = if (encoded.len > 0 and value[0] != '?')
try std.fmt.allocPrint(allocator, "?{s}", .{encoded})
const search = if (value.len > 0 and value[0] != '?')
try std.fmt.allocPrint(allocator, "?{s}", .{value})
else
encoded;
value;
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
@@ -647,13 +617,11 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const pathname = getPathname(current);
const search = getSearch(current);
const encoded = try percentEncodeSegment(allocator, value, .fragment);
// Add # prefix if not present and value is not empty
const hash = if (encoded.len > 0 and encoded[0] != '#')
try std.fmt.allocPrint(allocator, "#{s}", .{encoded})
const hash = if (value.len > 0 and value[0] != '#')
try std.fmt.allocPrint(allocator, "#{s}", .{value})
else
encoded;
value;
return buildUrl(allocator, protocol, host, pathname, search, hash);
}
@@ -1446,55 +1414,3 @@ test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
}
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" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
try testing.expectEqualSlices(u8, "http://example.com:8080", try getOrigin(allocator, "http://example.com:8080/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://example.com:8080/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com:443/") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://user:pass@example.com/page") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://user:pass@example.com:8080/page") orelse unreachable);
try testing.expectEqual(null, try getOrigin(allocator, "not-a-url"));
}
test "URL: SOP bypass" {
// SOP Bypass
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("https://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com?@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com#@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("https://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com?@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com#@bank.com/"));
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com/@bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "https://attacker.com", try getOrigin(allocator, "https://attacker.com/@bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com?bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com#bank.com/") orelse unreachable);
}

View File

@@ -23,8 +23,6 @@ const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.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 {
const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -104,34 +102,3 @@ 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,191 +293,3 @@ fn isBang(token: Tokenizer.Token) bool {
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());
}

View File

@@ -1,460 +0,0 @@
// 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

@@ -133,8 +133,6 @@ pub fn collectInteractiveElements(
// so classify and getListenerTypes are both O(1) per element.
const listener_targets = try buildListenerTargetMap(page, arena);
var css_cache: Element.PointerEventsCache = .empty;
var results: std.ArrayList(InteractiveElement) = .empty;
var tw = TreeWalker.Full.init(root, .{});
@@ -148,7 +146,7 @@ pub fn collectInteractiveElements(
else => {},
}
const itype = classifyInteractivity(page, el, html_el, listener_targets, &css_cache) orelse continue;
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
const listener_types = getListenerTypes(
el.asEventTarget(),
@@ -162,7 +160,7 @@ pub fn collectInteractiveElements(
.name = try getAccessibleName(el, arena),
.interactivity_type = itype,
.listener_types = listener_types,
.disabled = el.isDisabled(),
.disabled = isDisabled(el),
.tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")),
@@ -212,14 +210,10 @@ pub fn buildListenerTargetMap(page: *Page, arena: Allocator) !ListenerTargetMap
}
pub fn classifyInteractivity(
page: *Page,
el: *Element,
html_el: *Element.Html,
listener_targets: ListenerTargetMap,
cache: ?*Element.PointerEventsCache,
) ?InteractivityType {
if (el.hasPointerEventsNone(cache, page)) return null;
// 1. Native interactive by tag
switch (el.getTag()) {
.button, .summary, .details, .select, .textarea => return .native,
@@ -412,6 +406,36 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
// strip out trailing space
return arr.items[0 .. arr.items.len - 1];
}
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| {
@@ -530,11 +554,6 @@ test "browser.interactive: disabled by fieldset" {
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" {
const elements = try testInteractive("<div>Just text</div>");
try testing.expectEqual(0, elements.len);

View File

@@ -505,7 +505,6 @@ pub const Function = struct {
pub const Opts = struct {
noop: bool = false,
static: bool = false,
deletable: bool = true,
dom_exception: bool = false,
as_typed_array: bool = false,
null_as_undefined: bool = false,

View File

@@ -63,9 +63,7 @@ templates: []*const v8.FunctionTemplate,
// Arena for the lifetime of the context
arena: Allocator,
// 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.
// The page.call_arena
call_arena: Allocator,
// Because calls can be nested (i.e.a function calling a callback),
@@ -81,16 +79,6 @@ local: ?*const js.Local = null,
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
// across origins.
global_modules: std.ArrayList(v8.Global) = .empty,
@@ -197,8 +185,9 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
try origin.takeover(self.origin);
self.session.releaseOrigin(self.origin);
self.origin = origin;
{
@@ -214,16 +203,16 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.identity.globals.append(self.identity_arena, global);
return self.origin.trackGlobal(global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.identity.temps.put(self.identity_arena, global.data_ptr, global);
return self.origin.trackTemp(global);
}
pub fn weakRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj);
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -235,7 +224,7 @@ pub fn weakRef(self: *Context, obj: anytype) void {
pub fn safeWeakRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj);
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -248,7 +237,7 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
pub fn strongRef(self: *Context, obj: anytype) void {
const resolved = js.Local.resolveValue(obj);
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -258,48 +247,6 @@ pub fn strongRef(self: *Context, obj: anytype) void {
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.
pub fn localScope(self: *Context, ls: *js.Local.Scope) void {
const isolate = self.isolate;

View File

@@ -34,7 +34,6 @@ const Snapshot = @import("Snapshot.zig");
const Inspector = @import("Inspector.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Window = @import("../webapi/Window.zig");
const JsApis = bridge.JsApis;
@@ -255,15 +254,8 @@ pub fn deinit(self: *Env) void {
allocator.destroy(self.isolate_params);
}
pub const ContextParams = struct {
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 });
pub fn createContext(self: *Env, page: *Page) !*Context {
const context_arena = try self.app.arena_pool.acquire();
errdefer self.app.arena_pool.release(context_arena);
const isolate = self.isolate;
@@ -308,43 +300,33 @@ pub fn createContext(self: *Env, page: *Page, params: ContextParams) !*Context {
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
}
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
const context_id = self.context_id;
self.context_id = context_id + 1;
const session = page._session;
const origin = try session.getOrCreateOrigin(null);
errdefer session.releaseOrigin(origin);
const origin = try page._session.getOrCreateOrigin(null);
errdefer page._session.releaseOrigin(origin);
const context = try context_arena.create(Context);
context.* = .{
.env = self,
.page = page,
.session = page._session,
.origin = origin,
.id = context_id,
.session = session,
.isolate = isolate,
.arena = context_arena,
.handle = context_global,
.templates = self.templates,
.call_arena = params.call_arena,
.call_arena = page.call_arena,
.microtask_queue = microtask_queue,
.script_manager = &page._script_manager,
.scheduler = .init(context_arena),
.identity = params.identity,
.identity_arena = params.identity_arena,
};
{
// 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;
}
}
try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global);
// Store a pointer to our context inside the v8 context so that, given
// a v8 context, we can get our context out

View File

@@ -21,7 +21,6 @@ const js = @import("js.zig");
const v8 = js.v8;
const log = @import("../../log.zig");
const Session = @import("../Session.zig");
const Function = @This();
@@ -211,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);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
return .{ .handle = global, .origin = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.identity.temps };
return .{ .handle = global, .origin = ctx.origin };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
@@ -238,7 +237,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -258,10 +257,7 @@ fn G(comptime global_type: GlobalType) type {
}
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -1,76 +0,0 @@
// 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 Allocator = std.mem.Allocator;
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

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

View File

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

View File

@@ -16,12 +16,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("js.zig");
const v8 = js.v8;
const Session = @import("../Session.zig");
const Promise = @This();
local: *const js.Local,
@@ -66,10 +63,10 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
return .{ .handle = global, .origin = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.identity.temps };
return .{ .handle = global, .origin = ctx.origin };
}
pub const Temp = G(.temp);
@@ -83,7 +80,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -99,10 +96,7 @@ fn G(comptime global_type: GlobalType) type {
}
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
self.origin.releaseTemp(self.handle);
}
};
}

View File

@@ -137,7 +137,7 @@ pub fn create() !Snapshot {
defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST
var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
var templates: [JsApis.len]*v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate);
@@ -419,7 +419,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the
// FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
@@ -429,7 +429,7 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.Fun
break :blk illegalConstructorCallback;
};
const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
@@ -482,15 +482,10 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
}
// Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void {
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(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;
var has_named_index_getter = false;
@@ -502,47 +497,23 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
switch (definition) {
bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
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;
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
if (value.setter == null) {
attribute |= v8.ReadOnly;
}
if (value.deletable == false) {
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);
if (value.static) {
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
} else {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
}
} else {
v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
.key = js_name,
.getter = getter_callback,
.setter = setter_callback,
.attribute = attribute,
});
if (comptime IS_DEBUG) {
std.debug.assert(value.static == false);
}
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
}
},
bridge.Function => {
// 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 function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?);
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -580,7 +551,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
has_named_index_getter = true;
},
bridge.Iterator => {
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate)
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) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.page_arena) };
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.origin.arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}

View File

@@ -25,7 +25,6 @@ const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator;
const Session = @import("../Session.zig");
const Value = @This();
@@ -301,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);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
return .{ .handle = global, .origin = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.identity.temps };
return .{ .handle = global, .origin = ctx.origin };
}
pub fn toZig(self: Value, comptime T: type) !T {
@@ -362,7 +361,7 @@ const GlobalType = enum(u8) {
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
origin: if (global_type == .temp) *js.Origin else void,
const Self = @This();
@@ -382,10 +381,7 @@ fn G(comptime global_type: GlobalType) type {
}
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
self.origin.releaseTemp(self.handle);
}
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -419,117 +419,3 @@
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
}
</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,4 +342,3 @@
testing.expectEqual('html', doc.lastChild.nodeName);
}
</script>

View File

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

View File

@@ -1,226 +0,0 @@
<!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.onload(() => testing.expectEqual(true, asyncBlockDispatched));
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched));
}
</script>

View File

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

View File

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

View File

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

View File

@@ -236,11 +236,9 @@
{
const audio = document.createElement('audio');
testing.expectEqual('', audio.src);
testing.expectEqual('', audio.currentSrc);
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.currentSrc);
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,20 +128,6 @@
});
});
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);
testing.eventually(() => testing.expectEqual(true, result));
}
</script>

View File

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

View File

@@ -1,139 +0,0 @@
<!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>
var s3 = AbortSignal.timeout(10);
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
<a id=l1 target=f1 href=support/page.html></a>
<script id=anchor>
$('#l1').click();
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML);
});
</script>
@@ -21,7 +21,7 @@
form.action = 'support/page.html';
form.submit();
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', frame2.contentDocument.documentElement.outerHTML);
});
}
@@ -35,7 +35,7 @@
<script id=formtarget>
{
$('#submit1').click();
testing.onload(() => {
testing.eventually(() => {
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
// stop the script. If it succeeds, it'll set support_history_completed
// which we can use here to assume everything passed.
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual(true, window.support_history_completed);
testing.expectEqual(true, window.support_history_popstateEventFired);
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
<!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

@@ -28,40 +28,3 @@
d1.appendChild(p2);
assertChildren(['p1', 'p2'], d1);
</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);
iframe.src = blob_url;
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual('Hello Blob', iframe.contentDocument.getElementById('test').textContent);
});
}
@@ -33,7 +33,7 @@
document.body.appendChild(iframe2);
iframe2.src = url2;
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual('First', iframe1.contentDocument.body.textContent);
testing.expectEqual('Second', iframe2.contentDocument.body.textContent);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -591,35 +591,6 @@
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');
url.search = '?a=b';
@@ -685,20 +656,6 @@
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');
url.search = '';
@@ -716,20 +673,6 @@
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 sp = url.searchParams;
@@ -855,49 +798,3 @@
testing.expectEqual(true, url2.startsWith('blob:'));
}
</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>
<script id="SOP Bypass">
{
const url = new URL('http://example.com/@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
{
const url = new URL('http://example.com?@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
{
const url = new URL('http://example.com#@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
</script>

View File

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

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
<!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

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

View File

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

View File

@@ -17,7 +17,7 @@
testing.expectEqual(true, timer1 != timer2);
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual(true, set_interval1);
testing.expectEqual(false, set_interval2);
});
@@ -29,7 +29,7 @@
window.setTimeout((a, b) => {
wst2 = a + b;
}, 1, 2, 3);
testing.onload(() => testing.expectEqual(5, wst2));
testing.eventually(() => testing.expectEqual(5, wst2));
</script>
<script id=invalid-timer-clear>

View File

@@ -43,7 +43,7 @@
// noop
window.removeEventListener('load', fn);
testing.onload(() => {
testing.eventually(() => {
testing.expectEqual(1, call1);
testing.expectEqual(2, call2);
});
@@ -262,31 +262,6 @@
}
</script>
<script id=cached_getter_wrong_this>
// Test that extracting a cached property getter and calling it with wrong `this`
// doesn't crash (V8 internal field out of bounds). V8's Signature validation
// should throw "Illegal invocation" for wrong receiver types.
const documentGetter = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
// Verify we get an error with wrong this values
let errorCount = 0;
const testValues = [{}, null, undefined, 42, 'string', [], () => {}];
for (const val of testValues) {
try {
documentGetter.call(val);
} catch (e) {
if (e.message.includes('Illegal invocation')) {
errorCount++;
}
}
}
// At least some should throw (null/undefined/primitives get coerced in sloppy mode)
testing.expectEqual(true, errorCount > 0);
// Calling with correct this should still work
testing.expectEqual(document, documentGetter.call(window));
</script>
<script id=unhandled_rejection>
{
let unhandledCalled = 0;
@@ -310,6 +285,6 @@
unhandledCalled += 1;
});
Promise.reject({x: 'Fail'});
testing.onload(() => testing.expectEqual(2, unhandledCalled));
testing.eventually(() => testing.expectEqual(2, unhandledCalled));
}
</script>

View File

@@ -1,107 +0,0 @@
// 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 crypto = @import("../../sys/libcrypto.zig");
const js = @import("../js/js.zig");
/// Represents a cryptographic key obtained from one of the SubtleCrypto methods
/// generateKey(), deriveKey(), importKey(), or unwrapKey().
const CryptoKey = @This();
/// Algorithm being used.
_type: Type,
/// Whether the key is extractable.
_extractable: bool,
/// Bit flags of `usages`; see `Usages` type.
_usages: u8,
/// Raw bytes of key.
_key: []const u8,
/// Different algorithms may use different data structures;
/// this union can be used for such situations. Active field is understood
/// from `_type`.
_vary: extern union {
/// Used by HMAC.
digest: *const crypto.EVP_MD,
/// Used by asymmetric algorithms (X25519, Ed25519).
pkey: *crypto.EVP_PKEY,
},
/// https://developer.mozilla.org/en-US/docs/Web/API/CryptoKeyPair
pub const Pair = struct {
privateKey: *CryptoKey,
publicKey: *CryptoKey,
};
/// Key-creating functions expect this format.
pub const KeyOrPair = union(enum) { key: *CryptoKey, pair: Pair };
pub const Type = enum(u8) { hmac, rsa, x25519 };
/// Changing the names of fields would affect bitmask creation.
pub const Usages = struct {
// zig fmt: off
pub const encrypt = 0x001;
pub const decrypt = 0x002;
pub const sign = 0x004;
pub const verify = 0x008;
pub const deriveKey = 0x010;
pub const deriveBits = 0x020;
pub const wrapKey = 0x040;
pub const unwrapKey = 0x080;
// zig fmt: on
};
pub inline fn canSign(self: *const CryptoKey) bool {
return self._usages & Usages.sign != 0;
}
pub inline fn canVerify(self: *const CryptoKey) bool {
return self._usages & Usages.verify != 0;
}
pub inline fn canDeriveBits(self: *const CryptoKey) bool {
return self._usages & Usages.deriveBits != 0;
}
pub inline fn canExportKey(self: *const CryptoKey) bool {
return self._extractable;
}
/// Only valid for HMAC.
pub inline fn getDigest(self: *const CryptoKey) *const crypto.EVP_MD {
return self._vary.digest;
}
/// Only valid for asymmetric algorithms (X25519, Ed25519).
pub inline fn getKeyObject(self: *const CryptoKey) *crypto.EVP_PKEY {
return self._vary.pkey;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CryptoKey);
pub const Meta = struct {
pub const name = "CryptoKey";
pub var class_id: bridge.ClassId = undefined;
pub const prototype_chain = bridge.prototypeChain();
};
};

View File

@@ -548,8 +548,35 @@ pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !vo
}
pub fn replaceChildren(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
try validateDocumentNodes(self, nodes, false);
return self.asNode().replaceChildren(nodes, page);
try validateDocumentNodes(self, nodes, true);
page.domChanged();
const parent = self.asNode();
// Remove all existing children
var it = parent.childrenIterator();
while (it.next()) |child| {
page.removeNode(parent, child, .{ .will_be_reconnected = false });
}
// Append new children
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
// DocumentFragments are special - append all their children
if (child.is(Node.DocumentFragment)) |_| {
try page.appendAllChildren(child, parent);
continue;
}
var child_connected = false;
if (child._parent) |previous_parent| {
child_connected = child.isConnected();
page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
}
try page.appendNode(parent, child, .{ .child_already_connected = child_connected });
}
}
pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element {
@@ -564,7 +591,7 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element
while (stack.items.len > 0) {
const node = stack.pop() orelse break;
if (node.is(Element)) |element| {
if (element.checkVisibilityCached(null, page)) {
if (element.checkVisibility(page)) {
const rect = element.getBoundingClientRectForVisible(page);
if (x >= rect.getLeft() and x <= rect.getRight() and y >= rect.getTop() and y <= rect.getBottom()) {
topmost = element;
@@ -690,16 +717,9 @@ pub fn write(self: *Document, text: []const []const u8, page: *Page) !void {
}
// Determine insertion point:
// - If _write_insertion_point is set and still parented correctly, continue from there
// - Otherwise, start after the script (first write, or previous insertion point was removed)
var insert_after: ?*Node = blk: {
if (self._write_insertion_point) |wip| {
if (wip._parent == parent) {
break :blk wip;
}
}
break :blk script.asNode();
};
// - If _write_insertion_point is set, continue from there (subsequent write)
// - Otherwise, start after the script (first write)
var insert_after: ?*Node = self._write_insertion_point orelse script.asNode();
for (children_to_insert.items) |child| {
// Clear parent pointer (child is currently parented to fragment/HTML wrapper)
@@ -876,10 +896,6 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti
if (has_doctype) {
return error.HierarchyError;
}
if (has_element) {
// Doctype cannot be inserted if document already has an element
return error.HierarchyError;
}
has_doctype = true;
},
.cdata => |cd| switch (cd._type) {
@@ -902,10 +918,6 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti
if (has_doctype) {
return error.HierarchyError;
}
if (has_element) {
// Doctype cannot be inserted if document already has an element
return error.HierarchyError;
}
has_doctype = true;
},
.cdata => |cd| switch (cd._type) {

View File

@@ -143,7 +143,25 @@ pub fn prepend(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *P
}
pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void {
return self.asNode().replaceChildren(nodes, page);
page.domChanged();
var parent = self.asNode();
var it = parent.childrenIterator();
while (it.next()) |child| {
page.removeNode(parent, child, .{ .will_be_reconnected = false });
}
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
// If the new children has already a parent, remove from it.
if (child._parent) |p| {
page.removeNode(p, child, .{ .will_be_reconnected = true });
}
try page.appendNode(parent, child, .{ .child_already_connected = parent_is_connected });
}
}
pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void {

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