mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 15:40:04 +00:00
Compare commits
194 Commits
http-cache
...
url-userin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c04ccf5e87 | ||
|
|
a0dd14aaad | ||
|
|
7ea8f3f766 | ||
|
|
28cc60adb0 | ||
|
|
c14a9ad986 | ||
|
|
679f2104f4 | ||
|
|
c6b0c75106 | ||
|
|
93485c1ef3 | ||
|
|
0324d5c232 | ||
|
|
a75c0cf08d | ||
|
|
2812b8f07c | ||
|
|
e2afbec29d | ||
|
|
a45f9cb810 | ||
|
|
cf641ed458 | ||
|
|
0fc959dcc5 | ||
|
|
077376ea04 | ||
|
|
6ed8d1d201 | ||
|
|
5207bd4202 | ||
|
|
11ed95290b | ||
|
|
a876275828 | ||
|
|
e83b8aa36d | ||
|
|
179f9c1169 | ||
|
|
ca41bb5fa2 | ||
|
|
9c37961042 | ||
|
|
0dd0495ab8 | ||
|
|
c9fa76da0c | ||
|
|
7718184e22 | ||
|
|
b81b41cbf0 | ||
|
|
3a0cead03a | ||
|
|
92ce6a916a | ||
|
|
130bf7ba11 | ||
|
|
2e40354a3a | ||
|
|
3074bde2f3 | ||
|
|
ed9f5aae2e | ||
|
|
8e315e551a | ||
|
|
bad690da65 | ||
|
|
ae080f32eb | ||
|
|
c5c1d1f2f8 | ||
|
|
eb18dc89f6 | ||
|
|
afb0c29243 | ||
|
|
267eee9693 | ||
|
|
39352a6bda | ||
|
|
0838b510f8 | ||
|
|
b19f30d865 | ||
|
|
35be9f897f | ||
|
|
d517488158 | ||
|
|
fee8fe7830 | ||
|
|
428190aecc | ||
|
|
61dabdedec | ||
|
|
dfd9f216bd | ||
|
|
567cd97312 | ||
|
|
0bfe00bbb7 | ||
|
|
260768463b | ||
|
|
fd96cd6eb9 | ||
|
|
25a7b5b778 | ||
|
|
d4bcfa974f | ||
|
|
c91eac17d0 | ||
|
|
5c79961bb7 | ||
|
|
a0c200bc49 | ||
|
|
9ea39e1c34 | ||
|
|
f7125d2bf3 | ||
|
|
b163d9709b | ||
|
|
5453630955 | ||
|
|
8ada67637f | ||
|
|
5972630e95 | ||
|
|
58c18114a5 | ||
|
|
a94b0bec93 | ||
|
|
ff0fbb6b41 | ||
|
|
797cae2ef8 | ||
|
|
433c03c709 | ||
|
|
4d3e9feaf4 | ||
|
|
5700e214bf | ||
|
|
88d40a7dcd | ||
|
|
ff209f5adf | ||
|
|
8ad092a960 | ||
|
|
0fcdc1d194 | ||
|
|
60c2359fdd | ||
|
|
08c8ba72f5 | ||
|
|
cfa4201532 | ||
|
|
cb02eb000e | ||
|
|
23334edc05 | ||
|
|
8dbe22a01a | ||
|
|
80235e2ddd | ||
|
|
2abed9fe75 | ||
|
|
35551ac84e | ||
|
|
c3a2318eca | ||
|
|
a6e801be59 | ||
|
|
0bbe25ab5e | ||
|
|
c37286f845 | ||
|
|
34079913a3 | ||
|
|
4f1b499d0f | ||
|
|
c9bc370d6a | ||
|
|
4b29823a5b | ||
|
|
a69a22ccd7 | ||
|
|
a6d2ec7610 | ||
|
|
ad83c6e70b | ||
|
|
c2a0d4c0b2 | ||
|
|
9e7f0b4776 | ||
|
|
e3085cb0f1 | ||
|
|
4e2e895cd9 | ||
|
|
c1fc2b1301 | ||
|
|
324e5eb152 | ||
|
|
df4df64066 | ||
|
|
c557a0fd87 | ||
|
|
a869f92e9a | ||
|
|
4d28265839 | ||
|
|
78c6def2b1 | ||
|
|
87a0690776 | ||
|
|
fbc71d6ff7 | ||
|
|
e10ccd846d | ||
|
|
384b2f7614 | ||
|
|
fdc79af55c | ||
|
|
e9bed18cd8 | ||
|
|
30f387d361 | ||
|
|
e7d272eaf6 | ||
|
|
00d06dbe8c | ||
|
|
7b104789aa | ||
|
|
2107ade3a5 | ||
|
|
e60424a402 | ||
|
|
107da49f81 | ||
|
|
3e309da69f | ||
|
|
370ae2b85c | ||
|
|
6008187c78 | ||
|
|
598fa254cf | ||
|
|
8526770e9f | ||
|
|
21325ca9be | ||
|
|
b5b012bd5d | ||
|
|
b4b7a7d58a | ||
|
|
a5378feb1d | ||
|
|
b5d3d37f16 | ||
|
|
9b02e4963b | ||
|
|
a865b86fa5 | ||
|
|
de28d14aff | ||
|
|
2d91acbd14 | ||
|
|
88681b1fdb | ||
|
|
1feb121ba7 | ||
|
|
35cdc3c348 | ||
|
|
1353f76bf1 | ||
|
|
3e2be5b317 | ||
|
|
448eca0c32 | ||
|
|
5404ca723c | ||
|
|
e56ffe4b60 | ||
|
|
02d05ae464 | ||
|
|
a74e97854d | ||
|
|
6925fc3f70 | ||
|
|
84557cb4e6 | ||
|
|
4cdc24326a | ||
|
|
f1293b7346 | ||
|
|
94190f93af | ||
|
|
93e239f682 | ||
|
|
a4cb5031d1 | ||
|
|
f70865e174 | ||
|
|
38e9f86088 | ||
|
|
d9c5f56500 | ||
|
|
6c5733bba3 | ||
|
|
b8f1622b52 | ||
|
|
2b9d5fd4d9 | ||
|
|
2dbd32d120 | ||
|
|
1695ea81d2 | ||
|
|
b7bf86fd85 | ||
|
|
94d8f90a96 | ||
|
|
b9bef22bbf | ||
|
|
b2a996e5c7 | ||
|
|
e2be8525c4 | ||
|
|
c15afa23ca | ||
|
|
f594b033bf | ||
|
|
10e379e4fb | ||
|
|
c1bb27c450 | ||
|
|
dda5e2c542 | ||
|
|
e29778d72b | ||
|
|
09327c3897 | ||
|
|
43a70272c5 | ||
|
|
f0c9c262ca | ||
|
|
3fde349b9f | ||
|
|
55a9976d46 | ||
|
|
66a86541d1 | ||
|
|
bc19079dad | ||
|
|
351e44343d | ||
|
|
e362a9cbc3 | ||
|
|
e2563e57f2 | ||
|
|
df5e978247 | ||
|
|
f37862a25d | ||
|
|
84d76cf90d | ||
|
|
e12f28fb70 | ||
|
|
dfe04960c0 | ||
|
|
de2b1cc6fe | ||
|
|
2aef4ab677 | ||
|
|
798f68d0ce | ||
|
|
e0343a3f6d | ||
|
|
d918ec694b | ||
|
|
b2b609a309 | ||
|
|
48dd80867b | ||
|
|
f58f6e8d65 | ||
|
|
ee034943b6 |
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.3.4'
|
||||
default: 'v0.3.7'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
4
.github/workflows/e2e-integration-test.yml
vendored
4
.github/workflows/e2e-integration-test.yml
vendored
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
|
||||
|
||||
- name: upload artifact
|
||||
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`
|
||||
|
||||
27
.github/workflows/e2e-test.yml
vendored
27
.github/workflows/e2e-test.yml
vendored
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64
|
||||
|
||||
- name: upload artifact
|
||||
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,6 +179,9 @@ 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:
|
||||
@@ -205,9 +208,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>&-
|
||||
|
||||
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
@@ -7,7 +7,7 @@ env:
|
||||
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
||||
|
||||
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
||||
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
|
||||
VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion_string={0}', github.ref_name) || format('-Dpre_version={0}', 'nightly') }}
|
||||
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
2
.github/workflows/wpt.yml
vendored
2
.github/workflows/wpt.yml
vendored
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -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.4
|
||||
ARG ZIG_V8=v0.3.7
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
@@ -53,8 +53,7 @@ RUN zig build -Doptimize=ReleaseFast \
|
||||
# build release
|
||||
RUN zig build -Doptimize=ReleaseFast \
|
||||
-Dsnapshot_path=../../snapshot.bin \
|
||||
-Dprebuilt_v8_path=v8/libc_v8.a \
|
||||
-Dgit_commit=$(git rev-parse --short HEAD)
|
||||
-Dprebuilt_v8_path=v8/libc_v8.a
|
||||
|
||||
FROM debian:stable-slim
|
||||
|
||||
@@ -75,4 +74,4 @@ EXPOSE 9222/tcp
|
||||
# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang.
|
||||
# (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"]
|
||||
|
||||
@@ -4,11 +4,3 @@ 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/
|
||||
```
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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 -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\033[33mBuild OK\033[0m\n"
|
||||
|
||||
## Build in debug mode
|
||||
build-dev:
|
||||
@printf "\033[36mBuilding (debug)...\033[0m\n"
|
||||
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@$(ZIG) build || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\033[33mBuild OK\033[0m\n"
|
||||
|
||||
## Run the server in release mode
|
||||
|
||||
16
README.md
16
README.md
@@ -18,15 +18,15 @@ 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.svg">
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time-v2.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
 
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame-v2.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
</div>
|
||||
|
||||
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
|
||||
See [benchmark details](https://github.com/lightpanda-io/demo)._
|
||||
_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)._
|
||||
|
||||
Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
@@ -82,7 +82,7 @@ docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
|
||||
### Dump a URL
|
||||
|
||||
```console
|
||||
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
|
||||
./lightpanda fetch --obey-robots --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/
|
||||
```
|
||||
```console
|
||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||
@@ -117,7 +117,7 @@ INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
|
||||
### 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
|
||||
```
|
||||
```console
|
||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||
@@ -186,7 +186,7 @@ Here are the key features we have implemented:
|
||||
- [x] Custom HTTP headers
|
||||
- [x] Proxy support
|
||||
- [x] Network interception
|
||||
- [x] Respect `robots.txt` with option `--obey_robots`
|
||||
- [x] Respect `robots.txt` with option `--obey-robots`
|
||||
|
||||
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||
|
||||
@@ -317,7 +317,7 @@ First start the WPT's HTTP server from your `wpt/` clone dir.
|
||||
Run a Lightpanda browser
|
||||
|
||||
```
|
||||
zig build run -- --insecure_disable_tls_host_verification
|
||||
zig build run -- --insecure-disable-tls-host-verification
|
||||
```
|
||||
|
||||
Then you can start the wptrunner from the Demo's clone dir:
|
||||
|
||||
101
build.zig
101
build.zig
@@ -17,24 +17,37 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const Build = std.Build;
|
||||
const lightpanda_version = std.SemanticVersion.parse(@import("build.zig.zon").version) catch unreachable;
|
||||
const min_zig_version = std.SemanticVersion.parse(@import("build.zig.zon").minimum_zig_version) catch unreachable;
|
||||
|
||||
const Build = blk: {
|
||||
if (builtin.zig_version.order(min_zig_version) == .lt) {
|
||||
const message = std.fmt.comptimePrint(
|
||||
\\Zig version is too old:
|
||||
\\ current Zig version: {f}
|
||||
\\ minimum Zig version: {f}
|
||||
, .{ builtin.zig_version, min_zig_version });
|
||||
@compileError(message);
|
||||
} else {
|
||||
break :blk std.Build;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn build(b: *Build) !void {
|
||||
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", 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, "version", b.fmt("{f}", .{version}));
|
||||
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
|
||||
|
||||
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
|
||||
@@ -96,6 +109,11 @@ pub fn build(b: *Build) !void {
|
||||
}
|
||||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
const version_info_step = b.step("version", "Print the resolved version information");
|
||||
const version_info_run = b.addRunArtifact(exe);
|
||||
version_info_run.addArg("version");
|
||||
version_info_step.dependOn(&version_info_run.step);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -701,27 +719,50 @@ fn buildCurl(
|
||||
return lib;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
}
|
||||
/// 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 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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
.{
|
||||
.name = .browser,
|
||||
.version = "0.0.0",
|
||||
.version = "1.0.0-dev",
|
||||
.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.4.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
|
||||
},
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||
.arena_pool = undefined,
|
||||
};
|
||||
|
||||
app.network = try Network.init(allocator, app, config);
|
||||
app.network = try Network.init(allocator, config);
|
||||
errdefer app.network.deinit();
|
||||
|
||||
app.platform = try Platform.init();
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
// 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,
|
||||
@@ -30,10 +33,17 @@ 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 {
|
||||
@@ -42,10 +52,26 @@ 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;
|
||||
@@ -54,13 +80,21 @@ pub fn deinit(self: *ArenaPool) void {
|
||||
self.entry_pool.deinit();
|
||||
}
|
||||
|
||||
pub fn acquire(self: *ArenaPool) !Allocator {
|
||||
pub fn acquire(self: *ArenaPool, dbg: DebugInfo) !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();
|
||||
}
|
||||
|
||||
@@ -68,8 +102,16 @@ pub fn acquire(self: *ArenaPool) !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();
|
||||
}
|
||||
|
||||
@@ -83,6 +125,19 @@ 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();
|
||||
@@ -100,13 +155,18 @@ 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();
|
||||
const alloc = try pool.acquire(.{ .debug = "test" });
|
||||
const buf = try alloc.alloc(u8, 64);
|
||||
@memset(buf, 0xAB);
|
||||
try testing.expectEqual(@as(u8, 0xAB), buf[0]);
|
||||
@@ -118,14 +178,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();
|
||||
const alloc1 = try pool.acquire(.{ .debug = "test" });
|
||||
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();
|
||||
const alloc2 = try pool.acquire(.{ .debug = "test" });
|
||||
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
|
||||
try testing.expectEqual(alloc1.ptr, alloc2.ptr);
|
||||
|
||||
@@ -136,9 +196,9 @@ test "arena pool - multiple concurrent arenas" {
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const a1 = try pool.acquire();
|
||||
const a2 = try pool.acquire();
|
||||
const a3 = try pool.acquire();
|
||||
const a1 = try pool.acquire(.{ .debug = "test1" });
|
||||
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||
const a3 = try pool.acquire(.{ .debug = "test3" });
|
||||
|
||||
// All three must be distinct arenas.
|
||||
try testing.expect(a1.ptr != a2.ptr);
|
||||
@@ -161,8 +221,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();
|
||||
const a2 = try pool.acquire();
|
||||
const a1 = try pool.acquire(.{ .debug = "test1" });
|
||||
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||
|
||||
pool.release(a1);
|
||||
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
|
||||
@@ -176,7 +236,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();
|
||||
const alloc = try pool.acquire(.{ .debug = "test" });
|
||||
|
||||
const buf = try alloc.alloc(u8, 128);
|
||||
@memset(buf, 0xFF);
|
||||
@@ -200,8 +260,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();
|
||||
const a2 = try pool.acquire();
|
||||
const a1 = try pool.acquire(.{ .debug = "test1" });
|
||||
const a2 = try pool.acquire(.{ .debug = "test2" });
|
||||
_ = try a1.alloc(u8, 256);
|
||||
_ = try a2.alloc(u8, 512);
|
||||
pool.release(a1);
|
||||
|
||||
240
src/Config.zig
240
src/Config.zig
@@ -156,13 +156,6 @@ pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cacheDir(self: *const Config) ?[]const u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.cache_dir,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cdpTimeout(self: *const Config) usize {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
|
||||
@@ -170,6 +163,20 @@ pub fn cdpTimeout(self: *const Config) usize {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn port(self: *const Config) u16 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.port,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn advertiseHost(self: *const Config) []const u8 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.advertise_host orelse opts.host,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
|
||||
@@ -206,6 +213,7 @@ 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,
|
||||
@@ -224,6 +232,13 @@ 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,
|
||||
@@ -231,6 +246,8 @@ 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 {
|
||||
@@ -247,7 +264,6 @@ pub const Common = struct {
|
||||
log_format: ?log.Format = null,
|
||||
log_filter_scopes: ?[]log.Scope = null,
|
||||
user_agent_suffix: ?[]const u8 = null,
|
||||
cache_dir: ?[]const u8 = null,
|
||||
|
||||
web_bot_auth_key_file: ?[]const u8 = null,
|
||||
web_bot_auth_keyid: ?[]const u8 = null,
|
||||
@@ -301,71 +317,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
|
||||
;
|
||||
|
||||
@@ -384,16 +400,23 @@ 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.
|
||||
\\--with-frames Includes the contents of iframes. Defaults to false.
|
||||
\\
|
||||
\\--wait-ms Wait time in milliseconds.
|
||||
\\ Defaults to 5000.
|
||||
\\
|
||||
\\--wait-until Wait until the specified event.
|
||||
\\ Supported events: load, domcontentloaded, networkidle, done.
|
||||
\\ Defaults to 'done'.
|
||||
\\
|
||||
++ common_options ++
|
||||
\\
|
||||
@@ -408,14 +431,19 @@ 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.
|
||||
\\
|
||||
@@ -493,15 +521,15 @@ fn inferMode(opt: []const u8) ?RunMode {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--strip_mode")) {
|
||||
if (std.mem.eql(u8, opt, "--strip-mode") or std.mem.eql(u8, opt, "--strip_mode")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--with_base")) {
|
||||
if (std.mem.eql(u8, opt, "--with-base") or std.mem.eql(u8, opt, "--with_base")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--with_frames")) {
|
||||
if (std.mem.eql(u8, opt, "--with-frames") or std.mem.eql(u8, opt, "--with_frames")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
@@ -549,6 +577,15 @@ 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" });
|
||||
@@ -562,27 +599,27 @@ fn parseServeArgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
|
||||
if (std.mem.eql(u8, "--cdp-max-connections", opt) or std.mem.eql(u8, "--cdp_max_connections", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
|
||||
if (std.mem.eql(u8, "--cdp-max-pending-connections", opt) or std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_pending_connections", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
@@ -627,8 +664,34 @@ 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| {
|
||||
@@ -647,25 +710,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)) {
|
||||
if (std.mem.eql(u8, "--with-base", opt) or std.mem.eql(u8, "--with_base", opt)) {
|
||||
with_base = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--with_frames", opt)) {
|
||||
if (std.mem.eql(u8, "--with-frames", opt) or std.mem.eql(u8, "--with_frames", opt)) {
|
||||
with_frames = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--strip_mode", opt)) {
|
||||
if (std.mem.eql(u8, "--strip-mode", opt) or std.mem.eql(u8, "--strip_mode", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
@@ -683,7 +746,7 @@ fn parseFetchArgs(
|
||||
strip.ui = true;
|
||||
strip.css = true;
|
||||
} else {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed });
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = trimmed });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -717,6 +780,8 @@ fn parseFetchArgs(
|
||||
.common = common,
|
||||
.with_base = with_base,
|
||||
.with_frames = with_frames,
|
||||
.wait_ms = wait_ms,
|
||||
.wait_until = wait_until,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -726,102 +791,102 @@ fn parseCommonArg(
|
||||
args: *std.process.ArgIterator,
|
||||
common: *Common,
|
||||
) !bool {
|
||||
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
|
||||
if (std.mem.eql(u8, "--insecure-disable-tls-host-verification", opt) or std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
|
||||
common.tls_verify_host = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--obey_robots", opt)) {
|
||||
if (std.mem.eql(u8, "--obey-robots", opt) or std.mem.eql(u8, "--obey_robots", opt)) {
|
||||
common.obey_robots = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_proxy", opt)) {
|
||||
if (std.mem.eql(u8, "--http-proxy", opt) or std.mem.eql(u8, "--http_proxy", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.http_proxy = try allocator.dupeZ(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
||||
if (std.mem.eql(u8, "--proxy-bearer-token", opt) or std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
|
||||
if (std.mem.eql(u8, "--http-max-concurrent", opt) or std.mem.eql(u8, "--http_max_concurrent", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
|
||||
if (std.mem.eql(u8, "--http-max-host-open", opt) or std.mem.eql(u8, "--http_max_host_open", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
|
||||
if (std.mem.eql(u8, "--http-connect-timeout", opt) or std.mem.eql(u8, "--http_connect_timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_timeout", opt)) {
|
||||
if (std.mem.eql(u8, "--http-timeout", opt) or std.mem.eql(u8, "--http_timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
|
||||
if (std.mem.eql(u8, "--http-max-response-size", opt) or std.mem.eql(u8, "--http_max_response_size", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err });
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_level", opt)) {
|
||||
if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
@@ -829,26 +894,26 @@ fn parseCommonArg(
|
||||
if (std.mem.eql(u8, str, "error")) {
|
||||
break :blk .err;
|
||||
}
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_format", opt)) {
|
||||
if (std.mem.eql(u8, "--log-format", opt) or std.mem.eql(u8, "--log_format", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = str });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
|
||||
if (std.mem.eql(u8, "--log-filter-scopes", opt) or std.mem.eql(u8, "--log_filter_scopes", opt)) {
|
||||
if (builtin.mode != .Debug) {
|
||||
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
|
||||
return false;
|
||||
@@ -865,7 +930,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 = "--log_filter_scopes", .value = part });
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = opt, .value = part });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -873,14 +938,14 @@ fn parseCommonArg(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
|
||||
if (std.mem.eql(u8, "--user-agent-suffix", opt) or std.mem.eql(u8, "--user_agent_suffix", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
for (str) |c| {
|
||||
if (!std.ascii.isPrint(c)) {
|
||||
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
|
||||
log.fatal(.app, "not printable character", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
}
|
||||
@@ -888,41 +953,32 @@ fn parseCommonArg(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
|
||||
if (std.mem.eql(u8, "--web-bot-auth-key-file", opt) or std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
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)) {
|
||||
if (std.mem.eql(u8, "--web-bot-auth-keyid", opt) or std.mem.eql(u8, "--web_bot_auth_keyid", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
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)) {
|
||||
if (std.mem.eql(u8, "--web-bot-auth-domain", opt) or std.mem.eql(u8, "--web_bot_auth_domain", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" });
|
||||
log.fatal(.app, "missing argument value", .{ .arg = opt });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.web_bot_auth_domain = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--cache_dir", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--cache_dir" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.cache_dir = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,15 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
|
||||
log.err(.app, "listener map failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
|
||||
var visibility_cache: Element.VisibilityCache = .empty;
|
||||
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||
var ctx: WalkContext = .{
|
||||
.xpath_buffer = &xpath_buffer,
|
||||
.listener_targets = listener_targets,
|
||||
.visibility_cache = &visibility_cache,
|
||||
.pointer_events_cache = &pointer_events_cache,
|
||||
};
|
||||
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||
log.err(.app, "semantic tree json dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -60,7 +68,15 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
|
||||
log.err(.app, "listener map failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
|
||||
var visibility_cache: Element.VisibilityCache = .empty;
|
||||
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||
var ctx: WalkContext = .{
|
||||
.xpath_buffer = &xpath_buffer,
|
||||
.listener_targets = listener_targets,
|
||||
.visibility_cache = &visibility_cache,
|
||||
.pointer_events_cache = &pointer_events_cache,
|
||||
};
|
||||
self.walk(&ctx, self.dom_node, null, &visitor, 1, 0) catch |err| {
|
||||
log.err(.app, "semantic tree text dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -84,7 +100,22 @@ const NodeData = struct {
|
||||
node_name: []const u8,
|
||||
};
|
||||
|
||||
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void {
|
||||
const WalkContext = struct {
|
||||
xpath_buffer: *std.ArrayList(u8),
|
||||
listener_targets: interactive.ListenerTargetMap,
|
||||
visibility_cache: *Element.VisibilityCache,
|
||||
pointer_events_cache: *Element.PointerEventsCache,
|
||||
};
|
||||
|
||||
fn walk(
|
||||
self: @This(),
|
||||
ctx: *WalkContext,
|
||||
node: *Node,
|
||||
parent_name: ?[]const u8,
|
||||
visitor: anytype,
|
||||
index: usize,
|
||||
current_depth: u32,
|
||||
) !void {
|
||||
if (current_depth > self.max_depth) return;
|
||||
|
||||
// 1. Skip non-content nodes
|
||||
@@ -96,7 +127,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
if (tag == .datalist or tag == .option or tag == .optgroup) return;
|
||||
|
||||
// Check visibility using the engine's checkVisibility which handles CSS display: none
|
||||
if (!el.checkVisibility(self.page)) {
|
||||
if (!el.checkVisibilityCached(ctx.visibility_cache, self.page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +168,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
}
|
||||
|
||||
if (el.is(Element.Html)) |html_el| {
|
||||
if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) {
|
||||
if (interactive.classifyInteractivity(self.page, el, html_el, ctx.listener_targets, ctx.pointer_events_cache) != null) {
|
||||
is_interactive = true;
|
||||
}
|
||||
}
|
||||
@@ -145,9 +176,9 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
node_name = "root";
|
||||
}
|
||||
|
||||
const initial_xpath_len = xpath_buffer.items.len;
|
||||
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index);
|
||||
const xpath = xpath_buffer.items;
|
||||
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;
|
||||
|
||||
var name = try axn.getName(self.page, self.arena);
|
||||
|
||||
@@ -165,18 +196,6 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
name = null;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -208,6 +227,18 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
|
||||
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
|
||||
@@ -233,7 +264,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
|
||||
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,11 +272,11 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
try visitor.leave();
|
||||
}
|
||||
|
||||
xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||
ctx.xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||
}
|
||||
|
||||
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
|
||||
var options = std.ArrayListUnmanaged(OptionData){};
|
||||
var options: std.ArrayList(OptionData) = .empty;
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
if (child.is(Element)) |el| {
|
||||
|
||||
@@ -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").CDP;
|
||||
const CDP = @import("cdp/CDP.zig");
|
||||
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(allocator, address);
|
||||
const json_version_response = try buildJSONVersionResponse(app);
|
||||
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,
|
||||
|
||||
fn init(
|
||||
pub fn init(
|
||||
socket: posix.socket_t,
|
||||
allocator: Allocator,
|
||||
app: *App,
|
||||
@@ -250,7 +250,7 @@ pub const Client = struct {
|
||||
self.ws.shutdown();
|
||||
}
|
||||
|
||||
fn deinit(self: *Client) void {
|
||||
pub fn deinit(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.cdp => |*cdp| cdp.deinit(),
|
||||
.http => {},
|
||||
@@ -302,15 +302,8 @@ pub const Client = struct {
|
||||
var ms_remaining = self.ws.timeout_ms;
|
||||
|
||||
while (true) {
|
||||
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 result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
|
||||
error.NoPage => {
|
||||
const status = http.tick(ms_remaining) catch |err| {
|
||||
log.err(.app, "http tick", .{ .err = err });
|
||||
return;
|
||||
@@ -324,6 +317,18 @@ 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);
|
||||
@@ -456,7 +461,7 @@ pub const Client = struct {
|
||||
|
||||
fn upgradeConnection(self: *Client, request: []u8) !void {
|
||||
try self.ws.upgrade(request);
|
||||
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
|
||||
self.mode = .{ .cdp = try CDP.init(self) };
|
||||
}
|
||||
|
||||
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
|
||||
@@ -484,11 +489,17 @@ pub const Client = struct {
|
||||
// --------
|
||||
|
||||
fn buildJSONVersionResponse(
|
||||
allocator: Allocator,
|
||||
address: net.Address,
|
||||
app: *const App,
|
||||
) ![]const u8 {
|
||||
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}";
|
||||
const body_len = std.fmt.count(body_format, .{address});
|
||||
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 });
|
||||
|
||||
// We send a Connection: Close (and actually close the connection)
|
||||
// because chromedp (Go driver) sends a request to /json/version and then
|
||||
@@ -502,23 +513,22 @@ fn buildJSONVersionResponse(
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
body_format;
|
||||
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
|
||||
return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
|
||||
}
|
||||
|
||||
pub const timestamp = @import("datetime.zig").timestamp;
|
||||
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
|
||||
|
||||
const testing = std.testing;
|
||||
const testing = @import("testing.zig");
|
||||
test "server: buildJSONVersionResponse" {
|
||||
const address = try net.Address.parseIp4("127.0.0.1", 9001);
|
||||
const res = try buildJSONVersionResponse(testing.allocator, address);
|
||||
defer testing.allocator.free(res);
|
||||
const res = try buildJSONVersionResponse(testing.test_app);
|
||||
defer testing.test_app.allocator.free(res);
|
||||
|
||||
try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++
|
||||
try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: 48\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res);
|
||||
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}", res);
|
||||
}
|
||||
|
||||
test "Client: http invalid request" {
|
||||
@@ -526,7 +536,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.expectEqualStrings("HTTP/1.1 413 \r\n" ++
|
||||
try testing.expectEqual("HTTP/1.1 413 \r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Length: 17\r\n\r\n" ++
|
||||
"Request too large", res);
|
||||
@@ -595,7 +605,7 @@ test "Client: http valid handshake" {
|
||||
"Custom: Header-Value\r\n\r\n";
|
||||
|
||||
const res = try c.httpRequest(request);
|
||||
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
|
||||
@@ -723,7 +733,7 @@ test "server: 404" {
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
|
||||
try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++
|
||||
try testing.expectEqual("HTTP/1.1 404 \r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Length: 9\r\n\r\n" ++
|
||||
"Not found", res);
|
||||
@@ -735,7 +745,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:9583/\"}";
|
||||
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"}";
|
||||
|
||||
{
|
||||
// twice on the same connection
|
||||
@@ -743,7 +753,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.expectEqualStrings(expected_response, res1);
|
||||
try testing.expectEqual(expected_response, res1);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -752,7 +762,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.expectEqualStrings(expected_response, res1);
|
||||
try testing.expectEqual(expected_response, res1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,7 +780,7 @@ fn assertHTTPError(
|
||||
.{ expected_status, expected_body.len, expected_body },
|
||||
);
|
||||
|
||||
try testing.expectEqualStrings(expected_response, res);
|
||||
try testing.expectEqual(expected_response, res);
|
||||
}
|
||||
|
||||
fn assertWebSocketError(close_code: u16, input: []const u8) !void {
|
||||
@@ -914,7 +924,7 @@ const TestClient = struct {
|
||||
"Custom: Header-Value\r\n\r\n";
|
||||
|
||||
const res = try self.httpRequest(request);
|
||||
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
|
||||
|
||||
@@ -32,10 +32,6 @@ const Robots = @import("../network/Robots.zig");
|
||||
const RobotStore = Robots.RobotStore;
|
||||
const WebBotAuth = @import("../network/WebBotAuth.zig");
|
||||
|
||||
const Cache = @import("../network/cache/Cache.zig");
|
||||
const CacheMetadata = Cache.CachedMetadata;
|
||||
const CachedResponse = Cache.CachedResponse;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
@@ -293,69 +289,7 @@ pub fn request(self: *Client, req: Request) !void {
|
||||
return self.fetchRobotsThenProcessRequest(robots_url, req);
|
||||
}
|
||||
|
||||
fn serveFromCache(req: Request, cached: *const CachedResponse) !void {
|
||||
const response = Response.fromCached(req.ctx, cached);
|
||||
|
||||
if (req.start_callback) |cb| {
|
||||
try cb(response);
|
||||
}
|
||||
|
||||
const proceed = try req.header_callback(response);
|
||||
if (!proceed) {
|
||||
switch (cached.data) {
|
||||
.buffer => |_| {},
|
||||
.file => |file| file.close(),
|
||||
}
|
||||
req.error_callback(req.ctx, error.Abort);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (cached.data) {
|
||||
.buffer => |data| {
|
||||
if (data.len > 0) {
|
||||
try req.data_callback(response, data);
|
||||
}
|
||||
},
|
||||
.file => |file| {
|
||||
defer file.close();
|
||||
var buf: [1024]u8 = undefined;
|
||||
var file_reader = file.reader(&buf);
|
||||
|
||||
const reader = &file_reader.interface;
|
||||
var read_buf: [1024]u8 = undefined;
|
||||
|
||||
while (true) {
|
||||
const curr = try reader.readSliceShort(&read_buf);
|
||||
if (curr == 0) break;
|
||||
try req.data_callback(response, read_buf[0..curr]);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try req.done_callback(req.ctx);
|
||||
}
|
||||
|
||||
fn processRequest(self: *Client, req: Request) !void {
|
||||
if (self.network.cache) |*cache| {
|
||||
if (req.method == .GET) {
|
||||
const arena = try self.network.app.arena_pool.acquire();
|
||||
defer self.network.app.arena_pool.release(arena);
|
||||
|
||||
if (cache.get(arena, .{ .url = req.url, .timestamp = std.time.timestamp() })) |cached| {
|
||||
log.debug(.browser, "http.cache.get", .{
|
||||
.url = req.url,
|
||||
.found = true,
|
||||
.metadata = cached.metadata,
|
||||
});
|
||||
|
||||
defer req.headers.deinit();
|
||||
return serveFromCache(req, &cached);
|
||||
} else {
|
||||
log.debug(.browser, "http.cache.get", .{ .url = req.url, .found = false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transfer = try self.makeTransfer(req);
|
||||
|
||||
transfer.req.notification.dispatch(.http_request_start, &.{ .transfer = transfer });
|
||||
@@ -442,10 +376,8 @@ fn fetchRobotsThenProcessRequest(self: *Client, robots_url: [:0]const u8, req: R
|
||||
try entry.value_ptr.append(self.allocator, req);
|
||||
}
|
||||
|
||||
fn robotsHeaderCallback(response: Response) !bool {
|
||||
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
|
||||
// Robots callbacks only happen on real live requests.
|
||||
const transfer = response.inner.live;
|
||||
fn robotsHeaderCallback(transfer: *Transfer) !bool {
|
||||
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
if (transfer.response_header) |hdr| {
|
||||
log.debug(.browser, "robots status", .{ .status = hdr.status, .robots_url = ctx.robots_url });
|
||||
@@ -459,8 +391,8 @@ fn robotsHeaderCallback(response: Response) !bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn robotsDataCallback(response: Response, data: []const u8) !void {
|
||||
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(response.ctx));
|
||||
fn robotsDataCallback(transfer: *Transfer, data: []const u8) !void {
|
||||
const ctx: *RobotsRequestContext = @ptrCast(@alignCast(transfer.ctx));
|
||||
try ctx.buffer.appendSlice(ctx.client.allocator, data);
|
||||
}
|
||||
|
||||
@@ -679,6 +611,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
|
||||
.id = id,
|
||||
.url = req.url,
|
||||
.req = req,
|
||||
.ctx = req.ctx,
|
||||
.client = self,
|
||||
.max_response_size = self.network.config.httpMaxResponseSize(),
|
||||
};
|
||||
@@ -701,9 +634,9 @@ fn requestFailed(transfer: *Transfer, err: anyerror, comptime execute_callback:
|
||||
});
|
||||
|
||||
if (execute_callback) {
|
||||
transfer.req.error_callback(transfer.req.ctx, err);
|
||||
transfer.req.error_callback(transfer.ctx, err);
|
||||
} else if (transfer.req.shutdown_callback) |cb| {
|
||||
cb(transfer.req.ctx);
|
||||
cb(transfer.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +743,7 @@ fn makeRequest(self: *Client, conn: *Net.Connection, transfer: *Transfer) anyerr
|
||||
};
|
||||
|
||||
if (req.start_callback) |cb| {
|
||||
cb(Response.fromLive(transfer)) catch |err| {
|
||||
cb(transfer) catch |err| {
|
||||
transfer.deinit();
|
||||
return err;
|
||||
};
|
||||
@@ -915,14 +848,28 @@ 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) |err| {
|
||||
requestFailed(transfer, err, true);
|
||||
if (msg.err != null and !is_conn_close_recv) {
|
||||
requestFailed(transfer, msg.err.?, true);
|
||||
} else blk: {
|
||||
// make sure the transfer can't be immediately aborted from a callback
|
||||
// since we still need it here.
|
||||
@@ -942,31 +889,18 @@ fn processMessages(self: *Client) !bool {
|
||||
break :blk;
|
||||
}
|
||||
}
|
||||
transfer.req.done_callback(transfer.req.ctx) catch |err| {
|
||||
transfer.req.done_callback(transfer.ctx) catch |err| {
|
||||
// transfer isn't valid at this point, don't use it.
|
||||
log.err(.http, "done_callback", .{ .err = err });
|
||||
requestFailed(transfer, err, true);
|
||||
continue;
|
||||
};
|
||||
|
||||
if (transfer.pending_cache_metadata) |metadata| {
|
||||
const cache = &self.network.cache.?;
|
||||
|
||||
// TODO: Support Vary Keying
|
||||
const cache_key = transfer.req.url;
|
||||
|
||||
log.debug(.browser, "http cache", .{ .key = cache_key, .metadata = metadata });
|
||||
cache.put(metadata, transfer.pending_cache_body.items) catch |err| {
|
||||
log.warn(.http, "cache put failed", .{ .err = err });
|
||||
};
|
||||
log.debug(.browser, "http.cache.put", .{ .url = transfer.req.url });
|
||||
}
|
||||
transfer.req.notification.dispatch(.http_request_done, &.{
|
||||
.transfer = transfer,
|
||||
});
|
||||
processed = true;
|
||||
}
|
||||
|
||||
transfer.req.notification.dispatch(.http_request_done, &.{
|
||||
.transfer = transfer,
|
||||
});
|
||||
processed = true;
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
@@ -1042,9 +976,9 @@ pub const Request = struct {
|
||||
// arbitrary data that can be associated with this request
|
||||
ctx: *anyopaque = undefined,
|
||||
|
||||
start_callback: ?*const fn (response: Response) anyerror!void = null,
|
||||
header_callback: *const fn (response: Response) anyerror!bool,
|
||||
data_callback: *const fn (response: Response, data: []const u8) anyerror!void,
|
||||
start_callback: ?*const fn (transfer: *Transfer) anyerror!void = null,
|
||||
header_callback: *const fn (transfer: *Transfer) anyerror!bool,
|
||||
data_callback: *const fn (transfer: *Transfer, data: []const u8) anyerror!void,
|
||||
done_callback: *const fn (ctx: *anyopaque) anyerror!void,
|
||||
error_callback: *const fn (ctx: *anyopaque, err: anyerror) void,
|
||||
shutdown_callback: ?*const fn (ctx: *anyopaque) void = null,
|
||||
@@ -1072,92 +1006,16 @@ pub const Request = struct {
|
||||
|
||||
const AuthChallenge = Net.AuthChallenge;
|
||||
|
||||
pub const Response = struct {
|
||||
ctx: *anyopaque,
|
||||
inner: union(enum) {
|
||||
live: *Transfer,
|
||||
cached: *const CachedResponse,
|
||||
},
|
||||
|
||||
pub fn fromLive(transfer: *Transfer) Response {
|
||||
return .{ .ctx = transfer.req.ctx, .inner = .{ .live = transfer } };
|
||||
}
|
||||
|
||||
pub fn fromCached(ctx: *anyopaque, resp: *const CachedResponse) Response {
|
||||
return .{ .ctx = ctx, .inner = .{ .cached = resp } };
|
||||
}
|
||||
|
||||
pub fn status(self: Response) ?u16 {
|
||||
return switch (self.inner) {
|
||||
.live => |live| if (live.response_header) |rh| rh.status else null,
|
||||
.cached => |c| c.metadata.status,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn contentType(self: Response) ?[]const u8 {
|
||||
return switch (self.inner) {
|
||||
.live => |live| if (live.response_header) |*rh| rh.contentType() else null,
|
||||
.cached => |c| c.metadata.content_type,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn contentLength(self: Response) ?u32 {
|
||||
return switch (self.inner) {
|
||||
.live => |live| live.getContentLength(),
|
||||
.cached => |c| switch (c.data) {
|
||||
.buffer => |buf| @intCast(buf.len),
|
||||
.file => |f| @intCast(f.getEndPos() catch 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn redirectCount(self: Response) ?u32 {
|
||||
return switch (self.inner) {
|
||||
.live => |live| if (live.response_header) |rh| rh.redirect_count else null,
|
||||
.cached => 0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn url(self: Response) [:0]const u8 {
|
||||
return switch (self.inner) {
|
||||
.live => |live| live.url,
|
||||
.cached => |c| c.metadata.url,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn headerIterator(self: Response) HeaderIterator {
|
||||
return switch (self.inner) {
|
||||
.live => |live| live.responseHeaderIterator(),
|
||||
.cached => |c| HeaderIterator{ .list = .{ .list = c.metadata.headers } },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn abort(self: Response, err: anyerror) void {
|
||||
switch (self.inner) {
|
||||
.live => |live| live.abort(err),
|
||||
.cached => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminate(self: Response) void {
|
||||
switch (self.inner) {
|
||||
.live => |live| live.terminate(),
|
||||
.cached => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const Transfer = struct {
|
||||
arena: ArenaAllocator,
|
||||
id: u32 = 0,
|
||||
req: Request,
|
||||
url: [:0]const u8,
|
||||
ctx: *anyopaque, // copied from req.ctx to make it easier for callback handlers
|
||||
client: *Client,
|
||||
// total bytes received in the response, including the response status line,
|
||||
// the headers, and the [encoded] body.
|
||||
bytes_received: usize = 0,
|
||||
pending_cache_body: std.ArrayList(u8) = .empty,
|
||||
pending_cache_metadata: ?CacheMetadata = null,
|
||||
|
||||
aborted: bool = false,
|
||||
|
||||
@@ -1209,8 +1067,6 @@ pub const Transfer = struct {
|
||||
self._notified_fail = false;
|
||||
self.response_header = null;
|
||||
self.bytes_received = 0;
|
||||
self.pending_cache_metadata = null;
|
||||
self.pending_cache_body = .empty;
|
||||
|
||||
self._tries += 1;
|
||||
}
|
||||
@@ -1319,7 +1175,7 @@ pub const Transfer = struct {
|
||||
self.client.endTransfer(self);
|
||||
}
|
||||
if (self.req.shutdown_callback) |cb| {
|
||||
cb(self.req.ctx);
|
||||
cb(self.ctx);
|
||||
}
|
||||
self.deinit();
|
||||
}
|
||||
@@ -1421,48 +1277,11 @@ pub const Transfer = struct {
|
||||
}
|
||||
}
|
||||
|
||||
const proceed = transfer.req.header_callback(Response.fromLive(transfer)) catch |err| {
|
||||
const proceed = transfer.req.header_callback(transfer) catch |err| {
|
||||
log.err(.http, "header_callback", .{ .err = err, .req = transfer });
|
||||
return err;
|
||||
};
|
||||
|
||||
if (transfer.client.network.cache != null and transfer.req.method == .GET) {
|
||||
const rh = &transfer.response_header.?;
|
||||
const allocator = transfer.arena.allocator();
|
||||
|
||||
const maybe_cm = try Cache.tryCache(
|
||||
allocator,
|
||||
std.time.timestamp(),
|
||||
transfer.url,
|
||||
rh.status,
|
||||
rh.contentType(),
|
||||
if (conn.getResponseHeader("cache-control", 0)) |h| h.value else null,
|
||||
if (conn.getResponseHeader("vary", 0)) |h| h.value else null,
|
||||
if (conn.getResponseHeader("etag", 0)) |h| h.value else null,
|
||||
if (conn.getResponseHeader("last-modified", 0)) |h| h.value else null,
|
||||
if (conn.getResponseHeader("age", 0)) |h| h.value else null,
|
||||
conn.getResponseHeader("set-cookie", 0) != null,
|
||||
conn.getResponseHeader("authorization", 0) != null,
|
||||
);
|
||||
|
||||
if (maybe_cm) |cm| {
|
||||
var header_list: std.ArrayList(Net.Header) = .empty;
|
||||
var it = transfer.responseHeaderIterator();
|
||||
while (it.next()) |hdr| {
|
||||
try header_list.append(allocator, .{
|
||||
.name = try allocator.dupe(u8, hdr.name),
|
||||
.value = try allocator.dupe(u8, hdr.value),
|
||||
});
|
||||
}
|
||||
|
||||
transfer.pending_cache_metadata = cm;
|
||||
transfer.pending_cache_metadata.?.headers = header_list.items;
|
||||
if (transfer.getContentLength()) |cl| {
|
||||
try transfer.pending_cache_body.ensureTotalCapacity(allocator, cl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transfer.req.notification.dispatch(.http_response_header_done, &.{
|
||||
.transfer = transfer,
|
||||
});
|
||||
@@ -1623,14 +1442,7 @@ pub const Transfer = struct {
|
||||
}
|
||||
|
||||
const chunk = buffer[0..chunk_len];
|
||||
if (transfer.pending_cache_metadata != null) {
|
||||
transfer.pending_cache_body.appendSlice(transfer.arena.allocator(), chunk) catch |err| {
|
||||
log.err(.http, "cache body append", .{ .err = err, .req = transfer });
|
||||
return Net.writefunc_error;
|
||||
};
|
||||
}
|
||||
|
||||
transfer.req.data_callback(Response.fromLive(transfer), chunk) catch |err| {
|
||||
transfer.req.data_callback(transfer, chunk) catch |err| {
|
||||
log.err(.http, "data_callback", .{ .err = err, .req = transfer });
|
||||
return Net.writefunc_error;
|
||||
};
|
||||
@@ -1682,7 +1494,7 @@ pub const Transfer = struct {
|
||||
fn _fulfill(transfer: *Transfer, status: u16, headers: []const Net.Header, body: ?[]const u8) !void {
|
||||
const req = &transfer.req;
|
||||
if (req.start_callback) |cb| {
|
||||
try cb(Response.fromLive(transfer));
|
||||
try cb(transfer);
|
||||
}
|
||||
|
||||
transfer.response_header = .{
|
||||
@@ -1701,13 +1513,13 @@ pub const Transfer = struct {
|
||||
}
|
||||
|
||||
lp.assert(transfer._header_done_called == false, "Transfer.fulfill header_done_called", .{});
|
||||
if (try req.header_callback(Response.fromLive(transfer)) == false) {
|
||||
if (try req.header_callback(transfer) == false) {
|
||||
transfer.abort(error.Abort);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body) |b| {
|
||||
try req.data_callback(Response.fromLive(transfer), b);
|
||||
try req.data_callback(transfer, b);
|
||||
}
|
||||
|
||||
try req.done_callback(req.ctx);
|
||||
|
||||
@@ -27,9 +27,6 @@ charset: [41]u8 = default_charset,
|
||||
charset_len: usize = default_charset_len,
|
||||
is_default_charset: bool = true,
|
||||
|
||||
type_buf: [127]u8 = @splat(0),
|
||||
sub_type_buf: [127]u8 = @splat(0),
|
||||
|
||||
/// String "UTF-8" continued by null characters.
|
||||
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
|
||||
const default_charset_len = 5;
|
||||
@@ -64,10 +61,7 @@ pub const ContentType = union(ContentTypeEnum) {
|
||||
image_webp: void,
|
||||
application_json: void,
|
||||
unknown: void,
|
||||
other: struct {
|
||||
type: []const u8,
|
||||
sub_type: []const u8,
|
||||
},
|
||||
other: struct { type: []const u8, sub_type: []const u8 },
|
||||
};
|
||||
|
||||
pub fn contentTypeString(mime: *const Mime) []const u8 {
|
||||
@@ -118,18 +112,17 @@ fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
|
||||
return value;
|
||||
}
|
||||
|
||||
pub fn parse(input: []const u8) !Mime {
|
||||
pub fn parse(input: []u8) !Mime {
|
||||
if (input.len > 255) {
|
||||
return error.TooBig;
|
||||
}
|
||||
|
||||
var buf: [255]u8 = undefined;
|
||||
const normalized = std.ascii.lowerString(&buf, std.mem.trim(u8, input, &std.ascii.whitespace));
|
||||
// Zig's trim API is broken. The return type is always `[]const u8`,
|
||||
// even if the input type is `[]u8`. @constCast is safe here.
|
||||
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
|
||||
_ = std.ascii.lowerString(normalized, normalized);
|
||||
|
||||
var mime = Mime{ .content_type = undefined };
|
||||
|
||||
const content_type, const type_len = try parseContentType(normalized, &mime.type_buf, &mime.sub_type_buf);
|
||||
const content_type, const type_len = try parseContentType(normalized);
|
||||
if (type_len >= normalized.len) {
|
||||
return .{ .content_type = content_type };
|
||||
}
|
||||
@@ -170,12 +163,13 @@ pub fn parse(input: []const u8) !Mime {
|
||||
}
|
||||
}
|
||||
|
||||
mime.params = params;
|
||||
mime.charset = charset;
|
||||
mime.charset_len = charset_len;
|
||||
mime.content_type = content_type;
|
||||
mime.is_default_charset = !has_explicit_charset;
|
||||
return mime;
|
||||
return .{
|
||||
.params = params,
|
||||
.charset = charset,
|
||||
.charset_len = charset_len,
|
||||
.content_type = content_type,
|
||||
.is_default_charset = !has_explicit_charset,
|
||||
};
|
||||
}
|
||||
|
||||
/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
|
||||
@@ -392,8 +386,16 @@ 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, type_buf: []u8, sub_type_buf: []u8) !struct { ContentType, usize } {
|
||||
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
|
||||
const type_name = trimRight(value[0..end]);
|
||||
const attribute_start = end + 1;
|
||||
@@ -442,18 +444,10 @@ fn parseContentType(value: []const u8, type_buf: []u8, sub_type_buf: []u8) !stru
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
@memcpy(type_buf[0..main_type.len], main_type);
|
||||
@memcpy(sub_type_buf[0..sub_type.len], sub_type);
|
||||
|
||||
return .{
|
||||
.{
|
||||
.other = .{
|
||||
.type = type_buf[0..main_type.len],
|
||||
.sub_type = sub_type_buf[0..sub_type.len],
|
||||
},
|
||||
},
|
||||
attribute_start,
|
||||
};
|
||||
return .{ .{ .other = .{
|
||||
.type = main_type,
|
||||
.sub_type = sub_type,
|
||||
} }, attribute_start };
|
||||
}
|
||||
|
||||
const VALID_CODEPOINTS = blk: {
|
||||
@@ -467,13 +461,6 @@ const VALID_CODEPOINTS = blk: {
|
||||
break :blk v;
|
||||
};
|
||||
|
||||
pub fn typeString(self: *const Mime) []const u8 {
|
||||
return switch (self.content_type) {
|
||||
.other => |o| o.type[0..o.type_len],
|
||||
else => "",
|
||||
};
|
||||
}
|
||||
|
||||
fn validType(value: []const u8) bool {
|
||||
for (value) |b| {
|
||||
if (VALID_CODEPOINTS[b] == false) {
|
||||
|
||||
@@ -35,6 +35,7 @@ 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");
|
||||
|
||||
@@ -60,6 +61,7 @@ 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");
|
||||
@@ -144,6 +146,7 @@ _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)
|
||||
@@ -269,6 +272,7 @@ 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),
|
||||
};
|
||||
@@ -296,13 +300,22 @@ 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);
|
||||
self.js = try browser.env.createContext(self, .{
|
||||
.identity = &session.identity,
|
||||
.identity_arena = session.page_arena,
|
||||
.call_arena = self.call_arena,
|
||||
});
|
||||
errdefer self.js.deinit();
|
||||
|
||||
document._page = self;
|
||||
@@ -356,6 +369,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
|
||||
}
|
||||
|
||||
self._script_manager.deinit();
|
||||
self._style_manager.deinit();
|
||||
|
||||
session.releaseArena(self.call_arena);
|
||||
}
|
||||
@@ -437,6 +451,12 @@ 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]);
|
||||
@@ -583,13 +603,34 @@ 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,
|
||||
originator.base(),
|
||||
page_base,
|
||||
request_url,
|
||||
.{ .always_dupe = true, .encode = true },
|
||||
);
|
||||
@@ -821,10 +862,12 @@ fn notifyParentLoadComplete(self: *Page) void {
|
||||
parent.iframeCompletedLoading(self.iframe.?);
|
||||
}
|
||||
|
||||
fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
|
||||
var self: *Page = @ptrCast(@alignCast(response.ctx));
|
||||
fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
const response_url = response.url();
|
||||
const header = &transfer.response_header.?;
|
||||
|
||||
const response_url = std.mem.span(header.url);
|
||||
if (std.mem.eql(u8, response_url, self.url) == false) {
|
||||
// would be different than self.url in the case of a redirect
|
||||
self.url = try self.arena.dupeZ(u8, response_url);
|
||||
@@ -838,8 +881,8 @@ fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "navigate header", .{
|
||||
.url = self.url,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.status = header.status,
|
||||
.content_type = header.contentType(),
|
||||
.type = self._type,
|
||||
});
|
||||
}
|
||||
@@ -847,14 +890,14 @@ fn pageHeaderDoneCallback(response: HttpClient.Response) !bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
fn pageDataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
var self: *Page = @ptrCast(@alignCast(response.ctx));
|
||||
fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
var self: *Page = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
if (self._parse_state == .pre) {
|
||||
// we lazily do this, because we might need the first chunk of data
|
||||
// to sniff the content type
|
||||
var mime: Mime = blk: {
|
||||
if (response.contentType()) |ct| {
|
||||
if (transfer.response_header.?.contentType()) |ct| {
|
||||
break :blk try Mime.parse(ct);
|
||||
}
|
||||
break :blk Mime.sniff(data);
|
||||
@@ -2555,6 +2598,17 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2566,8 +2620,10 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
|
||||
self.domChanged();
|
||||
const dest_connected = target.isConnected();
|
||||
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
// 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| {
|
||||
// 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 });
|
||||
@@ -2579,8 +2635,10 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_
|
||||
self.domChanged();
|
||||
const dest_connected = parent.isConnected();
|
||||
|
||||
var it = fragment.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
// 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| {
|
||||
// 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 });
|
||||
@@ -3432,7 +3490,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form
|
||||
};
|
||||
|
||||
if (submit_opts.fire_event) {
|
||||
const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self);
|
||||
const submitter_html: ?*HtmlElement = if (submitter_) |s| s.is(HtmlElement) else null;
|
||||
const submit_event = (try SubmitEvent.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true, .submitter = submitter_html }, self)).asEvent();
|
||||
|
||||
// so submit_event is still valid when we check _prevent_default
|
||||
submit_event.acquireRef();
|
||||
@@ -3530,9 +3589,6 @@ test "WebApi: Page" {
|
||||
}
|
||||
|
||||
test "WebApi: Frames" {
|
||||
const filter: testing.LogFilter = .init(&.{.js});
|
||||
defer filter.deinit();
|
||||
|
||||
try testing.htmlRunner("frames", .{});
|
||||
}
|
||||
|
||||
|
||||
241
src/browser/Runner.zig
Normal file
241
src/browser/Runner.zig
Normal file
@@ -0,0 +1,241 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -694,86 +694,85 @@ pub const Script = struct {
|
||||
self.manager.page.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
fn startCallback(response: HttpClient.Response) !void {
|
||||
log.debug(.http, "script fetch start", .{ .req = response });
|
||||
fn startCallback(transfer: *HttpClient.Transfer) !void {
|
||||
log.debug(.http, "script fetch start", .{ .req = transfer });
|
||||
}
|
||||
|
||||
fn headerCallback(response: HttpClient.Response) !bool {
|
||||
const self: *Script = @ptrCast(@alignCast(response.ctx));
|
||||
|
||||
self.status = response.status().?;
|
||||
if (response.status() != 200) {
|
||||
fn headerCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||
const header = &transfer.response_header.?;
|
||||
self.status = header.status;
|
||||
if (header.status != 200) {
|
||||
log.info(.http, "script header", .{
|
||||
.req = response,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.req = transfer,
|
||||
.status = header.status,
|
||||
.content_type = header.contentType(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "script header", .{
|
||||
.req = response,
|
||||
.status = response.status(),
|
||||
.content_type = response.contentType(),
|
||||
.req = transfer,
|
||||
.status = header.status,
|
||||
.content_type = header.contentType(),
|
||||
});
|
||||
}
|
||||
|
||||
// {
|
||||
// // temp debug, trying to figure out why the next assert sometimes
|
||||
// // fails. Is the buffer just corrupt or is headerCallback really
|
||||
// // being called twice?
|
||||
// lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
|
||||
// .m = @tagName(std.meta.activeTag(self.mode)),
|
||||
// .a1 = self.debug_transfer_id,
|
||||
// .a2 = self.debug_transfer_tries,
|
||||
// .a3 = self.debug_transfer_aborted,
|
||||
// .a4 = self.debug_transfer_bytes_received,
|
||||
// .a5 = self.debug_transfer_notified_fail,
|
||||
// .a6 = self.debug_transfer_redirecting,
|
||||
// .a7 = self.debug_transfer_intercept_state,
|
||||
// .a8 = self.debug_transfer_auth_challenge,
|
||||
// .a9 = self.debug_transfer_easy_id,
|
||||
// .b1 = transfer.id,
|
||||
// .b2 = transfer._tries,
|
||||
// .b3 = transfer.aborted,
|
||||
// .b4 = transfer.bytes_received,
|
||||
// .b5 = transfer._notified_fail,
|
||||
// .b6 = transfer._redirecting,
|
||||
// .b7 = @intFromEnum(transfer._intercept_state),
|
||||
// .b8 = transfer._auth_challenge != null,
|
||||
// .b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
|
||||
// });
|
||||
// self.header_callback_called = true;
|
||||
// self.debug_transfer_id = transfer.id;
|
||||
// self.debug_transfer_tries = transfer._tries;
|
||||
// self.debug_transfer_aborted = transfer.aborted;
|
||||
// self.debug_transfer_bytes_received = transfer.bytes_received;
|
||||
// self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
// self.debug_transfer_redirecting = transfer._redirecting;
|
||||
// self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
|
||||
// self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||
// self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
|
||||
// }
|
||||
{
|
||||
// temp debug, trying to figure out why the next assert sometimes
|
||||
// fails. Is the buffer just corrupt or is headerCallback really
|
||||
// being called twice?
|
||||
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
|
||||
.m = @tagName(std.meta.activeTag(self.mode)),
|
||||
.a1 = self.debug_transfer_id,
|
||||
.a2 = self.debug_transfer_tries,
|
||||
.a3 = self.debug_transfer_aborted,
|
||||
.a4 = self.debug_transfer_bytes_received,
|
||||
.a5 = self.debug_transfer_notified_fail,
|
||||
.a6 = self.debug_transfer_redirecting,
|
||||
.a7 = self.debug_transfer_intercept_state,
|
||||
.a8 = self.debug_transfer_auth_challenge,
|
||||
.a9 = self.debug_transfer_easy_id,
|
||||
.b1 = transfer.id,
|
||||
.b2 = transfer._tries,
|
||||
.b3 = transfer.aborted,
|
||||
.b4 = transfer.bytes_received,
|
||||
.b5 = transfer._notified_fail,
|
||||
.b6 = transfer._redirecting,
|
||||
.b7 = @intFromEnum(transfer._intercept_state),
|
||||
.b8 = transfer._auth_challenge != null,
|
||||
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0,
|
||||
});
|
||||
self.header_callback_called = true;
|
||||
self.debug_transfer_id = transfer.id;
|
||||
self.debug_transfer_tries = transfer._tries;
|
||||
self.debug_transfer_aborted = transfer.aborted;
|
||||
self.debug_transfer_bytes_received = transfer.bytes_received;
|
||||
self.debug_transfer_notified_fail = transfer._notified_fail;
|
||||
self.debug_transfer_redirecting = transfer._redirecting;
|
||||
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
|
||||
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
|
||||
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0;
|
||||
}
|
||||
|
||||
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
|
||||
var buffer: std.ArrayList(u8) = .empty;
|
||||
if (response.contentLength()) |cl| {
|
||||
if (transfer.getContentLength()) |cl| {
|
||||
try buffer.ensureTotalCapacity(self.arena, cl);
|
||||
}
|
||||
self.source = .{ .remote = buffer };
|
||||
return true;
|
||||
}
|
||||
|
||||
fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
const self: *Script = @ptrCast(@alignCast(response.ctx));
|
||||
self._dataCallback(response, data) catch |err| {
|
||||
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
|
||||
fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
const self: *Script = @ptrCast(@alignCast(transfer.ctx));
|
||||
self._dataCallback(transfer, data) catch |err| {
|
||||
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
|
||||
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
|
||||
try self.source.remote.appendSlice(self.arena, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ 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");
|
||||
@@ -65,36 +67,41 @@ 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,
|
||||
|
||||
queued_navigation: std.ArrayList(*Page),
|
||||
// Double buffer so that, as we process one list of queued navigations, new entries
|
||||
// are added to the separate buffer. This ensures that we don't end up with
|
||||
// endless navigation loops AND that we don't invalidate the list while iterating
|
||||
// if a new entry gets appended
|
||||
queued_navigation_1: std.ArrayList(*Page),
|
||||
queued_navigation_2: std.ArrayList(*Page),
|
||||
// pointer to either queued_navigation_1 or queued_navigation_2
|
||||
queued_navigation: *std.ArrayList(*Page),
|
||||
|
||||
// Temporary buffer for about:blank navigations during processing.
|
||||
// We process async navigations first (safe from re-entrance), then sync
|
||||
// about:blank navigations (which may add to queued_navigation).
|
||||
queued_queued_navigation: std.ArrayList(*Page),
|
||||
|
||||
page_id_gen: u32,
|
||||
frame_id_gen: u32,
|
||||
page_id_gen: u32 = 0,
|
||||
frame_id_gen: u32 = 0,
|
||||
|
||||
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();
|
||||
const arena = try arena_pool.acquire(.{ .debug = "Session" });
|
||||
errdefer arena_pool.release(arena);
|
||||
|
||||
const page_arena = try arena_pool.acquire();
|
||||
const page_arena = try arena_pool.acquire(.{ .debug = "Session.page_arena" });
|
||||
errdefer arena_pool.release(page_arena);
|
||||
|
||||
self.* = .{
|
||||
@@ -104,17 +111,18 @@ 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 = .{},
|
||||
.queued_navigation = undefined,
|
||||
.queued_navigation_1 = .{},
|
||||
.queued_navigation_2 = .{},
|
||||
.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 {
|
||||
@@ -171,32 +179,11 @@ pub const GetArenaOpts = struct {
|
||||
};
|
||||
|
||||
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
|
||||
const allocator = try self.arena_pool.acquire();
|
||||
if (comptime IS_DEBUG) {
|
||||
// Use session's arena (not page_arena) since page_arena gets reset between pages
|
||||
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
|
||||
if (gop.found_existing and gop.value_ptr.count != 0) {
|
||||
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
|
||||
@panic("ArenaPool Double Use");
|
||||
}
|
||||
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
|
||||
}
|
||||
return allocator;
|
||||
return self.arena_pool.acquire(.{ .debug = opts.debug });
|
||||
}
|
||||
|
||||
pub fn releaseArena(self: *Session, allocator: Allocator) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
|
||||
if (found.count != 1) {
|
||||
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
|
||||
if (comptime builtin.is_test) {
|
||||
@panic("ArenaPool Double Free");
|
||||
}
|
||||
return;
|
||||
}
|
||||
found.count = 0;
|
||||
}
|
||||
return self.arena_pool.release(allocator);
|
||||
self.arena_pool.release(allocator);
|
||||
}
|
||||
|
||||
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
|
||||
@@ -237,18 +224,9 @@ pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
|
||||
/// Reset page_arena and factory for a clean slate.
|
||||
/// Called when root page is removed.
|
||||
fn resetPageResources(self: *Session) void {
|
||||
// Check for arena leaks before releasing
|
||||
if (comptime IS_DEBUG) {
|
||||
var it = self._arena_pool_leak_track.valueIterator();
|
||||
while (it.next()) |value_ptr| {
|
||||
if (value_ptr.count > 0) {
|
||||
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
|
||||
}
|
||||
}
|
||||
self._arena_pool_leak_track.clearRetainingCapacity();
|
||||
}
|
||||
self.identity.deinit();
|
||||
self.identity = .{};
|
||||
|
||||
// All origins should have been released when contexts were destroyed
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(self.origins.count() == 0);
|
||||
}
|
||||
@@ -259,10 +237,9 @@ fn resetPageResources(self: *Session) void {
|
||||
while (it.next()) |value| {
|
||||
value.*.deinit(app);
|
||||
}
|
||||
self.origins.clearRetainingCapacity();
|
||||
self.origins = .empty;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -293,12 +270,6 @@ 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);
|
||||
@@ -319,194 +290,12 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
|
||||
var page = &(self.page orelse return .no_page);
|
||||
while (true) {
|
||||
const wait_result = self._wait(page, wait_ms) catch |err| {
|
||||
switch (err) {
|
||||
error.JsError => {}, // already logged (with hopefully more context)
|
||||
else => log.err(.browser, "session wait", .{
|
||||
.err = err,
|
||||
.url = page.url,
|
||||
}),
|
||||
}
|
||||
return .done;
|
||||
};
|
||||
|
||||
switch (wait_result) {
|
||||
.done => {
|
||||
if (self.queued_navigation.items.len == 0) {
|
||||
return .done;
|
||||
}
|
||||
self.processQueuedNavigation() catch return .done;
|
||||
page = &self.page.?; // might have changed
|
||||
},
|
||||
else => |result| return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 runner(self: *Session, opts: Runner.Opts) !Runner {
|
||||
return Runner.init(self, opts);
|
||||
}
|
||||
|
||||
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| {
|
||||
@@ -519,8 +308,13 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
||||
return list.append(self.arena, page);
|
||||
}
|
||||
|
||||
fn processQueuedNavigation(self: *Session) !void {
|
||||
const navigations = &self.queued_navigation;
|
||||
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;
|
||||
}
|
||||
|
||||
if (self.page.?._queued_navigation != null) {
|
||||
// This is both an optimization and a simplification of sorts. If the
|
||||
@@ -536,7 +330,6 @@ fn processQueuedNavigation(self: *Session) !void {
|
||||
defer about_blank_queue.clearRetainingCapacity();
|
||||
|
||||
// First pass: process async navigations (non-about:blank)
|
||||
// These cannot cause re-entrant navigation scheduling
|
||||
for (navigations.items) |page| {
|
||||
const qn = page._queued_navigation.?;
|
||||
|
||||
@@ -551,7 +344,6 @@ fn processQueuedNavigation(self: *Session) !void {
|
||||
};
|
||||
}
|
||||
|
||||
// Clear the queue after first pass
|
||||
navigations.clearRetainingCapacity();
|
||||
|
||||
// Second pass: process synchronous navigations (about:blank)
|
||||
@@ -561,15 +353,17 @@ fn processQueuedNavigation(self: *Session) !void {
|
||||
try self.processFrameNavigation(page, qn);
|
||||
}
|
||||
|
||||
// Safety: Remove any about:blank navigations that were queued during the
|
||||
// second pass to prevent infinite loops
|
||||
// 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;
|
||||
var i: usize = 0;
|
||||
while (i < navigations.items.len) {
|
||||
const page = navigations.items[i];
|
||||
while (i < new_navigations.items.len) {
|
||||
const page = new_navigations.items[i];
|
||||
if (page._queued_navigation) |qn| {
|
||||
if (qn.is_about_blank) {
|
||||
log.warn(.page, "recursive about blank", .{});
|
||||
_ = navigations.swapRemove(i);
|
||||
log.warn(.page, "recursive about blank", .{});
|
||||
_ = self.queued_navigation.swapRemove(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -632,16 +426,6 @@ 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);
|
||||
@@ -672,3 +456,36 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
855
src/browser/StyleManager.zig
Normal file
855
src/browser/StyleManager.zig
Normal file
@@ -0,0 +1,855 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
|
||||
const CssParser = @import("css/Parser.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
const SelectorParser = @import("webapi/selector/Parser.zig");
|
||||
const SelectorList = @import("webapi/selector/List.zig");
|
||||
|
||||
const CSSStyleRule = @import("webapi/css/CSSStyleRule.zig");
|
||||
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
|
||||
const CSSStyleProperties = @import("webapi/css/CSSStyleProperties.zig");
|
||||
const CSSStyleProperty = @import("webapi/css/CSSStyleDeclaration.zig").Property;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const VisibilityCache = std.AutoHashMapUnmanaged(*Element, bool);
|
||||
pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
|
||||
|
||||
// Tracks visibility-relevant CSS rules from <style> elements.
|
||||
// Rules are bucketed by their rightmost selector part for fast lookup.
|
||||
const StyleManager = @This();
|
||||
|
||||
const Tag = Element.Tag;
|
||||
const RuleList = std.MultiArrayList(VisibilityRule);
|
||||
|
||||
page: *Page,
|
||||
|
||||
arena: Allocator,
|
||||
|
||||
// Bucketed rules for fast lookup - keyed by rightmost selector part
|
||||
id_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
|
||||
class_rules: std.StringHashMapUnmanaged(RuleList) = .empty,
|
||||
tag_rules: std.AutoHashMapUnmanaged(Tag, RuleList) = .empty,
|
||||
other_rules: RuleList = .empty, // universal, attribute, pseudo-class endings
|
||||
|
||||
// Document order counter for tie-breaking equal specificity
|
||||
next_doc_order: u32 = 0,
|
||||
|
||||
// When true, rules need to be rebuilt
|
||||
dirty: bool = false,
|
||||
|
||||
pub fn init(page: *Page) !StyleManager {
|
||||
return .{
|
||||
.page = page,
|
||||
.arena = try page.getArena(.{ .debug = "StyleManager" }),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *StyleManager) void {
|
||||
self.page.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
|
||||
if (sheet._css_rules) |css_rules| {
|
||||
for (css_rules._rules.items) |rule| {
|
||||
const style_rule = rule.is(CSSStyleRule) orelse continue;
|
||||
try self.addRule(style_rule);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const owner_node = sheet.getOwnerNode() orelse return;
|
||||
if (owner_node.is(Element.Html.Style)) |style| {
|
||||
const text = try style.asNode().getTextContentAlloc(self.arena);
|
||||
var it = CssParser.parseStylesheet(text);
|
||||
while (it.next()) |parsed_rule| {
|
||||
try self.addRawRule(parsed_rule.selector, parsed_rule.block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []const u8) !void {
|
||||
if (selector_text.len == 0) return;
|
||||
|
||||
var props = VisibilityProperties{};
|
||||
var it = CssParser.parseDeclarationsList(block_text);
|
||||
while (it.next()) |decl| {
|
||||
const name = decl.name;
|
||||
const val = decl.value;
|
||||
if (std.ascii.eqlIgnoreCase(name, "display")) {
|
||||
props.display_none = std.ascii.eqlIgnoreCase(val, "none");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "visibility")) {
|
||||
props.visibility_hidden = std.ascii.eqlIgnoreCase(val, "hidden") or std.ascii.eqlIgnoreCase(val, "collapse");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "opacity")) {
|
||||
props.opacity_zero = std.ascii.eqlIgnoreCase(val, "0");
|
||||
} else if (std.ascii.eqlIgnoreCase(name, "pointer-events")) {
|
||||
props.pointer_events_none = std.ascii.eqlIgnoreCase(val, "none");
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.isRelevant()) return;
|
||||
|
||||
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
|
||||
for (selectors) |selector| {
|
||||
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
|
||||
const bucket_key = getBucketKey(rightmost) orelse continue;
|
||||
const rule = VisibilityRule{
|
||||
.props = props,
|
||||
.selector = selector,
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||
};
|
||||
self.next_doc_order += 1;
|
||||
|
||||
switch (bucket_key) {
|
||||
.id => |id| {
|
||||
const gop = try self.id_rules.getOrPut(self.arena, id);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.class => |class| {
|
||||
const gop = try self.class_rules.getOrPut(self.arena, class);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.tag => |tag| {
|
||||
const gop = try self.tag_rules.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.other => {
|
||||
try self.other_rules.append(self.arena, rule);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sheetRemoved(self: *StyleManager) void {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn sheetModified(self: *StyleManager) void {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Rebuilds the rule list from all document stylesheets.
|
||||
/// Called lazily when dirty flag is set and rules are needed.
|
||||
fn rebuildIfDirty(self: *StyleManager) !void {
|
||||
if (!self.dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.dirty = false;
|
||||
errdefer self.dirty = true;
|
||||
const id_rules_count = self.id_rules.count();
|
||||
const class_rules_count = self.class_rules.count();
|
||||
const tag_rules_count = self.tag_rules.count();
|
||||
const other_rules_count = self.other_rules.len;
|
||||
|
||||
self.page._session.arena_pool.resetRetain(self.arena);
|
||||
|
||||
self.next_doc_order = 0;
|
||||
|
||||
self.id_rules = .empty;
|
||||
try self.id_rules.ensureTotalCapacity(self.arena, id_rules_count);
|
||||
|
||||
self.class_rules = .empty;
|
||||
try self.class_rules.ensureTotalCapacity(self.arena, class_rules_count);
|
||||
|
||||
self.tag_rules = .empty;
|
||||
try self.tag_rules.ensureTotalCapacity(self.arena, tag_rules_count);
|
||||
|
||||
self.other_rules = .{};
|
||||
try self.other_rules.ensureTotalCapacity(self.arena, other_rules_count);
|
||||
|
||||
const sheets = self.page.document._style_sheets orelse return;
|
||||
for (sheets._sheets.items) |sheet| {
|
||||
self.parseSheet(sheet) catch |err| {
|
||||
log.err(.browser, "StyleManager parseSheet", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if an element is hidden based on options.
|
||||
// By default only checks display:none.
|
||||
// Walks up the tree to check ancestors.
|
||||
pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, options: CheckVisibilityOptions) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
|
||||
var current: ?*Element = el;
|
||||
|
||||
while (current) |elem| {
|
||||
// Check cache first (only when checking all properties for caching consistency)
|
||||
if (cache) |c| {
|
||||
if (c.get(elem)) |hidden| {
|
||||
if (hidden) {
|
||||
return true;
|
||||
}
|
||||
current = elem.parentElement();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const hidden = self.isElementHidden(elem, options);
|
||||
|
||||
// Store in cache
|
||||
if (cache) |c| {
|
||||
c.put(self.page.call_arena, elem, hidden) catch {};
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return true;
|
||||
}
|
||||
current = elem.parentElement();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a single element (not ancestors) is hidden.
|
||||
fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOptions) bool {
|
||||
// Track best match per property (value + priority)
|
||||
// Initialize priority to INLINE_PRIORITY for properties we don't care about - this makes
|
||||
// the loop naturally skip them since no stylesheet rule can have priority >= INLINE_PRIORITY
|
||||
var display_none: ?bool = null;
|
||||
var display_priority: u64 = 0;
|
||||
|
||||
var visibility_hidden: ?bool = null;
|
||||
var visibility_priority: u64 = 0;
|
||||
|
||||
var opacity_zero: ?bool = null;
|
||||
var opacity_priority: u64 = 0;
|
||||
|
||||
// Check inline styles FIRST - they use INLINE_PRIORITY so no stylesheet can beat them
|
||||
if (getInlineStyleProperty(el, comptime .wrap("display"), self.page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("none"))) {
|
||||
return true; // Early exit for hiding value
|
||||
}
|
||||
display_none = false;
|
||||
display_priority = INLINE_PRIORITY;
|
||||
}
|
||||
|
||||
if (options.check_visibility) {
|
||||
if (getInlineStyleProperty(el, comptime .wrap("visibility"), self.page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"))) {
|
||||
return true;
|
||||
}
|
||||
visibility_hidden = false;
|
||||
visibility_priority = INLINE_PRIORITY;
|
||||
}
|
||||
} else {
|
||||
// This can't be beat. Setting this means that, when checking rules
|
||||
// we no longer have to check if options.check_visibility is enabled.
|
||||
// We can just compare the priority.
|
||||
visibility_priority = INLINE_PRIORITY;
|
||||
}
|
||||
|
||||
if (options.check_opacity) {
|
||||
if (getInlineStyleProperty(el, comptime .wrap("opacity"), self.page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("0"))) {
|
||||
return true;
|
||||
}
|
||||
opacity_zero = false;
|
||||
opacity_priority = INLINE_PRIORITY;
|
||||
}
|
||||
} else {
|
||||
opacity_priority = INLINE_PRIORITY;
|
||||
}
|
||||
|
||||
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to check a single rule
|
||||
const Ctx = struct {
|
||||
display_none: *?bool,
|
||||
display_priority: *u64,
|
||||
visibility_hidden: *?bool,
|
||||
visibility_priority: *u64,
|
||||
opacity_zero: *?bool,
|
||||
opacity_priority: *u64,
|
||||
el: *Element,
|
||||
page: *Page,
|
||||
|
||||
fn checkRules(ctx: @This(), rules: *const RuleList) void {
|
||||
if (ctx.display_priority.* == INLINE_PRIORITY and
|
||||
ctx.visibility_priority.* == INLINE_PRIORITY and
|
||||
ctx.opacity_priority.* == INLINE_PRIORITY)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const priorities = rules.items(.priority);
|
||||
const props_list = rules.items(.props);
|
||||
const selectors = rules.items(.selector);
|
||||
|
||||
for (priorities, props_list, selectors) |p, props, selector| {
|
||||
// Fast skip using packed u64 priority
|
||||
if (p <= ctx.display_priority.* and p <= ctx.visibility_priority.* and p <= ctx.opacity_priority.*) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logic for property dominance
|
||||
const dominated = (props.display_none == null or p <= ctx.display_priority.*) and
|
||||
(props.visibility_hidden == null or p <= ctx.visibility_priority.*) and
|
||||
(props.opacity_zero == null or p <= ctx.opacity_priority.*);
|
||||
|
||||
if (dominated) continue;
|
||||
|
||||
if (matchesSelector(ctx.el, selector, ctx.page)) {
|
||||
// Update best priorities
|
||||
if (props.display_none != null and p > ctx.display_priority.*) {
|
||||
ctx.display_none.* = props.display_none;
|
||||
ctx.display_priority.* = p;
|
||||
}
|
||||
if (props.visibility_hidden != null and p > ctx.visibility_priority.*) {
|
||||
ctx.visibility_hidden.* = props.visibility_hidden;
|
||||
ctx.visibility_priority.* = p;
|
||||
}
|
||||
if (props.opacity_zero != null and p > ctx.opacity_priority.*) {
|
||||
ctx.opacity_zero.* = props.opacity_zero;
|
||||
ctx.opacity_priority.* = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const ctx = Ctx{
|
||||
.display_none = &display_none,
|
||||
.display_priority = &display_priority,
|
||||
.visibility_hidden = &visibility_hidden,
|
||||
.visibility_priority = &visibility_priority,
|
||||
.opacity_zero = &opacity_zero,
|
||||
.opacity_priority = &opacity_priority,
|
||||
.el = el,
|
||||
.page = self.page,
|
||||
};
|
||||
|
||||
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
||||
if (self.id_rules.get(id)) |rules| {
|
||||
ctx.checkRules(&rules);
|
||||
}
|
||||
}
|
||||
|
||||
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
|
||||
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
|
||||
while (it.next()) |class| {
|
||||
if (self.class_rules.get(class)) |rules| {
|
||||
ctx.checkRules(&rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.tag_rules.get(el.getTag())) |rules| {
|
||||
ctx.checkRules(&rules);
|
||||
}
|
||||
|
||||
ctx.checkRules(&self.other_rules);
|
||||
|
||||
return (display_none orelse false) or (visibility_hidden orelse false) or (opacity_zero orelse false);
|
||||
}
|
||||
|
||||
/// Check if an element has pointer-events:none.
|
||||
/// Checks inline style first - if set, skips stylesheet lookup.
|
||||
/// Walks up the tree to check ancestors.
|
||||
pub fn hasPointerEventsNone(self: *StyleManager, el: *Element, cache: ?*PointerEventsCache) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
|
||||
var current: ?*Element = el;
|
||||
|
||||
while (current) |elem| {
|
||||
// Check cache first
|
||||
if (cache) |c| {
|
||||
if (c.get(elem)) |pe_none| {
|
||||
if (pe_none) return true;
|
||||
current = elem.parentElement();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const pe_none = self.elementHasPointerEventsNone(elem);
|
||||
|
||||
if (cache) |c| {
|
||||
c.put(self.page.call_arena, elem, pe_none) catch {};
|
||||
}
|
||||
|
||||
if (pe_none) {
|
||||
return true;
|
||||
}
|
||||
current = elem.parentElement();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if a single element (not ancestors) has pointer-events:none.
|
||||
fn elementHasPointerEventsNone(self: *StyleManager, el: *Element) bool {
|
||||
const page = self.page;
|
||||
|
||||
// Check inline style first
|
||||
if (getInlineStyleProperty(el, .wrap("pointer-events"), page)) |property| {
|
||||
if (property._value.eql(comptime .wrap("none"))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var result: ?bool = null;
|
||||
var best_priority: u64 = 0;
|
||||
|
||||
// Helper to check a single rule
|
||||
const checkRules = struct {
|
||||
fn check(rules: *const RuleList, res: *?bool, current_priority: *u64, elem: *Element, p: *Page) void {
|
||||
if (current_priority.* == INLINE_PRIORITY) return;
|
||||
|
||||
const priorities = rules.items(.priority);
|
||||
const props_list = rules.items(.props);
|
||||
const selectors = rules.items(.selector);
|
||||
|
||||
for (priorities, props_list, selectors) |priority, props, selector| {
|
||||
if (priority <= current_priority.*) continue;
|
||||
if (props.pointer_events_none == null) continue;
|
||||
|
||||
if (matchesSelector(elem, selector, p)) {
|
||||
res.* = props.pointer_events_none;
|
||||
current_priority.* = priority;
|
||||
}
|
||||
}
|
||||
}
|
||||
}.check;
|
||||
|
||||
if (el.getAttributeSafe(comptime .wrap("id"))) |id| {
|
||||
if (self.id_rules.get(id)) |rules| {
|
||||
checkRules(&rules, &result, &best_priority, el, page);
|
||||
}
|
||||
}
|
||||
|
||||
if (el.getAttributeSafe(comptime .wrap("class"))) |class_attr| {
|
||||
var it = std.mem.tokenizeAny(u8, class_attr, &std.ascii.whitespace);
|
||||
while (it.next()) |class| {
|
||||
if (self.class_rules.get(class)) |rules| {
|
||||
checkRules(&rules, &result, &best_priority, el, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self.tag_rules.get(el.getTag())) |rules| {
|
||||
checkRules(&rules, &result, &best_priority, el, page);
|
||||
}
|
||||
|
||||
checkRules(&self.other_rules, &result, &best_priority, el, page);
|
||||
|
||||
return result orelse false;
|
||||
}
|
||||
|
||||
// Extracts visibility-relevant rules from a CSS rule.
|
||||
// Creates one VisibilityRule per selector (not per selector list) so each has correct specificity.
|
||||
// Buckets rules by their rightmost selector part for fast lookup.
|
||||
fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
|
||||
const selector_text = style_rule._selector_text;
|
||||
if (selector_text.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the rule has visibility-relevant properties
|
||||
const style = style_rule._style orelse return;
|
||||
const props = extractVisibilityProperties(style);
|
||||
if (!props.isRelevant()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the selector list
|
||||
const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return;
|
||||
if (selectors.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create one rule per selector - each has its own specificity
|
||||
// e.g., "#id, .class { display: none }" becomes two rules with different specificities
|
||||
for (selectors) |selector| {
|
||||
// Get the rightmost compound (last segment, or first if no segments)
|
||||
const rightmost = if (selector.segments.len > 0)
|
||||
selector.segments[selector.segments.len - 1].compound
|
||||
else
|
||||
selector.first;
|
||||
|
||||
// Find the bucketing key from rightmost compound
|
||||
const bucket_key = getBucketKey(rightmost) orelse continue; // skip if dynamic pseudo-class
|
||||
|
||||
const rule = VisibilityRule{
|
||||
.props = props,
|
||||
.selector = selector,
|
||||
.priority = (@as(u64, computeSpecificity(selector)) << 32) | @as(u64, self.next_doc_order),
|
||||
};
|
||||
self.next_doc_order += 1;
|
||||
|
||||
// Add to appropriate bucket
|
||||
switch (bucket_key) {
|
||||
.id => |id| {
|
||||
const gop = try self.id_rules.getOrPut(self.arena, id);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.class => |class| {
|
||||
const gop = try self.class_rules.getOrPut(self.arena, class);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.tag => |tag| {
|
||||
const gop = try self.tag_rules.getOrPut(self.arena, tag);
|
||||
if (!gop.found_existing) gop.value_ptr.* = .{};
|
||||
try gop.value_ptr.append(self.arena, rule);
|
||||
},
|
||||
.other => {
|
||||
try self.other_rules.append(self.arena, rule);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BucketKey = union(enum) {
|
||||
id: []const u8,
|
||||
class: []const u8,
|
||||
tag: Tag,
|
||||
other,
|
||||
};
|
||||
|
||||
/// Returns the best bucket key for a compound selector, or null if it contains
|
||||
/// a dynamic pseudo-class we should skip (hover, active, focus, etc.)
|
||||
/// Priority: id > class > tag > other
|
||||
fn getBucketKey(compound: Selector.Compound) ?BucketKey {
|
||||
var best_key: BucketKey = .other;
|
||||
|
||||
for (compound.parts) |part| {
|
||||
switch (part) {
|
||||
.id => |id| {
|
||||
best_key = .{ .id = id };
|
||||
},
|
||||
.class => |class| {
|
||||
if (best_key != .id) {
|
||||
best_key = .{ .class = class };
|
||||
}
|
||||
},
|
||||
.tag => |tag| {
|
||||
if (best_key == .other) {
|
||||
best_key = .{ .tag = tag };
|
||||
}
|
||||
},
|
||||
.tag_name => {
|
||||
// Custom tag - put in other bucket (can't efficiently look up)
|
||||
// Keep current best_key if we have something better
|
||||
},
|
||||
.pseudo_class => |pc| {
|
||||
// Skip dynamic pseudo-classes - they depend on interaction state
|
||||
switch (pc) {
|
||||
.hover, .active, .focus, .focus_within, .focus_visible, .visited, .target => {
|
||||
return null; // Skip this selector entirely
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
},
|
||||
.universal, .attribute => {},
|
||||
}
|
||||
}
|
||||
|
||||
return best_key;
|
||||
}
|
||||
|
||||
/// Extracts visibility-relevant properties from a style declaration.
|
||||
fn extractVisibilityProperties(style: *CSSStyleProperties) VisibilityProperties {
|
||||
var props = VisibilityProperties{};
|
||||
const decl = style.asCSSStyleDeclaration();
|
||||
|
||||
if (decl.findProperty(comptime .wrap("display"))) |property| {
|
||||
props.display_none = property._value.eql(comptime .wrap("none"));
|
||||
}
|
||||
|
||||
if (decl.findProperty(comptime .wrap("visibility"))) |property| {
|
||||
props.visibility_hidden = property._value.eql(comptime .wrap("hidden")) or property._value.eql(comptime .wrap("collapse"));
|
||||
}
|
||||
|
||||
if (decl.findProperty(comptime .wrap("opacity"))) |property| {
|
||||
props.opacity_zero = property._value.eql(comptime .wrap("0"));
|
||||
}
|
||||
|
||||
if (decl.findProperty(.wrap("pointer-events"))) |property| {
|
||||
props.pointer_events_none = property._value.eql(comptime .wrap("none"));
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
// Computes CSS specificity for a selector.
|
||||
// Returns packed value: (id_count << 20) | (class_count << 10) | element_count
|
||||
pub fn computeSpecificity(selector: Selector.Selector) u32 {
|
||||
var ids: u32 = 0;
|
||||
var classes: u32 = 0; // includes classes, attributes, pseudo-classes
|
||||
var elements: u32 = 0; // includes elements, pseudo-elements
|
||||
|
||||
// Count specificity for first compound
|
||||
countCompoundSpecificity(selector.first, &ids, &classes, &elements);
|
||||
|
||||
// Count specificity for subsequent segments
|
||||
for (selector.segments) |segment| {
|
||||
countCompoundSpecificity(segment.compound, &ids, &classes, &elements);
|
||||
}
|
||||
|
||||
// Pack into single u32: (ids << 20) | (classes << 10) | elements
|
||||
// This gives us 10 bits each, supporting up to 1023 of each type
|
||||
return (@as(u32, @min(ids, 1023)) << 20) | (@as(u32, @min(classes, 1023)) << 10) | @min(elements, 1023);
|
||||
}
|
||||
|
||||
fn countCompoundSpecificity(compound: Selector.Compound, ids: *u32, classes: *u32, elements: *u32) void {
|
||||
for (compound.parts) |part| {
|
||||
switch (part) {
|
||||
.id => ids.* += 1,
|
||||
.class => classes.* += 1,
|
||||
.tag, .tag_name => elements.* += 1,
|
||||
.universal => {}, // zero specificity
|
||||
.attribute => classes.* += 1,
|
||||
.pseudo_class => |pc| {
|
||||
switch (pc) {
|
||||
// :where() has zero specificity
|
||||
.where => {},
|
||||
// :not(), :is(), :has() take specificity of their most specific argument
|
||||
.not, .is, .has => |nested| {
|
||||
var max_nested: u32 = 0;
|
||||
for (nested) |nested_sel| {
|
||||
const spec = computeSpecificity(nested_sel);
|
||||
if (spec > max_nested) max_nested = spec;
|
||||
}
|
||||
// Unpack and add to our counts
|
||||
ids.* += (max_nested >> 20) & 0x3FF;
|
||||
classes.* += (max_nested >> 10) & 0x3FF;
|
||||
elements.* += max_nested & 0x3FF;
|
||||
},
|
||||
// All other pseudo-classes count as class-level specificity
|
||||
else => classes.* += 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matchesSelector(el: *Element, selector: Selector.Selector, page: *Page) bool {
|
||||
const node = el.asNode();
|
||||
return SelectorList.matches(node, selector, node, page);
|
||||
}
|
||||
|
||||
const VisibilityProperties = struct {
|
||||
display_none: ?bool = null,
|
||||
visibility_hidden: ?bool = null,
|
||||
opacity_zero: ?bool = null,
|
||||
pointer_events_none: ?bool = null,
|
||||
|
||||
// returne true if any field in VisibilityProperties is not null
|
||||
fn isRelevant(self: VisibilityProperties) bool {
|
||||
return self.display_none != null or
|
||||
self.visibility_hidden != null or
|
||||
self.opacity_zero != null or
|
||||
self.pointer_events_none != null;
|
||||
}
|
||||
};
|
||||
|
||||
const VisibilityRule = struct {
|
||||
selector: Selector.Selector, // Single selector, not a list
|
||||
props: VisibilityProperties,
|
||||
|
||||
// Packed priority: (specificity << 32) | doc_order
|
||||
priority: u64,
|
||||
};
|
||||
|
||||
const CheckVisibilityOptions = struct {
|
||||
check_opacity: bool = false,
|
||||
check_visibility: bool = false,
|
||||
};
|
||||
|
||||
// Inline styles always win over stylesheets - use max u64 as sentinel
|
||||
const INLINE_PRIORITY: u64 = std.math.maxInt(u64);
|
||||
|
||||
fn getInlineStyleProperty(el: *Element, property_name: String, page: *Page) ?*CSSStyleProperty {
|
||||
const style = el.getOrCreateStyle(page) catch |err| {
|
||||
log.err(.browser, "StyleManager getOrCreateStyle", .{ .err = err });
|
||||
return null;
|
||||
};
|
||||
return style.asCSSStyleDeclaration().findProperty(property_name);
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "StyleManager: computeSpecificity: element selector" {
|
||||
// div -> (0, 0, 1)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .tag = .div }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: class selector" {
|
||||
// .foo -> (0, 1, 0)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: id selector" {
|
||||
// #bar -> (1, 0, 0)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .id = "bar" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1 << 20, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: combined selector" {
|
||||
// div.foo#bar -> (1, 1, 1)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .tag = .div },
|
||||
.{ .class = "foo" },
|
||||
.{ .id = "bar" },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual((1 << 20) | (1 << 10) | 1, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: universal selector" {
|
||||
// * -> (0, 0, 0)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.universal} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(0, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: multiple classes" {
|
||||
// .a.b.c -> (0, 3, 0)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .class = "a" },
|
||||
.{ .class = "b" },
|
||||
.{ .class = "c" },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(3 << 10, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: descendant combinator" {
|
||||
// div span -> (0, 0, 2)
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .tag = .div }} },
|
||||
.segments = &.{
|
||||
.{ .combinator = .descendant, .compound = .{ .parts = &.{.{ .tag = .span }} } },
|
||||
},
|
||||
};
|
||||
try testing.expectEqual(2, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: :where() has zero specificity" {
|
||||
// :where(.foo) -> (0, 0, 0) regardless of what's inside
|
||||
const inner_selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .pseudo_class = .{ .where = &.{inner_selector} } },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(0, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: :not() takes inner specificity" {
|
||||
// :not(.foo) -> (0, 1, 0) - takes specificity of .foo
|
||||
const inner_selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .pseudo_class = .{ .not = &.{inner_selector} } },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: :is() takes most specific inner" {
|
||||
// :is(.foo, #bar) -> (1, 0, 0) - takes the most specific (#bar)
|
||||
const class_selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .class = "foo" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
const id_selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{.{ .id = "bar" }} },
|
||||
.segments = &.{},
|
||||
};
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .pseudo_class = .{ .is = &.{ class_selector, id_selector } } },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1 << 20, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: computeSpecificity: pseudo-class (general)" {
|
||||
// :hover -> (0, 1, 0) - pseudo-classes count as class-level
|
||||
const selector = Selector.Selector{
|
||||
.first = .{ .parts = &.{
|
||||
.{ .pseudo_class = .hover },
|
||||
} },
|
||||
.segments = &.{},
|
||||
};
|
||||
try testing.expectEqual(1 << 10, computeSpecificity(selector));
|
||||
}
|
||||
|
||||
test "StyleManager: document order tie-breaking" {
|
||||
// When specificity is equal, higher doc_order (later in document) wins
|
||||
const beats = struct {
|
||||
fn f(spec: u32, doc_order: u32, best_spec: u32, best_doc_order: u32) bool {
|
||||
return spec > best_spec or (spec == best_spec and doc_order > best_doc_order);
|
||||
}
|
||||
}.f;
|
||||
|
||||
// Higher specificity always wins regardless of doc_order
|
||||
try testing.expect(beats(2, 0, 1, 10));
|
||||
try testing.expect(!beats(1, 10, 2, 0));
|
||||
|
||||
// Equal specificity: higher doc_order wins
|
||||
try testing.expect(beats(1, 5, 1, 3)); // doc_order 5 > 3
|
||||
try testing.expect(!beats(1, 3, 1, 5)); // doc_order 3 < 5
|
||||
|
||||
// Equal specificity and doc_order: no win
|
||||
try testing.expect(!beats(1, 5, 1, 5));
|
||||
}
|
||||
@@ -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 };
|
||||
const EncodeSet = enum { path, query, userinfo, fragment };
|
||||
|
||||
fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 {
|
||||
// Check if encoding is needed
|
||||
@@ -256,8 +256,10 @@ fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
|
||||
';', '=' => encode_set == .userinfo,
|
||||
// Separators: userinfo must encode these
|
||||
'/', ':', '@' => encode_set == .userinfo,
|
||||
// '?' is allowed in queries but not in paths or userinfo
|
||||
// '?' is allowed in queries only
|
||||
'?' => encode_set != .query,
|
||||
// '#' is allowed in fragments only
|
||||
'#' => encode_set != .fragment,
|
||||
// Everything else needs encoding (including space)
|
||||
else => true,
|
||||
};
|
||||
@@ -323,14 +325,22 @@ 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, "://") orelse 0;
|
||||
const path_start = std.mem.indexOfScalarPos(u8, raw, if (protocol_end > 0) protocol_end + 3 else 0, '/') orelse raw.len;
|
||||
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 query_or_hash_start = std.mem.indexOfAnyPos(u8, raw, path_start, "?#") orelse raw.len;
|
||||
|
||||
if (path_start >= query_or_hash_start) {
|
||||
if (std.mem.indexOf(u8, raw, "://") != null) return "/";
|
||||
return "";
|
||||
return "/";
|
||||
}
|
||||
|
||||
return raw[path_start..query_or_hash_start];
|
||||
@@ -394,10 +404,6 @@ 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..], "/?#");
|
||||
@@ -406,6 +412,12 @@ 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| {
|
||||
@@ -451,8 +463,15 @@ 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;
|
||||
|
||||
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;
|
||||
// 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 full_pos = authority_start + pos;
|
||||
if (full_pos < path_start) {
|
||||
@@ -466,13 +485,20 @@ 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;
|
||||
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
|
||||
|
||||
// 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| {
|
||||
authority_start += pos + 1;
|
||||
}
|
||||
|
||||
const authority = raw[authority_start..];
|
||||
const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority;
|
||||
return authority[0..path_start];
|
||||
return raw[authority_start..path_start];
|
||||
}
|
||||
|
||||
// Returns true if these two URLs point to the same document.
|
||||
@@ -587,11 +613,13 @@ 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 (value.len > 0 and value[0] != '/')
|
||||
try std.fmt.allocPrint(allocator, "/{s}", .{value})
|
||||
const pathname = if (encoded.len > 0 and encoded[0] != '/')
|
||||
try std.fmt.allocPrint(allocator, "/{s}", .{encoded})
|
||||
else
|
||||
value;
|
||||
encoded;
|
||||
|
||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||
}
|
||||
@@ -602,11 +630,13 @@ 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 (value.len > 0 and value[0] != '?')
|
||||
try std.fmt.allocPrint(allocator, "?{s}", .{value})
|
||||
const search = if (encoded.len > 0 and value[0] != '?')
|
||||
try std.fmt.allocPrint(allocator, "?{s}", .{encoded})
|
||||
else
|
||||
value;
|
||||
encoded;
|
||||
|
||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||
}
|
||||
@@ -617,11 +647,13 @@ 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 (value.len > 0 and value[0] != '#')
|
||||
try std.fmt.allocPrint(allocator, "#{s}", .{value})
|
||||
const hash = if (encoded.len > 0 and encoded[0] != '#')
|
||||
try std.fmt.allocPrint(allocator, "#{s}", .{encoded})
|
||||
else
|
||||
value;
|
||||
encoded;
|
||||
|
||||
return buildUrl(allocator, protocol, host, pathname, search, hash);
|
||||
}
|
||||
@@ -1414,3 +1446,55 @@ 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);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ 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;
|
||||
@@ -102,3 +104,34 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode {
|
||||
var timer = try std.time.Timer.start();
|
||||
var runner = try session.runner(.{});
|
||||
try runner.wait(.{ .ms = timeout_ms, .until = .load });
|
||||
|
||||
while (true) {
|
||||
const page = runner.page;
|
||||
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
|
||||
return error.InvalidSelector;
|
||||
};
|
||||
|
||||
if (element) |el| {
|
||||
return el.asNode();
|
||||
}
|
||||
|
||||
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
|
||||
if (elapsed >= timeout_ms) {
|
||||
return error.Timeout;
|
||||
}
|
||||
switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) {
|
||||
.done => return error.Timeout,
|
||||
.ok => |recommended_sleep_ms| {
|
||||
if (recommended_sleep_ms > 0) {
|
||||
// guanrateed to be <= 20ms
|
||||
std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,3 +293,191 @@ 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());
|
||||
}
|
||||
|
||||
460
src/browser/forms.zig
Normal file
460
src/browser/forms.zig
Normal file
@@ -0,0 +1,460 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const SelectOption = struct {
|
||||
value: []const u8,
|
||||
text: []const u8,
|
||||
|
||||
pub fn jsonStringify(self: *const SelectOption, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
try jw.objectField("value");
|
||||
try jw.write(self.value);
|
||||
try jw.objectField("text");
|
||||
try jw.write(self.text);
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
pub const FormField = struct {
|
||||
backendNodeId: ?u32 = null,
|
||||
node: *Node,
|
||||
tag_name: []const u8,
|
||||
name: ?[]const u8,
|
||||
input_type: ?[]const u8,
|
||||
required: bool,
|
||||
disabled: bool,
|
||||
value: ?[]const u8,
|
||||
placeholder: ?[]const u8,
|
||||
options: []SelectOption,
|
||||
|
||||
pub fn jsonStringify(self: *const FormField, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
|
||||
if (self.backendNodeId) |id| {
|
||||
try jw.objectField("backendNodeId");
|
||||
try jw.write(id);
|
||||
}
|
||||
|
||||
try jw.objectField("tagName");
|
||||
try jw.write(self.tag_name);
|
||||
|
||||
if (self.name) |v| {
|
||||
try jw.objectField("name");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.input_type) |v| {
|
||||
try jw.objectField("inputType");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
try jw.objectField("required");
|
||||
try jw.write(self.required);
|
||||
|
||||
try jw.objectField("disabled");
|
||||
try jw.write(self.disabled);
|
||||
|
||||
if (self.value) |v| {
|
||||
try jw.objectField("value");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.placeholder) |v| {
|
||||
try jw.objectField("placeholder");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.options.len > 0) {
|
||||
try jw.objectField("options");
|
||||
try jw.beginArray();
|
||||
for (self.options) |opt| {
|
||||
try opt.jsonStringify(jw);
|
||||
}
|
||||
try jw.endArray();
|
||||
}
|
||||
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
pub const FormInfo = struct {
|
||||
backendNodeId: ?u32 = null,
|
||||
node: *Node,
|
||||
action: ?[]const u8,
|
||||
method: ?[]const u8,
|
||||
fields: []FormField,
|
||||
|
||||
pub fn jsonStringify(self: *const FormInfo, jw: anytype) !void {
|
||||
try jw.beginObject();
|
||||
|
||||
if (self.backendNodeId) |id| {
|
||||
try jw.objectField("backendNodeId");
|
||||
try jw.write(id);
|
||||
}
|
||||
|
||||
if (self.action) |v| {
|
||||
try jw.objectField("action");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
if (self.method) |v| {
|
||||
try jw.objectField("method");
|
||||
try jw.write(v);
|
||||
}
|
||||
|
||||
try jw.objectField("fields");
|
||||
try jw.beginArray();
|
||||
for (self.fields) |field| {
|
||||
try field.jsonStringify(jw);
|
||||
}
|
||||
try jw.endArray();
|
||||
|
||||
try jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
/// Populate backendNodeId on each form and its fields by registering
|
||||
/// their nodes in the given registry. Works with both CDP and MCP registries.
|
||||
pub fn registerNodes(forms_data: []FormInfo, registry: anytype) !void {
|
||||
for (forms_data) |*form| {
|
||||
const form_registered = try registry.register(form.node);
|
||||
form.backendNodeId = form_registered.id;
|
||||
for (form.fields) |*field| {
|
||||
const field_registered = try registry.register(field.node);
|
||||
field.backendNodeId = field_registered.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all forms and their fields under `root`.
|
||||
/// Uses Form.getElements() to include fields outside the <form> that
|
||||
/// reference it via the form="id" attribute, matching browser behavior.
|
||||
/// `arena` must be an arena allocator — returned slices borrow its memory.
|
||||
pub fn collectForms(
|
||||
arena: Allocator,
|
||||
root: *Node,
|
||||
page: *Page,
|
||||
) ![]FormInfo {
|
||||
var forms: std.ArrayList(FormInfo) = .empty;
|
||||
|
||||
var tw = TreeWalker.Full.init(root, .{});
|
||||
while (tw.next()) |node| {
|
||||
const form = node.is(Element.Html.Form) orelse continue;
|
||||
const el = form.asElement();
|
||||
|
||||
const fields = try collectFormFields(arena, form, page);
|
||||
if (fields.len == 0) continue;
|
||||
|
||||
const action_attr = el.getAttributeSafe(comptime .wrap("action"));
|
||||
const method_str = form.getMethod();
|
||||
|
||||
try forms.append(arena, .{
|
||||
.node = node,
|
||||
.action = if (action_attr) |a| if (a.len > 0) a else null else null,
|
||||
.method = method_str,
|
||||
.fields = fields,
|
||||
});
|
||||
}
|
||||
|
||||
return forms.items;
|
||||
}
|
||||
|
||||
fn collectFormFields(
|
||||
arena: Allocator,
|
||||
form: *Element.Html.Form,
|
||||
page: *Page,
|
||||
) ![]FormField {
|
||||
var fields: std.ArrayList(FormField) = .empty;
|
||||
|
||||
var elements = try form.getElements(page);
|
||||
var it = try elements.iterator();
|
||||
while (it.next()) |el| {
|
||||
const node = el.asNode();
|
||||
|
||||
const is_disabled = el.isDisabled();
|
||||
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
if (input._input_type == .hidden) continue;
|
||||
if (input._input_type == .submit or input._input_type == .button or input._input_type == .image) continue;
|
||||
|
||||
try fields.append(arena, .{
|
||||
.node = node,
|
||||
.tag_name = "input",
|
||||
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||
.input_type = input._input_type.toString(),
|
||||
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||
.disabled = is_disabled,
|
||||
.value = input.getValue(),
|
||||
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||
.options = &.{},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.is(Element.Html.TextArea)) |textarea| {
|
||||
try fields.append(arena, .{
|
||||
.node = node,
|
||||
.tag_name = "textarea",
|
||||
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||
.input_type = null,
|
||||
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||
.disabled = is_disabled,
|
||||
.value = textarea.getValue(),
|
||||
.placeholder = el.getAttributeSafe(comptime .wrap("placeholder")),
|
||||
.options = &.{},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (el.is(Element.Html.Select)) |select| {
|
||||
const options = try collectSelectOptions(arena, node, page);
|
||||
|
||||
try fields.append(arena, .{
|
||||
.node = node,
|
||||
.tag_name = "select",
|
||||
.name = el.getAttributeSafe(comptime .wrap("name")),
|
||||
.input_type = null,
|
||||
.required = el.getAttributeSafe(comptime .wrap("required")) != null,
|
||||
.disabled = is_disabled,
|
||||
.value = select.getValue(page),
|
||||
.placeholder = null,
|
||||
.options = options,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Button elements from getElements() - skip (not fillable)
|
||||
}
|
||||
|
||||
return fields.items;
|
||||
}
|
||||
|
||||
fn collectSelectOptions(
|
||||
arena: Allocator,
|
||||
select_node: *Node,
|
||||
page: *Page,
|
||||
) ![]SelectOption {
|
||||
var options: std.ArrayList(SelectOption) = .empty;
|
||||
const Option = Element.Html.Option;
|
||||
|
||||
var tw = TreeWalker.Full.init(select_node, .{});
|
||||
while (tw.next()) |node| {
|
||||
const el = node.is(Element) orelse continue;
|
||||
const option = el.is(Option) orelse continue;
|
||||
|
||||
try options.append(arena, .{
|
||||
.value = option.getValue(page),
|
||||
.text = option.getText(page),
|
||||
});
|
||||
}
|
||||
|
||||
return options.items;
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
fn testForms(html: []const u8) ![]FormInfo {
|
||||
const page = try testing.test_session.createPage();
|
||||
|
||||
const doc = page.window._document;
|
||||
const div = try doc.createElement("div", null, page);
|
||||
try page.parseHtmlAsChildren(div.asNode(), html);
|
||||
|
||||
return collectForms(page.call_arena, div.asNode(), page);
|
||||
}
|
||||
|
||||
test "browser.forms: login form" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form action="/login" method="POST">
|
||||
\\ <input type="email" name="email" required placeholder="Email">
|
||||
\\ <input type="password" name="password" required>
|
||||
\\ <input type="submit" value="Log In">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual("/login", forms[0].action.?);
|
||||
try testing.expectEqual("post", forms[0].method.?);
|
||||
try testing.expectEqual(2, forms[0].fields.len);
|
||||
try testing.expectEqual("email", forms[0].fields[0].name.?);
|
||||
try testing.expectEqual("email", forms[0].fields[0].input_type.?);
|
||||
try testing.expect(forms[0].fields[0].required);
|
||||
try testing.expect(!forms[0].fields[0].disabled);
|
||||
try testing.expectEqual("password", forms[0].fields[1].name.?);
|
||||
}
|
||||
|
||||
test "browser.forms: form with select" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <select name="color">
|
||||
\\ <option value="red">Red</option>
|
||||
\\ <option value="blue">Blue</option>
|
||||
\\ </select>
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(1, forms[0].fields.len);
|
||||
try testing.expectEqual("select", forms[0].fields[0].tag_name);
|
||||
try testing.expectEqual(2, forms[0].fields[0].options.len);
|
||||
try testing.expectEqual("red", forms[0].fields[0].options[0].value);
|
||||
try testing.expectEqual("Red", forms[0].fields[0].options[0].text);
|
||||
}
|
||||
|
||||
test "browser.forms: form with textarea" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form method="POST">
|
||||
\\ <textarea name="message" placeholder="Your message"></textarea>
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(1, forms[0].fields.len);
|
||||
try testing.expectEqual("textarea", forms[0].fields[0].tag_name);
|
||||
try testing.expectEqual("Your message", forms[0].fields[0].placeholder.?);
|
||||
}
|
||||
|
||||
test "browser.forms: empty form skipped" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form action="/empty">
|
||||
\\ <p>No fields here</p>
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(0, forms.len);
|
||||
}
|
||||
|
||||
test "browser.forms: hidden inputs excluded" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <input type="hidden" name="csrf" value="token123">
|
||||
\\ <input type="text" name="username">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(1, forms[0].fields.len);
|
||||
try testing.expectEqual("username", forms[0].fields[0].name.?);
|
||||
}
|
||||
|
||||
test "browser.forms: multiple forms" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form action="/search" method="GET">
|
||||
\\ <input type="text" name="q" placeholder="Search">
|
||||
\\</form>
|
||||
\\<form action="/login" method="POST">
|
||||
\\ <input type="email" name="email">
|
||||
\\ <input type="password" name="pass">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(2, forms.len);
|
||||
try testing.expectEqual(1, forms[0].fields.len);
|
||||
try testing.expectEqual(2, forms[1].fields.len);
|
||||
}
|
||||
|
||||
test "browser.forms: disabled fields flagged" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <input type="text" name="enabled_field">
|
||||
\\ <input type="text" name="disabled_field" disabled>
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(2, forms[0].fields.len);
|
||||
try testing.expect(!forms[0].fields[0].disabled);
|
||||
try testing.expect(forms[0].fields[1].disabled);
|
||||
}
|
||||
|
||||
test "browser.forms: disabled fieldset" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <fieldset disabled>
|
||||
\\ <input type="text" name="in_disabled_fieldset">
|
||||
\\ </fieldset>
|
||||
\\ <input type="text" name="outside_fieldset">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(2, forms[0].fields.len);
|
||||
try testing.expect(forms[0].fields[0].disabled);
|
||||
try testing.expect(!forms[0].fields[1].disabled);
|
||||
}
|
||||
|
||||
test "browser.forms: external field via form attribute" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<input type="text" name="external" form="myform">
|
||||
\\<form id="myform" action="/submit">
|
||||
\\ <input type="text" name="internal">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(2, forms[0].fields.len);
|
||||
}
|
||||
|
||||
test "browser.forms: checkbox and radio return value attribute" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <input type="checkbox" name="agree" value="yes" checked>
|
||||
\\ <input type="radio" name="color" value="red">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(2, forms[0].fields.len);
|
||||
try testing.expectEqual("checkbox", forms[0].fields[0].input_type.?);
|
||||
try testing.expectEqual("yes", forms[0].fields[0].value.?);
|
||||
try testing.expectEqual("radio", forms[0].fields[1].input_type.?);
|
||||
try testing.expectEqual("red", forms[0].fields[1].value.?);
|
||||
}
|
||||
|
||||
test "browser.forms: form without action or method" {
|
||||
defer testing.reset();
|
||||
defer testing.test_session.removePage();
|
||||
const forms = try testForms(
|
||||
\\<form>
|
||||
\\ <input type="text" name="q">
|
||||
\\</form>
|
||||
);
|
||||
try testing.expectEqual(1, forms.len);
|
||||
try testing.expectEqual(null, forms[0].action);
|
||||
try testing.expectEqual("get", forms[0].method.?);
|
||||
try testing.expectEqual(1, forms[0].fields.len);
|
||||
}
|
||||
@@ -133,6 +133,8 @@ 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, .{});
|
||||
@@ -146,7 +148,7 @@ pub fn collectInteractiveElements(
|
||||
else => {},
|
||||
}
|
||||
|
||||
const itype = classifyInteractivity(el, html_el, listener_targets) orelse continue;
|
||||
const itype = classifyInteractivity(page, el, html_el, listener_targets, &css_cache) orelse continue;
|
||||
|
||||
const listener_types = getListenerTypes(
|
||||
el.asEventTarget(),
|
||||
@@ -160,7 +162,7 @@ pub fn collectInteractiveElements(
|
||||
.name = try getAccessibleName(el, arena),
|
||||
.interactivity_type = itype,
|
||||
.listener_types = listener_types,
|
||||
.disabled = isDisabled(el),
|
||||
.disabled = el.isDisabled(),
|
||||
.tab_index = html_el.getTabIndex(),
|
||||
.id = el.getAttributeSafe(comptime .wrap("id")),
|
||||
.class = el.getAttributeSafe(comptime .wrap("class")),
|
||||
@@ -210,10 +212,14 @@ 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,
|
||||
@@ -406,36 +412,6 @@ 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| {
|
||||
@@ -554,6 +530,11 @@ 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);
|
||||
|
||||
@@ -505,6 +505,7 @@ 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,
|
||||
|
||||
@@ -63,7 +63,9 @@ templates: []*const v8.FunctionTemplate,
|
||||
// Arena for the lifetime of the context
|
||||
arena: Allocator,
|
||||
|
||||
// The page.call_arena
|
||||
// The call_arena for this context. For main world contexts this is
|
||||
// page.call_arena. For isolated world contexts this is a separate arena
|
||||
// owned by the IsolatedWorld.
|
||||
call_arena: Allocator,
|
||||
|
||||
// Because calls can be nested (i.e.a function calling a callback),
|
||||
@@ -79,6 +81,16 @@ 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,
|
||||
@@ -185,9 +197,8 @@ 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;
|
||||
|
||||
{
|
||||
@@ -203,16 +214,16 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||
}
|
||||
|
||||
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
|
||||
return self.origin.trackGlobal(global);
|
||||
return self.identity.globals.append(self.identity_arena, global);
|
||||
}
|
||||
|
||||
pub fn trackTemp(self: *Context, global: v8.Global) !void {
|
||||
return self.origin.trackTemp(global);
|
||||
return self.identity.temps.put(self.identity_arena, global.data_ptr, global);
|
||||
}
|
||||
|
||||
pub fn weakRef(self: *Context, obj: anytype) void {
|
||||
const resolved = js.Local.resolveValue(obj);
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -224,7 +235,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.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -237,7 +248,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.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -247,6 +258,48 @@ 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;
|
||||
@@ -545,13 +598,13 @@ pub fn dynamicModuleCallback(
|
||||
|
||||
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
};
|
||||
|
||||
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
|
||||
const normalized_specifier = self.script_manager.?.resolveSpecifier(
|
||||
@@ -560,14 +613,14 @@ pub fn dynamicModuleCallback(
|
||||
specifier,
|
||||
) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
|
||||
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
|
||||
log.err(.js, "dynamic module callback", .{
|
||||
.err = err,
|
||||
});
|
||||
break :blk local.rejectPromise("Failed to load module") catch return null;
|
||||
break :blk local.rejectPromise(.{ .generic_error = "Out of memory" });
|
||||
};
|
||||
return @constCast(promise.handle);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ 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;
|
||||
@@ -254,8 +255,15 @@ pub fn deinit(self: *Env) void {
|
||||
allocator.destroy(self.isolate_params);
|
||||
}
|
||||
|
||||
pub fn createContext(self: *Env, page: *Page) !*Context {
|
||||
const context_arena = try self.app.arena_pool.acquire();
|
||||
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 });
|
||||
errdefer self.app.arena_pool.release(context_arena);
|
||||
|
||||
const isolate = self.isolate;
|
||||
@@ -300,33 +308,43 @@ pub fn createContext(self: *Env, page: *Page) !*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 origin = try page._session.getOrCreateOrigin(null);
|
||||
errdefer page._session.releaseOrigin(origin);
|
||||
const session = page._session;
|
||||
const origin = try session.getOrCreateOrigin(null);
|
||||
errdefer 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 = page.call_arena,
|
||||
.call_arena = params.call_arena,
|
||||
.microtask_queue = microtask_queue,
|
||||
.script_manager = &page._script_manager,
|
||||
.scheduler = .init(context_arena),
|
||||
.identity = params.identity,
|
||||
.identity_arena = params.identity_arena,
|
||||
};
|
||||
try context.origin.identity_map.putNoClobber(origin.arena, @intFromPtr(page.window), global_global);
|
||||
|
||||
{
|
||||
// Multiple contexts can be created for the same Window (via CDP). We only
|
||||
// need to register the first one.
|
||||
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, @intFromPtr(page.window));
|
||||
if (gop.found_existing == false) {
|
||||
// our window wrapped in a v8::Global
|
||||
var global_global: v8.Global = undefined;
|
||||
v8.v8__Global__New(isolate.handle, global_obj, &global_global);
|
||||
gop.value_ptr.* = global_global;
|
||||
}
|
||||
}
|
||||
|
||||
// Store a pointer to our context inside the v8 context so that, given
|
||||
// a v8 context, we can get our context out
|
||||
|
||||
@@ -21,6 +21,7 @@ const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Function = @This();
|
||||
|
||||
@@ -210,10 +211,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, .origin = {} };
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||
}
|
||||
|
||||
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
|
||||
@@ -237,7 +238,7 @@ const GlobalType = enum(u8) {
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -257,7 +258,10 @@ fn G(comptime global_type: GlobalType) type {
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
76
src/browser/js/Identity.zig
Normal file
76
src/browser/js/Identity.zig
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,21 @@ pub fn createError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||
return v8.v8__Exception__Error(message).?;
|
||||
}
|
||||
|
||||
pub fn createRangeError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||
const message = self.initStringHandle(msg);
|
||||
return v8.v8__Exception__RangeError(message).?;
|
||||
}
|
||||
|
||||
pub fn createReferenceError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||
const message = self.initStringHandle(msg);
|
||||
return v8.v8__Exception__ReferenceError(message).?;
|
||||
}
|
||||
|
||||
pub fn createSyntaxError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||
const message = self.initStringHandle(msg);
|
||||
return v8.v8__Exception__SyntaxError(message).?;
|
||||
}
|
||||
|
||||
pub fn createTypeError(self: Isolate, msg: []const u8) *const v8.Value {
|
||||
const message = self.initStringHandle(msg);
|
||||
return v8.v8__Exception__TypeError(message).?;
|
||||
|
||||
@@ -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 origin_arena = ctx.origin.arena;
|
||||
const context_arena = ctx.arena;
|
||||
|
||||
const T = @TypeOf(value);
|
||||
switch (@typeInfo(T)) {
|
||||
.@"struct" => {
|
||||
// Struct, has to be placed on the heap
|
||||
const heap = try origin_arena.create(T);
|
||||
const heap = try context_arena.create(T);
|
||||
heap.* = value;
|
||||
return self.mapZigInstanceToJs(js_obj_handle, heap);
|
||||
},
|
||||
.pointer => |ptr| {
|
||||
const resolved = resolveValue(value);
|
||||
|
||||
const gop = try ctx.origin.addIdentity(@intFromPtr(resolved.ptr));
|
||||
const gop = try ctx.addIdentity(@intFromPtr(resolved.ptr));
|
||||
if (gop.found_existing) {
|
||||
// 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 origin_arena.create(TaggedOpaque);
|
||||
const tao = try context_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.origin.createFinalizerCallback(ctx.session, gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
||||
const fc = try ctx.createFinalizerCallback(gop.value_ptr.*, resolved.ptr, resolved.finalizer_from_zig.?);
|
||||
{
|
||||
errdefer fc.deinit();
|
||||
try ctx.origin.finalizer_callbacks.put(ctx.origin.arena, @intFromPtr(resolved.ptr), fc);
|
||||
try ctx.identity.finalizer_callbacks.put(ctx.identity_arena, @intFromPtr(resolved.ptr), fc);
|
||||
}
|
||||
|
||||
conditionallyReference(value);
|
||||
@@ -1206,9 +1206,9 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
|
||||
}
|
||||
|
||||
// == Promise Helpers ==
|
||||
pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
|
||||
pub fn rejectPromise(self: *const Local, err: js.PromiseResolver.RejectError) js.Promise {
|
||||
var resolver = js.PromiseResolver.init(self);
|
||||
resolver.reject("Local.rejectPromise", value);
|
||||
resolver.rejectError("Local.rejectPromise", err);
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,19 +16,21 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Origin represents the shared Zig<->JS bridge state for all contexts within
|
||||
// the same origin. Multiple contexts (frames) from the same origin share a
|
||||
// single Origin, ensuring that JS objects maintain their identity across frames.
|
||||
// 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.
|
||||
|
||||
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();
|
||||
|
||||
@@ -38,40 +40,12 @@ arena: Allocator,
|
||||
// The key, e.g. lightpanda.io:443
|
||||
key: []const u8,
|
||||
|
||||
// Security token - all contexts in this realm must use the same v8::Value instance
|
||||
// Security token - all contexts in this origin must use the same v8::Value instance
|
||||
// as their security token for V8 to allow cross-context access
|
||||
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();
|
||||
const arena = try app.arena_pool.acquire(.{ .debug = "Origin" });
|
||||
errdefer app.arena_pool.release(arena);
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
@@ -88,175 +62,12 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
// 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,
|
||||
@@ -63,10 +66,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, .origin = {} };
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||
}
|
||||
|
||||
pub const Temp = G(.temp);
|
||||
@@ -80,7 +83,7 @@ const GlobalType = enum(u8) {
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -96,7 +99,10 @@ fn G(comptime global_type: GlobalType) type {
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const DOMException = @import("../webapi/DOMException.zig");
|
||||
|
||||
const PromiseResolver = @This();
|
||||
@@ -66,19 +67,37 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
|
||||
}
|
||||
|
||||
pub const RejectError = union(enum) {
|
||||
generic: []const u8,
|
||||
/// Not to be confused with `DOMException`; this is bare `Error`.
|
||||
generic_error: []const u8,
|
||||
range_error: []const u8,
|
||||
reference_error: []const u8,
|
||||
syntax_error: []const u8,
|
||||
type_error: []const u8,
|
||||
dom_exception: anyerror,
|
||||
/// DOM exceptions are unknown to V8, belongs to web standards.
|
||||
dom_exception: struct { err: anyerror },
|
||||
};
|
||||
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
|
||||
|
||||
/// Rejects the promise w/ an error object.
|
||||
pub fn rejectError(
|
||||
self: PromiseResolver,
|
||||
comptime source: []const u8,
|
||||
err: RejectError,
|
||||
) void {
|
||||
const handle = switch (err) {
|
||||
.type_error => |str| self.local.isolate.createTypeError(str),
|
||||
.generic => |str| self.local.isolate.createError(str),
|
||||
.generic_error => |msg| self.local.isolate.createError(msg),
|
||||
.range_error => |msg| self.local.isolate.createRangeError(msg),
|
||||
.reference_error => |msg| self.local.isolate.createReferenceError(msg),
|
||||
.syntax_error => |msg| self.local.isolate.createSyntaxError(msg),
|
||||
.type_error => |msg| self.local.isolate.createTypeError(msg),
|
||||
// "Exceptional".
|
||||
.dom_exception => |exception| {
|
||||
self.reject(source, DOMException.fromError(exception));
|
||||
self._reject(DOMException.fromError(exception.err) orelse unreachable) catch |reject_err| {
|
||||
log.err(.bug, "rejectDomException", .{ .source = source, .err = reject_err, .persistent = false });
|
||||
};
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
|
||||
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
|
||||
};
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn create() !Snapshot {
|
||||
defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
|
||||
|
||||
// Create templates (constructors only) FIRST
|
||||
var templates: [JsApis.len]*v8.FunctionTemplate = undefined;
|
||||
var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
|
||||
inline for (JsApis, 0..) |JsApi, i| {
|
||||
@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) *v8.FunctionTemplate {
|
||||
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const 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) *v8.FunctionT
|
||||
break :blk illegalConstructorCallback;
|
||||
};
|
||||
|
||||
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
|
||||
const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
|
||||
{
|
||||
const internal_field_count = comptime countInternalFields(JsApi);
|
||||
if (internal_field_count > 0) {
|
||||
@@ -482,10 +482,15 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
|
||||
}
|
||||
|
||||
// Attaches JsApi members to the prototype template (normal case)
|
||||
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
|
||||
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const 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;
|
||||
|
||||
@@ -497,23 +502,47 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
||||
switch (definition) {
|
||||
bridge.Accessor => {
|
||||
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?);
|
||||
const getter_signature = if (value.static) null else signature;
|
||||
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = value.getter,
|
||||
.signature = getter_signature,
|
||||
}).?;
|
||||
const setter_callback = if (value.setter) |setter|
|
||||
v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = setter,
|
||||
.signature = getter_signature,
|
||||
}).?
|
||||
else
|
||||
null;
|
||||
|
||||
var attribute: v8.PropertyAttribute = 0;
|
||||
if (value.setter == null) {
|
||||
if (value.static) {
|
||||
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback);
|
||||
} else {
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback);
|
||||
}
|
||||
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);
|
||||
} else {
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(value.static == false);
|
||||
}
|
||||
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?);
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback);
|
||||
v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
|
||||
.key = js_name,
|
||||
.getter = getter_callback,
|
||||
.setter = setter_callback,
|
||||
.attribute = attribute,
|
||||
});
|
||||
}
|
||||
},
|
||||
bridge.Function => {
|
||||
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?);
|
||||
// For non-static functions, use the signature to validate the receiver
|
||||
const func_signature = if (value.static) null else signature;
|
||||
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
|
||||
.callback = value.func,
|
||||
.length = value.arity,
|
||||
.signature = func_signature,
|
||||
}).?;
|
||||
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
|
||||
if (value.static) {
|
||||
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
|
||||
@@ -551,7 +580,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
|
||||
has_named_index_getter = true;
|
||||
},
|
||||
bridge.Iterator => {
|
||||
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?);
|
||||
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
|
||||
const js_name = if (value.async)
|
||||
v8.v8__Symbol__GetAsyncIterator(isolate)
|
||||
else
|
||||
|
||||
@@ -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.origin.arena) };
|
||||
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.page_arena) };
|
||||
}
|
||||
return self.toSSOWithAlloc(self.local.call_arena);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const v8 = js.v8;
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Value = @This();
|
||||
|
||||
@@ -300,10 +301,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, .origin = {} };
|
||||
return .{ .handle = global, .temps = {} };
|
||||
}
|
||||
try ctx.trackTemp(global);
|
||||
return .{ .handle = global, .origin = ctx.origin };
|
||||
return .{ .handle = global, .temps = &ctx.identity.temps };
|
||||
}
|
||||
|
||||
pub fn toZig(self: Value, comptime T: type) !T {
|
||||
@@ -361,7 +362,7 @@ const GlobalType = enum(u8) {
|
||||
fn G(comptime global_type: GlobalType) type {
|
||||
return struct {
|
||||
handle: v8.Global,
|
||||
origin: if (global_type == .temp) *js.Origin else void,
|
||||
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -381,7 +382,10 @@ fn G(comptime global_type: GlobalType) type {
|
||||
}
|
||||
|
||||
pub fn release(self: *const Self) void {
|
||||
self.origin.releaseTemp(self.handle);
|
||||
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
|
||||
var g = kv.value;
|
||||
v8.v8__Global__Reset(&g);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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;
|
||||
|
||||
@@ -117,13 +116,12 @@ 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: *Origin.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
||||
const fc: *Session.FinalizerCallback = @ptrCast(@alignCast(ptr));
|
||||
|
||||
const origin = fc.origin;
|
||||
const value_ptr = fc.ptr;
|
||||
if (origin.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
||||
if (fc.identity.finalizer_callbacks.contains(@intFromPtr(value_ptr))) {
|
||||
func(@ptrCast(@alignCast(value_ptr)), false, fc.session);
|
||||
origin.release(value_ptr);
|
||||
fc.releaseIdentity();
|
||||
} else {
|
||||
// A bit weird, but v8 _requires_ that we release it
|
||||
// If we don't. We'll 100% crash.
|
||||
@@ -200,6 +198,7 @@ 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,
|
||||
@@ -208,6 +207,7 @@ pub const Accessor = struct {
|
||||
var accessor = Accessor{
|
||||
.cache = opts.cache,
|
||||
.static = opts.static,
|
||||
.deletable = opts.deletable,
|
||||
};
|
||||
|
||||
if (@typeInfo(@TypeOf(getter)) != .null) {
|
||||
@@ -852,6 +852,7 @@ 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"),
|
||||
@@ -901,6 +902,7 @@ 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"),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ 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");
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
a1.play();
|
||||
cb.push(a1.playState);
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
|
||||
testing.onload(() => 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.eventually(() => testing.expectEqual(['finish'], calls));
|
||||
testing.onload(() => testing.expectEqual(['finish'], calls));
|
||||
</script>
|
||||
|
||||
<script id=pause>
|
||||
@@ -52,7 +52,7 @@
|
||||
a4.pause();
|
||||
cb4.push(a4.playState)
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(['running', 'paused'], cb4));
|
||||
testing.onload(() => testing.expectEqual(['running', 'paused'], cb4));
|
||||
</script>
|
||||
|
||||
<script id=finish>
|
||||
@@ -65,5 +65,6 @@
|
||||
cb5.push(a5.playState);
|
||||
a5.play();
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
|
||||
testing.onload(() => testing.expectEqual(['idle', 'finished'], cb5));
|
||||
</script>
|
||||
-->
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
document.fonts.load("italic bold 16px Roboto");
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, loading);
|
||||
testing.expectEqual(true, loadingdone);
|
||||
});
|
||||
|
||||
@@ -419,3 +419,117 @@
|
||||
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>
|
||||
|
||||
@@ -342,3 +342,4 @@
|
||||
testing.expectEqual('html', doc.lastChild.nodeName);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
document.open();
|
||||
}, 5);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
// The element should be gone now
|
||||
const afterOpen = document.getElementById('will_be_removed');
|
||||
testing.expectEqual(null, afterOpen);
|
||||
|
||||
226
src/browser/tests/element/check_visibility.html
Normal file
226
src/browser/tests/element/check_visibility.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
<body></body>
|
||||
|
||||
<!-
|
||||
<script id="inline_display_none">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.style.display = "none";
|
||||
testing.expectEqual(false, el.checkVisibility());
|
||||
|
||||
el.style.display = "block";
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inline_visibility_hidden">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
|
||||
el.style.visibility = "hidden";
|
||||
// Without visibilityProperty option, visibility:hidden is not checked
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
// With visibilityProperty: true, visibility:hidden is detected
|
||||
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
|
||||
|
||||
el.style.visibility = "collapse";
|
||||
testing.expectEqual(false, el.checkVisibility({ visibilityProperty: true }));
|
||||
|
||||
el.style.visibility = "visible";
|
||||
testing.expectEqual(true, el.checkVisibility({ visibilityProperty: true }));
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inline_opacity_zero">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
|
||||
el.style.opacity = "0";
|
||||
// Without checkOpacity option, opacity:0 is not checked
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
// With checkOpacity: true, opacity:0 is detected
|
||||
testing.expectEqual(false, el.checkVisibility({ checkOpacity: true }));
|
||||
|
||||
el.style.opacity = "0.5";
|
||||
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
|
||||
|
||||
el.style.opacity = "1";
|
||||
testing.expectEqual(true, el.checkVisibility({ checkOpacity: true }));
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="parent_hidden_hides_child">
|
||||
{
|
||||
const parent = document.createElement("div");
|
||||
const child = document.createElement("span");
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
testing.expectEqual(true, child.checkVisibility());
|
||||
|
||||
// display:none on parent hides children (no option needed)
|
||||
parent.style.display = "none";
|
||||
testing.expectEqual(false, child.checkVisibility());
|
||||
|
||||
// visibility:hidden on parent - needs visibilityProperty option
|
||||
parent.style.display = "block";
|
||||
parent.style.visibility = "hidden";
|
||||
testing.expectEqual(true, child.checkVisibility()); // without option
|
||||
testing.expectEqual(false, child.checkVisibility({ visibilityProperty: true }));
|
||||
|
||||
// opacity:0 on parent - needs checkOpacity option
|
||||
parent.style.visibility = "visible";
|
||||
parent.style.opacity = "0";
|
||||
testing.expectEqual(true, child.checkVisibility()); // without option
|
||||
testing.expectEqual(false, child.checkVisibility({ checkOpacity: true }));
|
||||
|
||||
parent.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style id="style-basic">
|
||||
.hidden-by-class { display: none; }
|
||||
.visible-by-class { display: block; }
|
||||
</style>
|
||||
<script id="style_tag_basic">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.className = "hidden-by-class";
|
||||
testing.expectEqual(false, el.checkVisibility());
|
||||
|
||||
el.className = "visible-by-class";
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.className = "";
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style id="style-specificity">
|
||||
.spec-hidden { display: none; }
|
||||
#spec-visible { display: block; }
|
||||
</style>
|
||||
<script id="specificity_id_beats_class">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
el.id = "spec-visible";
|
||||
el.className = "spec-hidden";
|
||||
document.body.appendChild(el);
|
||||
|
||||
// ID selector (#spec-visible: display:block) should beat class selector (.spec-hidden: display:none)
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style id="style-order-1">
|
||||
.order-test { display: none; }
|
||||
</style>
|
||||
<style id="style-order-2">
|
||||
.order-test { display: block; }
|
||||
</style>
|
||||
<script id="rule_order_later_wins">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
el.className = "order-test";
|
||||
document.body.appendChild(el);
|
||||
|
||||
// Second style block should win (display: block)
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style id="style-override">
|
||||
.should-be-hidden { display: none; }
|
||||
</style>
|
||||
<script id="inline_overrides_stylesheet">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
el.className = "should-be-hidden";
|
||||
document.body.appendChild(el);
|
||||
|
||||
testing.expectEqual(false, el.checkVisibility());
|
||||
|
||||
// Inline style should override
|
||||
el.style.display = "block";
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="dynamic_style_element">
|
||||
{
|
||||
const el = document.createElement("div");
|
||||
el.className = "dynamic-style-test";
|
||||
document.body.appendChild(el);
|
||||
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
// Add a style element
|
||||
const style = document.createElement("style");
|
||||
style.textContent = ".dynamic-style-test { display: none; }";
|
||||
document.head.appendChild(style);
|
||||
|
||||
testing.expectEqual(false, el.checkVisibility());
|
||||
|
||||
// Remove the style element
|
||||
style.remove();
|
||||
testing.expectEqual(true, el.checkVisibility());
|
||||
|
||||
el.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="deep_nesting">
|
||||
{
|
||||
const levels = 5;
|
||||
let current = document.body;
|
||||
const elements = [];
|
||||
|
||||
for (let i = 0; i < levels; i++) {
|
||||
const el = document.createElement("div");
|
||||
current.appendChild(el);
|
||||
elements.push(el);
|
||||
current = el;
|
||||
}
|
||||
|
||||
// All should be visible
|
||||
for (let i = 0; i < levels; i++) {
|
||||
testing.expectEqual(true, elements[i].checkVisibility());
|
||||
}
|
||||
|
||||
// Hide middle element
|
||||
elements[2].style.display = "none";
|
||||
|
||||
// Elements 0, 1 should still be visible
|
||||
testing.expectEqual(true, elements[0].checkVisibility());
|
||||
testing.expectEqual(true, elements[1].checkVisibility());
|
||||
|
||||
// Elements 2, 3, 4 should be hidden
|
||||
testing.expectEqual(false, elements[2].checkVisibility());
|
||||
testing.expectEqual(false, elements[3].checkVisibility());
|
||||
testing.expectEqual(false, elements[4].checkVisibility());
|
||||
|
||||
elements[0].remove();
|
||||
}
|
||||
</script>
|
||||
@@ -532,6 +532,6 @@
|
||||
testing.expectEqual(true, result);
|
||||
});
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched));
|
||||
testing.onload(() => testing.expectEqual(true, asyncBlockDispatched));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -463,3 +463,44 @@
|
||||
});
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
|
||||
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'));
|
||||
|
||||
@@ -137,7 +139,7 @@
|
||||
});
|
||||
});
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, result));
|
||||
testing.onload(() => testing.expectEqual(true, result));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,7 +150,7 @@
|
||||
const img = document.createElement("img");
|
||||
img.addEventListener("load", () => { fired = true; });
|
||||
document.body.appendChild(img);
|
||||
testing.eventually(() => testing.expectEqual(false, fired));
|
||||
testing.onload(() => testing.expectEqual(false, fired));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -161,7 +163,7 @@
|
||||
document.body.appendChild(img);
|
||||
img.src = "https://cdn.lightpanda.io/website/assets/images/docs/hn.png";
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, result));
|
||||
testing.onload(() => testing.expectEqual(true, result));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
});
|
||||
input.setSelectionRange(1, 4);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(5, eventCount);
|
||||
testing.expectEqual('selectionchange', lastEvent.type);
|
||||
testing.expectEqual(input, lastEvent.target);
|
||||
@@ -247,7 +247,7 @@
|
||||
|
||||
input.select();
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, eventCount);
|
||||
testing.expectEqual('select', lastEvent.type);
|
||||
testing.expectEqual(input, lastEvent.target);
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
link.rel = 'stylesheet';
|
||||
link.addEventListener('load', () => { fired = true; });
|
||||
document.head.appendChild(link);
|
||||
testing.eventually(() => testing.expectEqual(false, fired));
|
||||
testing.onload(() => 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.eventually(() => testing.expectEqual(false, fired));
|
||||
testing.onload(() => testing.expectEqual(false, fired));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
// then set href.
|
||||
link.href = 'https://lightpanda.io/opensource-browser/15';
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, result));
|
||||
testing.onload(() => testing.expectEqual(true, result));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
});
|
||||
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
results.forEach((r) => {
|
||||
testing.expectEqual(true, r);
|
||||
});
|
||||
|
||||
@@ -236,9 +236,11 @@
|
||||
{
|
||||
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>
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
script1.async = false;
|
||||
script1.src = "dynamic1.js";
|
||||
document.getElementsByTagName('head')[0].appendChild(script1);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, loaded1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=no_double_execute>
|
||||
document.getElementsByTagName('head')[0].appendChild(script1);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(2, loaded2);
|
||||
});
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
<script id=src_after_append>
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(2, loaded2);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
s6.type = 'module';
|
||||
s6.textContent = 'window.module_executed = true;';
|
||||
document.head.appendChild(s6);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectTrue(window.module_executed);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
testing.expectEqual(testing.BASE_URL + 'element/html/script/empty.js', s.src);
|
||||
document.head.appendChild(s);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, dom_load);
|
||||
testing.expectEqual(true, attribute_load);
|
||||
});
|
||||
|
||||
@@ -427,7 +427,7 @@
|
||||
div.setAttribute('slot', 'content');
|
||||
host.appendChild(div);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls);
|
||||
});
|
||||
}
|
||||
@@ -455,7 +455,7 @@
|
||||
|
||||
div.setAttribute('slot', 'other');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls);
|
||||
});
|
||||
}
|
||||
@@ -483,7 +483,7 @@
|
||||
|
||||
div.remove();
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls);
|
||||
});
|
||||
}
|
||||
@@ -511,7 +511,7 @@
|
||||
|
||||
div.slot = 'other';
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@
|
||||
});
|
||||
});
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, result));
|
||||
testing.onload(() => testing.expectEqual(true, result));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="style-tag-content-parsing">
|
||||
{
|
||||
const style = document.createElement("style");
|
||||
style.textContent = '.content-test { padding: 5px; }';
|
||||
document.head.appendChild(style);
|
||||
|
||||
const sheet = style.sheet;
|
||||
testing.expectTrue(sheet instanceof CSSStyleSheet);
|
||||
testing.expectEqual(1, sheet.cssRules.length);
|
||||
testing.expectEqual('.content-test', sheet.cssRules[0].selectorText);
|
||||
testing.expectEqual('5px', sheet.cssRules[0].style.padding);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -256,7 +256,7 @@
|
||||
|
||||
textarea.select();
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, eventCount);
|
||||
testing.expectEqual('select', lastEvent.type);
|
||||
testing.expectEqual(textarea, lastEvent.target);
|
||||
@@ -295,7 +295,7 @@
|
||||
});
|
||||
textarea.setSelectionRange(1, 4);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(5, eventCount);
|
||||
testing.expectEqual('selectionchange', lastEvent.type);
|
||||
testing.expectEqual(textarea, lastEvent.target);
|
||||
|
||||
139
src/browser/tests/element/replace_children.html
Normal file
139
src/browser/tests/element/replace_children.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<head>
|
||||
<title>element.replaceChildren Tests</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="test">Original content</div>
|
||||
</body>
|
||||
|
||||
<script id=error_replace_with_self>
|
||||
{
|
||||
// Test that element.replaceChildren(element) throws HierarchyRequestError
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
testing.expectError('HierarchyRequest', () => {
|
||||
doc.body.replaceChildren(doc.body);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=error_replace_with_ancestor>
|
||||
{
|
||||
// Test that replacing with an ancestor throws HierarchyRequestError
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const child = doc.createElement('div');
|
||||
doc.body.appendChild(child);
|
||||
|
||||
testing.expectError('HierarchyRequest', () => {
|
||||
child.replaceChildren(doc.body);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_basic>
|
||||
{
|
||||
// Test basic element.replaceChildren
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const child1 = doc.createElement('div');
|
||||
const child2 = doc.createElement('span');
|
||||
doc.body.appendChild(child1);
|
||||
|
||||
doc.body.replaceChildren(child2);
|
||||
|
||||
testing.expectEqual(1, doc.body.childNodes.length);
|
||||
testing.expectEqual(child2, doc.body.firstChild);
|
||||
testing.expectEqual(null, child1.parentNode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_empty>
|
||||
{
|
||||
// Test element.replaceChildren with no arguments removes all children
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
doc.body.appendChild(doc.createElement('div'));
|
||||
doc.body.appendChild(doc.createElement('span'));
|
||||
|
||||
doc.body.replaceChildren();
|
||||
|
||||
testing.expectEqual(0, doc.body.childNodes.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_fragment>
|
||||
{
|
||||
// Test element.replaceChildren with DocumentFragment
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const frag = doc.createDocumentFragment();
|
||||
frag.appendChild(doc.createElement('div'));
|
||||
frag.appendChild(doc.createElement('span'));
|
||||
|
||||
doc.body.replaceChildren(frag);
|
||||
|
||||
testing.expectEqual(2, doc.body.childNodes.length);
|
||||
testing.expectEqual('DIV', doc.body.firstChild.tagName);
|
||||
testing.expectEqual('SPAN', doc.body.lastChild.tagName);
|
||||
testing.expectEqual(0, frag.childNodes.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=error_fragment_replace_with_self>
|
||||
{
|
||||
// Test that replacing with a fragment containing self throws
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const frag = doc.createDocumentFragment();
|
||||
const child = doc.createElement('div');
|
||||
frag.appendChild(child);
|
||||
|
||||
testing.expectError('HierarchyRequest', () => {
|
||||
child.replaceChildren(frag);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_text>
|
||||
{
|
||||
// Test element.replaceChildren with text
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
doc.body.appendChild(doc.createElement('div'));
|
||||
|
||||
doc.body.replaceChildren('Hello', 'World');
|
||||
|
||||
testing.expectEqual(2, doc.body.childNodes.length);
|
||||
testing.expectEqual('Hello', doc.body.firstChild.textContent);
|
||||
testing.expectEqual('World', doc.body.lastChild.textContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_mixed>
|
||||
{
|
||||
// Test element.replaceChildren with mixed nodes and text
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const span = doc.createElement('span');
|
||||
span.textContent = 'middle';
|
||||
|
||||
doc.body.replaceChildren('start', span, 'end');
|
||||
|
||||
testing.expectEqual(3, doc.body.childNodes.length);
|
||||
testing.expectEqual('start', doc.body.childNodes[0].textContent);
|
||||
testing.expectEqual('SPAN', doc.body.childNodes[1].tagName);
|
||||
testing.expectEqual('end', doc.body.childNodes[2].textContent);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=replace_children_reparents>
|
||||
{
|
||||
// Test that replaceChildren properly reparents nodes from another parent
|
||||
const doc = document.implementation.createHTMLDocument("title");
|
||||
const div1 = doc.createElement('div');
|
||||
const div2 = doc.createElement('div');
|
||||
const child = doc.createElement('span');
|
||||
|
||||
div1.appendChild(child);
|
||||
testing.expectEqual(div1, child.parentNode);
|
||||
|
||||
div2.replaceChildren(child);
|
||||
testing.expectEqual(div2, child.parentNode);
|
||||
testing.expectEqual(0, div1.childNodes.length);
|
||||
}
|
||||
</script>
|
||||
@@ -242,7 +242,7 @@
|
||||
|
||||
<script id=abortsignal_timeout>
|
||||
var s3 = AbortSignal.timeout(10);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, s3.aborted);
|
||||
testing.expectEqual('TimeoutError', s3.reason);
|
||||
testing.expectError('Error: TimeoutError', () => {
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
window.postMessage('test data', '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(testObj, receivedData);
|
||||
});
|
||||
}
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
window.postMessage(42, '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(42, received);
|
||||
});
|
||||
}
|
||||
@@ -129,7 +129,7 @@
|
||||
const arr = [1, 2, 3, 'test'];
|
||||
window.postMessage(arr, '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(arr, received);
|
||||
});
|
||||
}
|
||||
@@ -146,7 +146,7 @@
|
||||
|
||||
window.postMessage(null, '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(null, received);
|
||||
});
|
||||
}
|
||||
@@ -163,7 +163,7 @@
|
||||
|
||||
window.postMessage('test', '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('http://127.0.0.1:9582', receivedOrigin);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
window.postMessage('trigger', '*');
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(2, count);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
$('#f2').src = 'support/sub2.html';
|
||||
testing.expectEqual(true, true);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual("<html><head></head><body></body></html>", f4.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
}
|
||||
@@ -112,35 +112,47 @@
|
||||
document.documentElement.appendChild(f5);
|
||||
f5.src = "about:blank";
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual("<html><head></head><body></body></html>", f5.contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<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();
|
||||
<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(() => {
|
||||
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.eventually(() => {
|
||||
testing.expectEqual(8, window.length);
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(9, window.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
{
|
||||
let reply = null;
|
||||
window.addEventListener('message', (e) => {
|
||||
console.warn('reply')
|
||||
reply = e.data;
|
||||
});
|
||||
|
||||
@@ -17,7 +16,7 @@
|
||||
iframe.contentWindow.postMessage('ping', '*');
|
||||
});
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('pong', reply.data);
|
||||
testing.expectEqual(testing.ORIGIN, reply.origin);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<!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}, '*');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a id=l1 target=f1 href=support/page.html></a>
|
||||
<script id=anchor>
|
||||
$('#l1').click();
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#f3').contentDocument.documentElement.outerHTML);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, window.support_history_completed);
|
||||
testing.expectEqual(true, window.support_history_popstateEventFired);
|
||||
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, callbackCalled);
|
||||
testing.expectEqual(1, entries.length);
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
count += 1;
|
||||
}).observe(div);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(0, count);
|
||||
});
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
}).observe(div1);
|
||||
|
||||
div2.appendChild(div1);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, count);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, callCount);
|
||||
|
||||
observer.disconnect();
|
||||
@@ -22,7 +22,7 @@
|
||||
const observer2 = new IntersectionObserver(() => {});
|
||||
observer2.observe(target);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
observer2.disconnect();
|
||||
testing.expectEqual(1, callCount);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
observer.observe(target1);
|
||||
observer.observe(target2);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(2, entryCount);
|
||||
testing.expectTrue(seenTargets.has(target1));
|
||||
testing.expectTrue(seenTargets.has(target2));
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
observer.unobserve(target1);
|
||||
observer.observe(target2);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
// Should only see target2, not target1
|
||||
testing.expectEqual(1, seenTargets.length);
|
||||
testing.expectEqual(target2, seenTargets[0]);
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
|
||||
let replaced = false;
|
||||
css.replace('body{}').then(() => replaced = true);
|
||||
testing.eventually(() => testing.expectEqual(true, replaced));
|
||||
testing.onload(() => testing.expectEqual(true, replaced));
|
||||
</script>
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
cb.push('finished');
|
||||
cb.push(x == a1);
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(['finished', true], cb));
|
||||
testing.onload(() => testing.expectEqual(['finished', true], cb));
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
count += 1;
|
||||
}).observe(div);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(0, count);
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
}).observe(div1);
|
||||
|
||||
div2.appendChild(div1);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, count);
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
observer.observe(div1);
|
||||
testing.expectEqual(0, count);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, count);
|
||||
});
|
||||
}
|
||||
@@ -75,7 +75,7 @@
|
||||
testing.expectEqual(0, count);
|
||||
|
||||
observer.unobserve(div1);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(0, count);
|
||||
});
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
testing.expectEqual(0, count);
|
||||
observer.disconnect();
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(0, count);
|
||||
});
|
||||
}
|
||||
@@ -117,7 +117,7 @@
|
||||
document.body.appendChild(div1);
|
||||
new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual([
|
||||
'pre-append',
|
||||
'post-append',
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<script id=timeout>
|
||||
var s3 = AbortSignal.timeout(10);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, s3.aborted);
|
||||
testing.expectEqual('TimeoutError', s3.reason);
|
||||
testing.expectError('Error: TimeoutError', () => {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
popstateEventState = event.state;
|
||||
});
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, popstateEventFired);
|
||||
testing.expectEqual(state, popstateEventState);
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
popstateEventState = event.state;
|
||||
};
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, popstateEventFired);
|
||||
testing.expectEqual(state, popstateEventState);
|
||||
})
|
||||
|
||||
@@ -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.eventually(() => testing.expectEqual(2, dyn1_loaded));
|
||||
testing.onload(() => testing.expectEqual(2, dyn1_loaded));
|
||||
</script>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
lp.appendChild(div);
|
||||
testing.expectEqual(slot, div.assignedSlot);
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls)
|
||||
});
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
|
||||
const div = $('#s2');
|
||||
div.removeAttribute('slot');
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls)
|
||||
});
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
const div = $('#s3');
|
||||
div.slot = 'other';
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls)
|
||||
});
|
||||
}
|
||||
@@ -154,7 +154,7 @@
|
||||
div.slot = 'other';
|
||||
lp.appendChild(div);
|
||||
div.slot = 'slot-1'
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls)
|
||||
});
|
||||
}
|
||||
@@ -172,7 +172,7 @@
|
||||
});
|
||||
|
||||
$('#s5').remove();
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, calls)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
start = timestamp;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, start > 0)
|
||||
});
|
||||
|
||||
@@ -24,23 +24,23 @@
|
||||
start = 0;
|
||||
});
|
||||
cancelAnimationFrame(request_id);
|
||||
testing.eventually(() => testing.expectEqual(true, start > 0));
|
||||
testing.onload(() => testing.expectEqual(true, start > 0));
|
||||
</script>
|
||||
|
||||
<script id=setTimeout>
|
||||
let longCall = false;
|
||||
window.setTimeout(() => {longCall = true}, 5001);
|
||||
testing.eventually(() => testing.expectEqual(false, longCall));
|
||||
testing.onload(() => testing.expectEqual(false, longCall));
|
||||
|
||||
let wst1 = 0;
|
||||
window.setTimeout(() => {wst1 += 1}, 1);
|
||||
testing.eventually(() => testing.expectEqual(1, wst1));
|
||||
testing.onload(() => testing.expectEqual(1, wst1));
|
||||
|
||||
let wst2 = 1;
|
||||
window.setTimeout((a, b) => {
|
||||
wst2 = a + b;
|
||||
}, 1, 2, 3);
|
||||
testing.eventually(() => testing.expectEqual(5, wst2));
|
||||
testing.onload(() => testing.expectEqual(5, wst2));
|
||||
</script>
|
||||
|
||||
<script id=eventTarget>
|
||||
@@ -70,7 +70,7 @@
|
||||
<script id=queueMicroTask>
|
||||
var qm = false;
|
||||
window.queueMicrotask(() => {qm = true });
|
||||
testing.eventually(() => testing.expectEqual(true, qm));
|
||||
testing.onload(() => testing.expectEqual(true, qm));
|
||||
</script>
|
||||
|
||||
<script id=DOMContentLoaded>
|
||||
@@ -79,7 +79,7 @@
|
||||
window.addEventListener('DOMContentLoaded', (e) => {
|
||||
dcl = e.target == document;
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(true, dcl));
|
||||
testing.onload(() => testing.expectEqual(true, dcl));
|
||||
</script>
|
||||
|
||||
<script id=window.onload>
|
||||
@@ -97,7 +97,7 @@
|
||||
window.onload = callback;
|
||||
testing.expectEqual(callback, window.onload);
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, isDocumentTarget));
|
||||
testing.onload(() => testing.expectEqual(true, isDocumentTarget));
|
||||
</script>
|
||||
|
||||
<script id=reportError>
|
||||
|
||||
14
src/browser/tests/mcp_wait_for_selector.html
Normal file
14
src/browser/tests/mcp_wait_for_selector.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="existing">Already here</div>
|
||||
<script>
|
||||
setTimeout(function() {
|
||||
var el = document.createElement("div");
|
||||
el.id = "delayed";
|
||||
el.textContent = "Appeared after delay";
|
||||
document.body.appendChild(el);
|
||||
}, 20);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,3 +28,40 @@
|
||||
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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
document.body.appendChild(iframe);
|
||||
iframe.src = blob_url;
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('Hello Blob', iframe.contentDocument.getElementById('test').textContent);
|
||||
});
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
document.body.appendChild(iframe2);
|
||||
iframe2.src = url2;
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('First', iframe1.contentDocument.body.textContent);
|
||||
testing.expectEqual('Second', iframe2.contentDocument.body.textContent);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
call1 = true;
|
||||
});
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(document, ex1.target);
|
||||
testing.expectEqual('DOMContentLoaded', ex1.type);
|
||||
testing.expectEqual(true, call1);
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
// With buffered: true, existing marks should be delivered
|
||||
observer.observe({ type: "mark", buffered: true });
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, receivedEntries !== null);
|
||||
testing.expectEqual(2, receivedEntries.length);
|
||||
testing.expectEqual("early1", receivedEntries[0].name);
|
||||
|
||||
@@ -582,7 +582,7 @@
|
||||
document.removeEventListener('selectionchange', listener);
|
||||
textNode.textContent = "The quick brown fox";
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(14, eventCount);
|
||||
testing.expectEqual('selectionchange', lastEvent.type);
|
||||
testing.expectEqual(document, lastEvent.target);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let eventuallies = [];
|
||||
let async_capture = null;
|
||||
let current_script_id = null;
|
||||
let async_pending = 0;
|
||||
|
||||
function expectTrue(actual) {
|
||||
expectEqual(true, actual);
|
||||
@@ -52,10 +53,10 @@
|
||||
throw new Error('no error');
|
||||
}
|
||||
|
||||
function eventually(cb) {
|
||||
function onload(cb) {
|
||||
const script_id = _currentScriptId();
|
||||
if (!script_id) {
|
||||
throw new Error('testing.eventually called outside of a script');
|
||||
throw new Error('testing.onload called outside of a script');
|
||||
}
|
||||
eventuallies.push({
|
||||
callback: cb,
|
||||
@@ -64,6 +65,25 @@
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -74,6 +94,10 @@
|
||||
throw new Error('Failed');
|
||||
}
|
||||
|
||||
if (async_pending > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let e of eventuallies) {
|
||||
current_script_id = e.script_id;
|
||||
e.callback();
|
||||
@@ -97,6 +121,8 @@
|
||||
throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
|
||||
@@ -110,7 +136,7 @@
|
||||
expectEqual: expectEqual,
|
||||
expectError: expectError,
|
||||
withError: withError,
|
||||
eventually: eventually,
|
||||
onload: onload,
|
||||
IS_TEST_RUNNER: IS_TEST_RUNNER,
|
||||
HOST: '127.0.0.1',
|
||||
ORIGIN: 'http://127.0.0.1:9582',
|
||||
|
||||
@@ -591,6 +591,35 @@
|
||||
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';
|
||||
@@ -656,6 +685,20 @@
|
||||
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 = '';
|
||||
@@ -673,6 +716,20 @@
|
||||
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;
|
||||
@@ -798,3 +855,49 @@
|
||||
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>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
testing.expectEqual(window, e.currentTarget);
|
||||
}
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, called);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Verify: handler fires, "event" parameter is a proper Event, and handler is a function.
|
||||
let loadEvent = null;
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual("function", typeof document.body.onload);
|
||||
testing.expectTrue(loadEvent instanceof Event);
|
||||
testing.expectEqual("load", loadEvent.type);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Verify: handler fires exactly once, and body.onload reflects to window.onload.
|
||||
let called = 0;
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
// The attribute handler should have fired exactly once.
|
||||
testing.expectEqual(1, called);
|
||||
|
||||
|
||||
51
src/browser/tests/window/cross_origin.html
Normal file
51
src/browser/tests/window/cross_origin.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<iframe src=support/frame1.html></iframe>
|
||||
|
||||
<script id=post_message type=module>
|
||||
const state = await testing.async();
|
||||
|
||||
{
|
||||
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
|
||||
|
||||
{
|
||||
let iframe2 = document.createElement('iframe');
|
||||
iframe2.src = ALT_BASE + 'window/support/frame1.html';
|
||||
document.documentElement.appendChild(iframe2);
|
||||
}
|
||||
|
||||
{
|
||||
let iframe3 = document.createElement('iframe');
|
||||
iframe3.src = ALT_BASE + 'window/support/frame2.html';
|
||||
document.documentElement.appendChild(iframe3);
|
||||
}
|
||||
|
||||
let captures = [];
|
||||
window.addEventListener('message', (e) => {
|
||||
captures.push(e.data);
|
||||
if (captures.length == 3) {
|
||||
state.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
await state.done(() => {
|
||||
const expected_urls = [
|
||||
testing.BASE_URL + 'window/support/frame1.html',
|
||||
ALT_BASE + 'window/support/frame1.html',
|
||||
ALT_BASE + 'window/support/frame2.html',
|
||||
];
|
||||
|
||||
// No strong order guarantee for messaages, and we don't care about the order
|
||||
// so long as it's the correct data.
|
||||
testing.expectEqual(expected_urls.sort(), captures.map((c) => {return c.url}).sort());
|
||||
captures.forEach((c) => {
|
||||
if (c.url.includes(testing.BASE_URL)) {
|
||||
testing.expectEqual(false, c.document_is_undefined);
|
||||
} else {
|
||||
testing.expectEqual(true, c.document_is_undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
7
src/browser/tests/window/support/frame1.html
Normal file
7
src/browser/tests/window/support/frame1.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.parent.postMessage({
|
||||
url: location.toString(),
|
||||
document_is_undefined: window.parent.document === undefined,
|
||||
}, '*')
|
||||
</script>
|
||||
7
src/browser/tests/window/support/frame2.html
Normal file
7
src/browser/tests/window/support/frame2.html
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.top.postMessage({
|
||||
url: location.toString(),
|
||||
document_is_undefined: window.top.document === undefined,
|
||||
}, '*')
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
testing.expectEqual(true, timer1 != timer2);
|
||||
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
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.eventually(() => testing.expectEqual(5, wst2));
|
||||
testing.onload(() => testing.expectEqual(5, wst2));
|
||||
</script>
|
||||
|
||||
<script id=invalid-timer-clear>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
// noop
|
||||
window.removeEventListener('load', fn);
|
||||
testing.eventually(() => {
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(1, call1);
|
||||
testing.expectEqual(2, call2);
|
||||
});
|
||||
@@ -262,6 +262,31 @@
|
||||
}
|
||||
</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;
|
||||
@@ -285,6 +310,6 @@
|
||||
unhandledCalled += 1;
|
||||
});
|
||||
Promise.reject({x: 'Fail'});
|
||||
testing.eventually(() => testing.expectEqual(2, unhandledCalled));
|
||||
testing.onload(() => testing.expectEqual(2, unhandledCalled));
|
||||
}
|
||||
</script>
|
||||
|
||||
107
src/browser/webapi/CryptoKey.zig
Normal file
107
src/browser/webapi/CryptoKey.zig
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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();
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user