mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 07:33:16 +00:00
Compare commits
399 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ed45637a | ||
|
|
9068fe718e | ||
|
|
5369d25213 | ||
|
|
649d8d1024 | ||
|
|
15d60d845a | ||
|
|
c4b837b598 | ||
|
|
54391238c9 | ||
|
|
d33edc5697 | ||
|
|
16ca8d4b14 | ||
|
|
707ffb4893 | ||
|
|
4782b37216 | ||
|
|
ce197256dd | ||
|
|
e6d644998a | ||
|
|
67bd555e75 | ||
|
|
a10e533701 | ||
|
|
0065677273 | ||
|
|
226d9bfc6f | ||
|
|
2e65ae632e | ||
|
|
ea422075c7 | ||
|
|
1d54e6944b | ||
|
|
de32e5cf34 | ||
|
|
c8d8ca5e94 | ||
|
|
7f2139f612 | ||
|
|
da0828620f | ||
|
|
cdd33621e3 | ||
|
|
8001709506 | ||
|
|
a0ae6b4c92 | ||
|
|
fdf7f5267a | ||
|
|
88e0b39d6b | ||
|
|
f95396a487 | ||
|
|
d02d05b246 | ||
|
|
7b2d817d0e | ||
|
|
7e778a17d6 | ||
|
|
a0dd14aaad | ||
|
|
d447d1e3c7 | ||
|
|
8684d35394 | ||
|
|
e243f96988 | ||
|
|
7ea8f3f766 | ||
|
|
5e6082b5e9 | ||
|
|
1befd9a5e8 | ||
|
|
e103ce0f39 | ||
|
|
14fa2da2ad | ||
|
|
28cc60adb0 | ||
|
|
96d24b5dc6 | ||
|
|
c14a9ad986 | ||
|
|
679f2104f4 | ||
|
|
c6b0c75106 | ||
|
|
93485c1ef3 | ||
|
|
0324d5c232 | ||
|
|
0588cc374d | ||
|
|
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 | ||
|
|
cf46f0097a | ||
|
|
f1293b7346 | ||
|
|
d94fd2a43b | ||
|
|
8c5e737669 | ||
|
|
fb29a1c5bf | ||
|
|
94190f93af | ||
|
|
93e239f682 | ||
|
|
a4cb5031d1 | ||
|
|
a2e59af44c | ||
|
|
00c962bdd8 | ||
|
|
1fa87442b8 | ||
|
|
ac5400696a | ||
|
|
5062273b7a | ||
|
|
9c2393351d | ||
|
|
f0cfe3ffc8 | ||
|
|
f70865e174 | ||
|
|
615fcffb99 | ||
|
|
13b746f9e4 | ||
|
|
e90fce4c55 | ||
|
|
59175437b5 | ||
|
|
e950384b9b | ||
|
|
78440350dc | ||
|
|
f435297949 | ||
|
|
54d1563cf3 | ||
|
|
38e9f86088 | ||
|
|
d9c5f56500 | ||
|
|
6c5733bba3 | ||
|
|
b8f1622b52 | ||
|
|
f36499b806 | ||
|
|
fa1dd5237d | ||
|
|
2b9d5fd4d9 | ||
|
|
2dbd32d120 | ||
|
|
1695ea81d2 | ||
|
|
b7bf86fd85 | ||
|
|
94d8f90a96 | ||
|
|
964fa0a8aa | ||
|
|
db01158d2d | ||
|
|
e997f8317e | ||
|
|
b9bef22bbf | ||
|
|
b2a996e5c7 | ||
|
|
a88c21cdb5 | ||
|
|
e2be8525c4 | ||
|
|
c15afa23ca | ||
|
|
7a7c4b9f49 | ||
|
|
f594b033bf | ||
|
|
10e379e4fb | ||
|
|
c1bb27c450 | ||
|
|
dda5e2c542 | ||
|
|
edd0c5c83f | ||
|
|
c6861829c3 | ||
|
|
e14c8b3025 | ||
|
|
5bc00c595c | ||
|
|
db5fb40de0 | ||
|
|
4e6a357e6e | ||
|
|
6cf515151d | ||
|
|
bf6e4cf3a6 | ||
|
|
60936baa96 | ||
|
|
c29f72a7e8 | ||
|
|
d4427e4370 | ||
|
|
b85ec04175 | ||
|
|
da05ba0eb7 | ||
|
|
414a68abeb | ||
|
|
52455b732b | ||
|
|
ba71268eb3 | ||
|
|
694aac5ce8 | ||
|
|
cbab0b712a | ||
|
|
1aee3db521 | ||
|
|
e29778d72b | ||
|
|
f634c9843d | ||
|
|
e1e45d1c5d | ||
|
|
09327c3897 | ||
|
|
ff288c8aa2 | ||
|
|
e1b14a6833 | ||
|
|
015edc3848 | ||
|
|
bd2406f803 | ||
|
|
3c29e7dbd4 | ||
|
|
586413357e | ||
|
|
9a055a61a6 | ||
|
|
5fb561dc9c | ||
|
|
b14ae02548 | ||
|
|
51fb08e6aa | ||
|
|
a6d699ad5d | ||
|
|
8372b45cc5 | ||
|
|
1739ae6b9a | ||
|
|
ba62150f7a | ||
|
|
8143a61955 | ||
|
|
e16c479781 | ||
|
|
c0c4e26d63 | ||
|
|
b252aa71d0 | ||
|
|
9ef8d9c189 | ||
|
|
9f27416603 | ||
|
|
0729f4a03a | ||
|
|
21f7b95db9 | ||
|
|
4125a5aa1e | ||
|
|
6d0dc6cb1e | ||
|
|
0675c23217 | ||
|
|
d0e6a1f5bb | ||
|
|
91afe08235 | ||
|
|
041d9d41fb | ||
|
|
7009fb5899 | ||
|
|
d2003c7c9a | ||
|
|
ce002b999c | ||
|
|
5b1056862a | ||
|
|
cc4ac99b4a | ||
|
|
46df341506 | ||
|
|
b698e2d078 | ||
|
|
5cc5e513dd | ||
|
|
e048b0372f | ||
|
|
d7aaa1c870 | ||
|
|
463aac9b59 | ||
|
|
d9cdd78138 | ||
|
|
44a83c0e1c | ||
|
|
96f24a2662 | ||
|
|
5d2801c652 | ||
|
|
deb08b7880 | ||
|
|
96e5054ffc | ||
|
|
c9753a690d | ||
|
|
27aaf46630 | ||
|
|
84190e1e06 | ||
|
|
b0b1f755ea | ||
|
|
fcf1d30c77 | ||
|
|
3c532e5aef | ||
|
|
3efcb2705d | ||
|
|
c25f389e91 | ||
|
|
533f4075a3 | ||
|
|
f508d37426 | ||
|
|
548c6eeb7a | ||
|
|
c8265f4807 | ||
|
|
a74e46debf | ||
|
|
1ceaabe69f | ||
|
|
91a2441ed8 | ||
|
|
2ecbc833a9 | ||
|
|
dac456d98c | ||
|
|
422320d9ac | ||
|
|
18b635936c | ||
|
|
7b2895ef08 | ||
|
|
b09e9f7398 | ||
|
|
ac651328c3 | ||
|
|
0380df1cb4 | ||
|
|
21421d5b53 | ||
|
|
80c309aa69 | ||
|
|
f5bc7310b1 | ||
|
|
21e9967a8a | ||
|
|
32f450f803 | ||
|
|
1972142703 | ||
|
|
b10d866e4b | ||
|
|
b373fb4a42 | ||
|
|
43a70272c5 | ||
|
|
ddd34dc57b | ||
|
|
265c5aba2e | ||
|
|
21fc6d1cf6 | ||
|
|
1a7fe6129c | ||
|
|
37462a16c5 | ||
|
|
323ec0046c | ||
|
|
dc7c6984fb | ||
|
|
92f7248a16 | ||
|
|
1ec3e156fb | ||
|
|
1121bed49b | ||
|
|
0eb43fb530 | ||
|
|
1f50dc38c3 | ||
|
|
a9d044ec10 | ||
|
|
1bdf464ef2 | ||
|
|
a70da0d176 | ||
|
|
8c52b8357c | ||
|
|
0243c6b450 | ||
|
|
f7071447cb | ||
|
|
c038bfafa1 | ||
|
|
4d60f56e66 | ||
|
|
56d3cf51e8 | ||
|
|
3013e3a9e6 | ||
|
|
fe9b2e672b | ||
|
|
3e9fa4ca47 | ||
|
|
a2e66f85a1 | ||
|
|
a9b9cf14c3 | ||
|
|
d4b941cf30 | ||
|
|
4b6bf29b83 | ||
|
|
a8b147dfc0 | ||
|
|
65627c1296 | ||
|
|
3dcdaa0a9b | ||
|
|
5bc00045c7 | ||
|
|
93ea95af24 | ||
|
|
f754773bf6 | ||
|
|
f0c9c262ca | ||
|
|
42bb2f3c58 | ||
|
|
3fde349b9f | ||
|
|
55a9976d46 | ||
|
|
66a86541d1 | ||
|
|
bc19079dad | ||
|
|
351e44343d | ||
|
|
e362a9cbc3 | ||
|
|
e2563e57f2 | ||
|
|
df5e978247 | ||
|
|
68337a6989 | ||
|
|
bf6dbedbe4 | ||
|
|
a204f40968 | ||
|
|
1352839472 | ||
|
|
099550dddc | ||
|
|
8b310ce993 | ||
|
|
f37862a25d | ||
|
|
84d76cf90d | ||
|
|
e12f28fb70 | ||
|
|
dfe04960c0 | ||
|
|
de2b1cc6fe | ||
|
|
2aef4ab677 | ||
|
|
798f68d0ce | ||
|
|
e0343a3f6d | ||
|
|
d918ec694b | ||
|
|
b2b609a309 | ||
|
|
48dd80867b | ||
|
|
f58f6e8d65 | ||
|
|
ee034943b6 |
4
.github/actions/install/action.yml
vendored
4
.github/actions/install/action.yml
vendored
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.3.3'
|
||||
default: 'v0.3.7'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
@@ -46,7 +46,7 @@ runs:
|
||||
|
||||
- name: Cache v8
|
||||
id: cache-v8
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
cache-name: cache-v8
|
||||
with:
|
||||
|
||||
14
.github/workflows/e2e-integration-test.yml
vendored
14
.github/workflows/e2e-integration-test.yml
vendored
@@ -20,19 +20,17 @@ jobs:
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- 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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
@@ -47,7 +45,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -55,7 +53,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -63,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`
|
||||
|
||||
94
.github/workflows/e2e-test.yml
vendored
94
.github/workflows/e2e-test.yml
vendored
@@ -9,15 +9,13 @@ env:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
paths:
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/zig-js-runtime"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
pull_request:
|
||||
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
@@ -29,12 +27,10 @@ on:
|
||||
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
- "build.zig.zon"
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -52,16 +48,14 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- 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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
@@ -76,7 +70,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -84,7 +78,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -104,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`
|
||||
|
||||
@@ -126,7 +120,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -134,7 +128,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -145,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`
|
||||
@@ -161,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`
|
||||
@@ -182,39 +176,44 @@ jobs:
|
||||
name: wba-test
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
# force a wakup of the auth server before requesting it w/ the test itself
|
||||
- run: curl https://${{ vars.WBA_DOMAIN }}
|
||||
|
||||
- name: run wba test
|
||||
shell: bash
|
||||
run: |
|
||||
node webbotauth/validator.js &
|
||||
VALIDATOR_PID=$!
|
||||
sleep 2
|
||||
sleep 5
|
||||
|
||||
./lightpanda fetch http://127.0.0.1:8989/ \
|
||||
--web_bot_auth_key_file private_key.pem \
|
||||
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
|
||||
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
|
||||
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
|
||||
|
||||
./lightpanda fetch --dump http://127.0.0.1:8989/ \
|
||||
--web-bot-auth-key-file /proc/self/fd/3 \
|
||||
--web-bot-auth-keyid ${{ vars.WBA_KEY_ID }} \
|
||||
--web-bot-auth-domain ${{ vars.WBA_DOMAIN }}
|
||||
|
||||
wait $VALIDATOR_PID
|
||||
exec 3>&-
|
||||
|
||||
cdp-and-hyperfine-bench:
|
||||
name: cdp-and-hyperfine-bench
|
||||
@@ -224,7 +223,6 @@ jobs:
|
||||
MAX_VmHWM: 28000 # 28MB (KB)
|
||||
MAX_CG_PEAK: 8000 # 8MB (KB)
|
||||
MAX_AVG_DURATION: 17
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
# How to give cgroups access to the user actions-runner on the host:
|
||||
# $ sudo apt install cgroup-tools
|
||||
@@ -239,7 +237,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -247,7 +245,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -333,7 +331,7 @@ jobs:
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bench-results
|
||||
path: |
|
||||
@@ -356,12 +354,12 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bench-results
|
||||
|
||||
@@ -379,7 +377,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ env:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
||||
|
||||
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
||||
VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion={0}', github.ref_name) || '-Dversion=nightly' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -33,8 +35,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -72,11 +72,9 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
@@ -87,7 +85,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -116,11 +114,9 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
@@ -131,7 +127,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -158,11 +154,9 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
@@ -173,7 +167,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
42
.github/workflows/wpt.yml
vendored
42
.github/workflows/wpt.yml
vendored
@@ -10,7 +10,7 @@ env:
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 2 * * *"
|
||||
- cron: "21 2 * * *"
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -19,23 +19,31 @@ jobs:
|
||||
wpt-build-release:
|
||||
name: zig build release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
ARCH: aarch64
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
os: ${{env.OS}}
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build release
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
@@ -45,7 +53,7 @@ jobs:
|
||||
wpt-build-runner:
|
||||
name: build wpt runner
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
@@ -59,7 +67,7 @@ jobs:
|
||||
CGO_ENABLED=0 go build
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wptrunner
|
||||
path: |
|
||||
@@ -73,8 +81,8 @@ jobs:
|
||||
- wpt-build-runner
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-bench-hetzner
|
||||
timeout-minutes: 180
|
||||
runs-on: lpd-wpt-aws
|
||||
timeout-minutes: 600
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -91,14 +99,14 @@ jobs:
|
||||
run: ./wpt manifest
|
||||
|
||||
- name: download lightpanda release
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: download wptrunner
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wptrunner
|
||||
|
||||
@@ -107,8 +115,8 @@ jobs:
|
||||
- name: run test with json output
|
||||
run: |
|
||||
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
||||
sleep 10s
|
||||
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
|
||||
sleep 20s
|
||||
./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
|
||||
kill `cat WPT.pid`
|
||||
|
||||
- name: write commit
|
||||
@@ -116,7 +124,7 @@ jobs:
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wpt-results
|
||||
path: |
|
||||
@@ -139,7 +147,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wpt-results
|
||||
|
||||
|
||||
60
.github/workflows/zig-fmt.yml
vendored
60
.github/workflows/zig-fmt.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: zig-fmt
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
||||
# Since we skip the job when the PR is in draft state, we want to force CI
|
||||
# running when the PR is marked ready_for_review w/o other change.
|
||||
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
zig-fmt:
|
||||
name: zig fmt
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Zig version used from the `minimum_zig_version` field in build.zig.zon
|
||||
- uses: mlugg/setup-zig@v2
|
||||
|
||||
- name: Run zig fmt
|
||||
id: fmt
|
||||
run: |
|
||||
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -s zig-fmt.err ]; then
|
||||
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [ -s zig-fmt.err2 ]; then
|
||||
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Fail the job
|
||||
if: steps.fmt.outputs.zig_fmt_errs != ''
|
||||
run: exit 1
|
||||
98
.github/workflows/zig-test.yml
vendored
98
.github/workflows/zig-test.yml
vendored
@@ -5,19 +5,18 @@ env:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
paths:
|
||||
- "build.zig"
|
||||
- "src/**"
|
||||
- "vendor/zig-js-runtime"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
pull_request:
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
pull_request:
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
||||
# Since we skip the job when the PR is in draft state, we want to force CI
|
||||
@@ -27,28 +26,63 @@ on:
|
||||
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
- "build.zig.zon"
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
zig-test-debug:
|
||||
name: zig test using v8 in debug mode
|
||||
timeout-minutes: 15
|
||||
zig-fmt:
|
||||
name: zig fmt
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Zig version used from the `minimum_zig_version` field in build.zig.zon
|
||||
- uses: mlugg/setup-zig@v2
|
||||
|
||||
- name: Run zig fmt
|
||||
id: fmt
|
||||
run: |
|
||||
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -s zig-fmt.err ]; then
|
||||
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [ -s zig-fmt.err2 ]; then
|
||||
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Fail the job
|
||||
if: steps.fmt.outputs.zig_fmt_errs != ''
|
||||
run: exit 1
|
||||
|
||||
zig-test-debug:
|
||||
name: zig test using v8 in debug mode
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
@@ -57,21 +91,18 @@ jobs:
|
||||
- name: zig build test
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
|
||||
|
||||
zig-test:
|
||||
zig-test-release:
|
||||
name: zig test
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
@@ -83,7 +114,7 @@ jobs:
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bench-results
|
||||
path: |
|
||||
@@ -93,23 +124,22 @@ jobs:
|
||||
|
||||
bench-fmt:
|
||||
name: perf-fmt
|
||||
needs: zig-test
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: zig-test-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: bench-results
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM debian:stable-slim
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.0.365.4
|
||||
ARG ZIG_V8=v0.3.3
|
||||
ARG ZIG_V8=v0.3.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/
|
||||
```
|
||||
|
||||
12
Makefile
12
Makefile
@@ -47,7 +47,7 @@ help:
|
||||
|
||||
# $(ZIG) commands
|
||||
# ------------
|
||||
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
|
||||
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
|
||||
|
||||
## Build v8 snapshot
|
||||
build-v8-snapshot:
|
||||
@@ -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
|
||||
@@ -77,11 +77,6 @@ run-debug: build-dev
|
||||
@printf "\033[36mRunning...\033[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
|
||||
|
||||
## Run a JS shell in debug mode
|
||||
shell:
|
||||
@printf "\033[36mBuilding shell...\033[0m\n"
|
||||
@$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
|
||||
## Test - `grep` is used to filter out the huge compile command on build
|
||||
ifeq ($(OS), macos)
|
||||
test:
|
||||
@@ -106,4 +101,3 @@ install: build
|
||||
|
||||
data:
|
||||
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -1,18 +1,32 @@
|
||||
<p align="center">
|
||||
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Lightpanda Browser</h1>
|
||||
<p align="center">
|
||||
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
|
||||
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p>
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
|
||||
[](https://twitter.com/lightpanda_io)
|
||||
[](https://github.com/lightpanda-io/browser)
|
||||
[](https://discord.gg/K63XeymfB5)
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
[<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-v2.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
</div>
|
||||
|
||||
_chromedp requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
|
||||
See [benchmark details](https://github.com/lightpanda-io/demo/blob/main/BENCHMARKS.md#crawler-benchmark)._
|
||||
|
||||
Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
@@ -26,16 +40,6 @@ Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
- Exceptionally fast execution (11x faster than Chrome)
|
||||
- Instant startup
|
||||
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
 
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.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)._
|
||||
|
||||
[^1]: **Playwright support disclaimer:**
|
||||
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
|
||||
|
||||
@@ -78,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]
|
||||
@@ -113,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]
|
||||
@@ -166,6 +170,7 @@ You may still encounter errors or crashes. Please open an issue with specifics i
|
||||
|
||||
Here are the key features we have implemented:
|
||||
|
||||
- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015)
|
||||
- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
|
||||
- [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
|
||||
- [x] DOM tree
|
||||
@@ -182,12 +187,10 @@ 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.
|
||||
|
||||
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
|
||||
|
||||
## Build from sources
|
||||
|
||||
### Prerequisites
|
||||
@@ -196,10 +199,10 @@ Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
|
||||
install it with the right version in order to build the project.
|
||||
|
||||
Lightpanda also depends on
|
||||
[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
|
||||
[v8](https://chromium.googlesource.com/v8/v8.git),
|
||||
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
|
||||
|
||||
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
|
||||
To be able to build the v8 engine, you have to install some libs:
|
||||
|
||||
For **Debian/Ubuntu based Linux**:
|
||||
|
||||
@@ -315,7 +318,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:
|
||||
|
||||
103
build.zig
103
build.zig
@@ -17,22 +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 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, "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;
|
||||
@@ -94,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);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -699,27 +719,56 @@ fn buildCurl(
|
||||
return lib;
|
||||
}
|
||||
|
||||
const Manifest = struct {
|
||||
version: []const u8,
|
||||
minimum_zig_version: []const u8,
|
||||
/// Resolves the semantic version of the build.
|
||||
///
|
||||
/// The base version is read from `build.zig.zon`. This can be overridden
|
||||
/// using the `-Dversion` command-line flag:
|
||||
/// - If the flag contains a full semantic version (e.g., `1.2.3`), it replaces
|
||||
/// the base version entirely.
|
||||
/// - If the flag contains a simple string (e.g., `nightly`), it replaces only
|
||||
/// the pre-release tag of the base version (e.g., `1.0.0-dev` -> `1.0.0-nightly`).
|
||||
///
|
||||
/// For versions that have a pre-release tag and no explicit build metadata,
|
||||
/// this function automatically enriches the version with the git commit count
|
||||
/// and short hash (e.g., `1.0.0-dev.5243+dbe45229`).
|
||||
fn resolveVersion(b: *std.Build) std.SemanticVersion {
|
||||
const opt_version = b.option([]const u8, "version", "Override the version of this build");
|
||||
|
||||
fn init(b: *std.Build) Manifest {
|
||||
const input = @embedFile("build.zig.zon");
|
||||
const version = if (opt_version) |v|
|
||||
std.SemanticVersion.parse(v) catch blk: {
|
||||
var fallback = lightpanda_version;
|
||||
fallback.pre = v;
|
||||
break :blk fallback;
|
||||
}
|
||||
else
|
||||
lightpanda_version;
|
||||
|
||||
var diagnostics: std.zon.parse.Diagnostics = .{};
|
||||
defer diagnostics.deinit(b.allocator);
|
||||
// Only enrich versions that have a pre-release field and no explicit build metadata.
|
||||
if (version.pre == null or version.build != null) return version;
|
||||
|
||||
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);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
// For dev/nightly versions, calculate the commit count and hash
|
||||
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return 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 version;
|
||||
const commit_count = std.mem.trim(u8, git_count_raw, " \n\r");
|
||||
|
||||
return .{
|
||||
.major = version.major,
|
||||
.minor = version.minor,
|
||||
.patch = version.patch,
|
||||
.pre = b.fmt("{s}.{s}", .{ version.pre.?, commit_count }),
|
||||
.build = commit_hash,
|
||||
};
|
||||
}
|
||||
|
||||
/// Helper function to run git commands and return stdout
|
||||
fn runGit(b: *std.Build, args: []const []const u8) ![]const u8 {
|
||||
var code: u8 = undefined;
|
||||
const dir = b.pathFromRoot(".");
|
||||
var command: std.ArrayList([]const u8) = .empty;
|
||||
defer command.deinit(b.allocator);
|
||||
try command.appendSlice(b.allocator, &.{ "git", "-C", dir });
|
||||
try command.appendSlice(b.allocator, args);
|
||||
return b.runAllowFail(command.items, &code, .Ignore);
|
||||
}
|
||||
|
||||
@@ -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.3.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
|
||||
},
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||
app.app_dir_path = getAndMakeAppDir(allocator);
|
||||
|
||||
app.telemetry = try Telemetry.init(app, config.mode);
|
||||
errdefer app.telemetry.deinit();
|
||||
errdefer app.telemetry.deinit(allocator);
|
||||
|
||||
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
|
||||
errdefer app.arena_pool.deinit();
|
||||
@@ -85,7 +85,7 @@ pub fn deinit(self: *App) void {
|
||||
allocator.free(app_dir_path);
|
||||
self.app_dir_path = null;
|
||||
}
|
||||
self.telemetry.deinit();
|
||||
self.telemetry.deinit(allocator);
|
||||
self.network.deinit();
|
||||
self.snapshot.deinit();
|
||||
self.platform.deinit();
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const log = @import("log.zig");
|
||||
|
||||
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 +34,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 +53,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) {
|
||||
log.err(.bug, "ArenaPool leak", .{ .name = kv.key_ptr.*, .count = kv.value_ptr.* });
|
||||
has_leaks = true;
|
||||
}
|
||||
}
|
||||
if (has_leaks) {
|
||||
@panic("ArenaPool: leaked arenas detected");
|
||||
}
|
||||
self._leak_track.deinit(self.allocator);
|
||||
}
|
||||
|
||||
var entry = self.free_list;
|
||||
while (entry) |e| {
|
||||
entry = e.next;
|
||||
@@ -54,13 +81,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 +103,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 +126,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) {
|
||||
log.err(.bug, "ArenaPool double-free", .{ .name = entry.debug });
|
||||
@panic("ArenaPool: double-free detected");
|
||||
}
|
||||
} else {
|
||||
log.err(.bug, "ArenaPool release unknown", .{ .name = entry.debug });
|
||||
@panic("ArenaPool: release of untracked arena");
|
||||
}
|
||||
}
|
||||
|
||||
const free_list_len = self.free_list_len;
|
||||
if (free_list_len == self.free_list_max) {
|
||||
arena.deinit();
|
||||
@@ -100,13 +156,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 +179,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 +197,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 +222,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 +237,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 +261,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);
|
||||
|
||||
223
src/Config.zig
223
src/Config.zig
@@ -163,6 +163,20 @@ pub fn cdpTimeout(self: *const Config) usize {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn port(self: *const Config) u16 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.port,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn advertiseHost(self: *const Config) []const u8 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.advertise_host orelse opts.host,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
|
||||
@@ -199,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,
|
||||
@@ -217,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,
|
||||
@@ -224,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 {
|
||||
@@ -293,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
|
||||
;
|
||||
|
||||
@@ -376,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 ++
|
||||
\\
|
||||
@@ -400,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.
|
||||
\\
|
||||
@@ -485,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;
|
||||
}
|
||||
|
||||
@@ -541,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" });
|
||||
@@ -554,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;
|
||||
@@ -619,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| {
|
||||
@@ -639,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;
|
||||
};
|
||||
|
||||
@@ -675,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;
|
||||
@@ -709,6 +780,8 @@ fn parseFetchArgs(
|
||||
.common = common,
|
||||
.with_base = with_base,
|
||||
.with_frames = with_frames,
|
||||
.wait_ms = wait_ms,
|
||||
.wait_until = wait_until,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -718,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;
|
||||
};
|
||||
|
||||
@@ -821,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;
|
||||
@@ -857,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;
|
||||
});
|
||||
}
|
||||
@@ -865,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;
|
||||
}
|
||||
}
|
||||
@@ -880,27 +953,27 @@ 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);
|
||||
|
||||
@@ -36,7 +36,9 @@ dom_node: *Node,
|
||||
registry: *CDPNode.Registry,
|
||||
page: *Page,
|
||||
arena: std.mem.Allocator,
|
||||
prune: bool = false,
|
||||
prune: bool = true,
|
||||
interactive_only: bool = false,
|
||||
max_depth: u32 = std.math.maxInt(u32) - 1,
|
||||
|
||||
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
|
||||
var visitor = JsonVisitor{ .jw = jw, .tree = self };
|
||||
@@ -45,7 +47,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) 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;
|
||||
};
|
||||
@@ -58,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) 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;
|
||||
};
|
||||
@@ -71,7 +89,7 @@ const OptionData = struct {
|
||||
};
|
||||
|
||||
const NodeData = struct {
|
||||
id: u32,
|
||||
id: CDPNode.Id,
|
||||
axn: AXNode,
|
||||
role: []const u8,
|
||||
name: ?[]const u8,
|
||||
@@ -82,7 +100,24 @@ const NodeData = struct {
|
||||
node_name: []const u8,
|
||||
};
|
||||
|
||||
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap) !void {
|
||||
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
|
||||
if (node.is(Element)) |el| {
|
||||
const tag = el.getTag();
|
||||
@@ -92,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;
|
||||
}
|
||||
|
||||
@@ -133,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;
|
||||
}
|
||||
}
|
||||
@@ -141,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);
|
||||
|
||||
@@ -161,20 +196,24 @@ 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.prune) {
|
||||
if (self.interactive_only) {
|
||||
var keep = false;
|
||||
if (interactive.isInteractiveRole(role)) {
|
||||
keep = true;
|
||||
} else if (interactive.isContentRole(role)) {
|
||||
if (name != null and name.?.len > 0) {
|
||||
keep = true;
|
||||
}
|
||||
} else if (std.mem.eql(u8, role, "RootWebArea")) {
|
||||
keep = true;
|
||||
} else if (is_interactive) {
|
||||
keep = true;
|
||||
}
|
||||
if (!keep) {
|
||||
should_visit = false;
|
||||
}
|
||||
} else if (self.prune) {
|
||||
if (structural and !is_interactive and !has_explicit_label) {
|
||||
should_visit = false;
|
||||
}
|
||||
@@ -188,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
|
||||
@@ -213,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);
|
||||
try self.walk(ctx, child, name, visitor, gop.value_ptr.*, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,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| {
|
||||
@@ -389,36 +440,45 @@ const TextVisitor = struct {
|
||||
depth: usize,
|
||||
|
||||
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
|
||||
// Format: " [12] link: Hacker News (value)"
|
||||
for (0..(self.depth * 2)) |_| {
|
||||
for (0..self.depth) |_| {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
try self.writer.print("[{d}] {s}: ", .{ data.id, data.role });
|
||||
|
||||
var name_to_print: ?[]const u8 = null;
|
||||
if (data.name) |n| {
|
||||
if (n.len > 0) {
|
||||
try self.writer.writeAll(n);
|
||||
name_to_print = n;
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
|
||||
if (trimmed.len > 0) {
|
||||
try self.writer.writeAll(trimmed);
|
||||
name_to_print = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
|
||||
|
||||
try self.writer.print("{d}", .{data.id});
|
||||
if (!is_text_only) {
|
||||
try self.writer.print(" {s}", .{data.role});
|
||||
}
|
||||
if (name_to_print) |n| {
|
||||
try self.writer.print(" '{s}'", .{n});
|
||||
}
|
||||
|
||||
if (data.value) |v| {
|
||||
if (v.len > 0) {
|
||||
try self.writer.print(" (value: {s})", .{v});
|
||||
try self.writer.print(" value='{s}'", .{v});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.options) |options| {
|
||||
try self.writer.writeAll(" options: [");
|
||||
try self.writer.writeAll(" options=[");
|
||||
for (options, 0..) |opt, i| {
|
||||
if (i > 0) try self.writer.writeAll(", ");
|
||||
if (i > 0) try self.writer.writeAll(",");
|
||||
try self.writer.print("'{s}'", .{opt.value});
|
||||
if (opt.selected) {
|
||||
try self.writer.writeAll(" (selected)");
|
||||
try self.writer.writeAll("*");
|
||||
}
|
||||
}
|
||||
try self.writer.writeAll("]\n");
|
||||
@@ -448,3 +508,56 @@ const TextVisitor = struct {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "SemanticTree backendDOMNodeId" {
|
||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var page = try testing.pageTest("cdp/registry1.html");
|
||||
defer testing.reset();
|
||||
defer page._session.removePage();
|
||||
|
||||
const st: Self = .{
|
||||
.dom_node = page.window._document.asNode(),
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.arena = testing.arena_allocator,
|
||||
.prune = false,
|
||||
.interactive_only = false,
|
||||
.max_depth = std.math.maxInt(u32) - 1,
|
||||
};
|
||||
|
||||
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
||||
defer testing.allocator.free(json_str);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
|
||||
}
|
||||
|
||||
test "SemanticTree max_depth" {
|
||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var page = try testing.pageTest("cdp/registry1.html");
|
||||
defer testing.reset();
|
||||
defer page._session.removePage();
|
||||
|
||||
const st: Self = .{
|
||||
.dom_node = page.window._document.asNode(),
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.arena = testing.arena_allocator,
|
||||
.prune = false,
|
||||
.interactive_only = false,
|
||||
.max_depth = 1,
|
||||
};
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
try st.textStringify(&aw.writer);
|
||||
const text_str = aw.written();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
||||
}
|
||||
|
||||
109
src/Server.zig
109
src/Server.zig
@@ -22,12 +22,11 @@ const net = std.net;
|
||||
const posix = std.posix;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
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 +44,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);
|
||||
@@ -64,17 +63,17 @@ pub fn init(app: *App, address: net.Address) !*Server {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server) void {
|
||||
// Stop all active clients
|
||||
{
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
pub fn shutdown(self: *Server) void {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
|
||||
for (self.clients.items) |client| {
|
||||
client.stop();
|
||||
}
|
||||
for (self.clients.items) |client| {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server) void {
|
||||
self.shutdown();
|
||||
self.joinThreads();
|
||||
self.clients.deinit(self.allocator);
|
||||
self.clients_pool.deinit();
|
||||
@@ -212,7 +211,7 @@ pub const Client = struct {
|
||||
http: *HttpClient,
|
||||
ws: Net.WsConnection,
|
||||
|
||||
fn init(
|
||||
pub fn init(
|
||||
socket: posix.socket_t,
|
||||
allocator: Allocator,
|
||||
app: *App,
|
||||
@@ -242,12 +241,15 @@ pub const Client = struct {
|
||||
fn stop(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.http => {},
|
||||
.cdp => |*cdp| cdp.browser.env.terminate(),
|
||||
.cdp => |*cdp| {
|
||||
cdp.browser.env.terminate();
|
||||
self.ws.sendClose();
|
||||
},
|
||||
}
|
||||
self.ws.shutdown();
|
||||
}
|
||||
|
||||
fn deinit(self: *Client) void {
|
||||
pub fn deinit(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.cdp => |*cdp| cdp.deinit(),
|
||||
.http => {},
|
||||
@@ -295,19 +297,12 @@ pub const Client = struct {
|
||||
}
|
||||
|
||||
var cdp = &self.mode.cdp;
|
||||
var last_message = timestamp(.monotonic);
|
||||
var last_message = milliTimestamp(.monotonic);
|
||||
var ms_remaining = self.ws.timeout_ms;
|
||||
|
||||
while (true) {
|
||||
switch (cdp.pageWait(ms_remaining)) {
|
||||
.cdp_socket => {
|
||||
if (self.readSocket() == false) {
|
||||
return;
|
||||
}
|
||||
last_message = timestamp(.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;
|
||||
@@ -319,16 +314,30 @@ pub const Client = struct {
|
||||
if (self.readSocket() == false) {
|
||||
return;
|
||||
}
|
||||
last_message = timestamp(.monotonic);
|
||||
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 elapsed = timestamp(.monotonic) - last_message;
|
||||
if (elapsed > ms_remaining) {
|
||||
const now = milliTimestamp(.monotonic);
|
||||
const elapsed = now - last_message;
|
||||
if (elapsed >= ms_remaining) {
|
||||
log.info(.app, "CDP timeout", .{});
|
||||
return;
|
||||
}
|
||||
ms_remaining -= @intCast(elapsed);
|
||||
last_message = now;
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -451,7 +460,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 {
|
||||
@@ -479,11 +488,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
|
||||
@@ -497,22 +512,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" {
|
||||
@@ -520,7 +535,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);
|
||||
@@ -589,7 +604,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);
|
||||
@@ -717,7 +732,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);
|
||||
@@ -729,7 +744,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
|
||||
@@ -737,7 +752,7 @@ test "server: get /json/version" {
|
||||
defer c.deinit();
|
||||
|
||||
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
|
||||
try testing.expectEqualStrings(expected_response, res1);
|
||||
try testing.expectEqual(expected_response, res1);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -746,7 +761,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +779,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 {
|
||||
@@ -908,7 +923,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);
|
||||
|
||||
@@ -19,17 +19,13 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Session = @import("Session.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
|
||||
@@ -91,25 +87,32 @@ pub fn runMicrotasks(self: *Browser) void {
|
||||
self.env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn runMacrotasks(self: *Browser) !?u64 {
|
||||
pub fn runMacrotasks(self: *Browser) !void {
|
||||
const env = &self.env;
|
||||
|
||||
const time_to_next = try self.env.runMacrotasks();
|
||||
try self.env.runMacrotasks();
|
||||
env.pumpMessageLoop();
|
||||
|
||||
// either of the above could have queued more microtasks
|
||||
env.runMicrotasks();
|
||||
|
||||
return time_to_next;
|
||||
}
|
||||
|
||||
pub fn hasBackgroundTasks(self: *Browser) bool {
|
||||
return self.env.hasBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn waitForBackgroundTasks(self: *Browser) void {
|
||||
self.env.waitForBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn msToNextMacrotask(self: *Browser) ?u64 {
|
||||
return self.env.msToNextMacrotask();
|
||||
}
|
||||
|
||||
pub fn msTo(self: *Browser) bool {
|
||||
return self.env.hasBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn runIdleTasks(self: *const Browser) void {
|
||||
self.env.runIdleTasks();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,12 @@ const DispatchDirectOptions = struct {
|
||||
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
|
||||
const page = self.page;
|
||||
|
||||
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||
const window = page.window;
|
||||
const prev_event = window._current_event;
|
||||
window._current_event = event;
|
||||
defer window._current_event = prev_event;
|
||||
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, page._session);
|
||||
|
||||
@@ -398,6 +404,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
}
|
||||
|
||||
const page = self.page;
|
||||
|
||||
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||
const window = page.window;
|
||||
const prev_event = window._current_event;
|
||||
window._current_event = event;
|
||||
defer window._current_event = prev_event;
|
||||
|
||||
var was_handled = false;
|
||||
|
||||
// Create a single scope for all event handlers in this dispatch.
|
||||
@@ -412,7 +425,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
const activation_state = ActivationState.create(event, target, page);
|
||||
const activation_state = try ActivationState.create(event, target, page);
|
||||
|
||||
// Defer runs even on early return - ensures event phase is reset
|
||||
// and default actions execute (unless prevented)
|
||||
@@ -807,7 +820,7 @@ const ActivationState = struct {
|
||||
|
||||
const Input = Element.Html.Input;
|
||||
|
||||
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
|
||||
fn create(event: *const Event, target: *Node, page: *Page) !?ActivationState {
|
||||
if (event._type_string.eql(comptime .wrap("click")) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ params: []const u8 = "",
|
||||
// We keep 41 for null-termination since HTML parser expects in this format.
|
||||
charset: [41]u8 = default_charset,
|
||||
charset_len: usize = default_charset_len,
|
||||
is_default_charset: bool = true,
|
||||
|
||||
/// String "UTF-8" continued by null characters.
|
||||
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
|
||||
@@ -130,6 +131,7 @@ pub fn parse(input: []u8) !Mime {
|
||||
|
||||
var charset: [41]u8 = default_charset;
|
||||
var charset_len: usize = default_charset_len;
|
||||
var has_explicit_charset = false;
|
||||
|
||||
var it = std.mem.splitScalar(u8, params, ';');
|
||||
while (it.next()) |attr| {
|
||||
@@ -156,6 +158,7 @@ pub fn parse(input: []u8) !Mime {
|
||||
// Null-terminate right after attribute value.
|
||||
charset[attribute_value.len] = 0;
|
||||
charset_len = attribute_value.len;
|
||||
has_explicit_charset = true;
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -165,9 +168,137 @@ pub fn parse(input: []u8) !Mime {
|
||||
.charset = charset,
|
||||
.charset_len = charset_len,
|
||||
.content_type = content_type,
|
||||
.is_default_charset = !has_explicit_charset,
|
||||
};
|
||||
}
|
||||
|
||||
/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
|
||||
/// Looks for `<meta charset="X">` and `<meta http-equiv="Content-Type" content="...;charset=X">`.
|
||||
/// Returns the charset value or null if none found.
|
||||
/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations
|
||||
pub fn prescanCharset(html: []const u8) ?[]const u8 {
|
||||
const limit = @min(html.len, 1024);
|
||||
const data = html[0..limit];
|
||||
|
||||
// Scan for <meta tags
|
||||
var pos: usize = 0;
|
||||
while (pos < data.len) {
|
||||
// Find next '<'
|
||||
pos = std.mem.indexOfScalarPos(u8, data, pos, '<') orelse return null;
|
||||
pos += 1;
|
||||
if (pos >= data.len) return null;
|
||||
|
||||
// Check for "meta" (case-insensitive)
|
||||
if (pos + 4 >= data.len) return null;
|
||||
var tag_buf: [4]u8 = undefined;
|
||||
_ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]);
|
||||
if (!std.mem.eql(u8, &tag_buf, "meta")) {
|
||||
continue;
|
||||
}
|
||||
pos += 4;
|
||||
|
||||
// Must be followed by whitespace or end of tag
|
||||
if (pos >= data.len) return null;
|
||||
if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and
|
||||
data[pos] != '\r' and data[pos] != '/')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan attributes within this meta tag
|
||||
const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null;
|
||||
const attrs = data[pos..tag_end];
|
||||
|
||||
// Look for charset= attribute directly
|
||||
if (findAttrValue(attrs, "charset")) |charset| {
|
||||
if (charset.len > 0 and charset.len <= 40) return charset;
|
||||
}
|
||||
|
||||
// Look for http-equiv="content-type" with content="...;charset=X"
|
||||
if (findAttrValue(attrs, "http-equiv")) |he| {
|
||||
if (std.ascii.eqlIgnoreCase(he, "content-type")) {
|
||||
if (findAttrValue(attrs, "content")) |content| {
|
||||
if (extractCharsetFromContentType(content)) |charset| {
|
||||
return charset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 {
|
||||
var pos: usize = 0;
|
||||
while (pos < attrs.len) {
|
||||
// Skip whitespace
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or
|
||||
attrs[pos] == '\n' or attrs[pos] == '\r'))
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
if (pos >= attrs.len) return null;
|
||||
|
||||
// Read attribute name
|
||||
const attr_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and
|
||||
attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/')
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
const attr_name = attrs[attr_start..pos];
|
||||
|
||||
// Skip whitespace around =
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||
if (pos >= attrs.len or attrs[pos] != '=') {
|
||||
// No '=' found - skip this token. Advance at least one byte to avoid infinite loop.
|
||||
if (pos == attr_start) pos += 1;
|
||||
continue;
|
||||
}
|
||||
pos += 1; // skip '='
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||
if (pos >= attrs.len) return null;
|
||||
|
||||
// Read attribute value
|
||||
const value = blk: {
|
||||
if (attrs[pos] == '"' or attrs[pos] == '\'') {
|
||||
const quote = attrs[pos];
|
||||
pos += 1;
|
||||
const val_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != quote) pos += 1;
|
||||
const val = attrs[val_start..pos];
|
||||
if (pos < attrs.len) pos += 1; // skip closing quote
|
||||
break :blk val;
|
||||
} else {
|
||||
const val_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and
|
||||
attrs[pos] != '>' and attrs[pos] != '/')
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
break :blk attrs[val_start..pos];
|
||||
}
|
||||
};
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(attr_name, name)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn extractCharsetFromContentType(content: []const u8) ?[]const u8 {
|
||||
var it = std.mem.splitScalar(u8, content, ';');
|
||||
while (it.next()) |part| {
|
||||
const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' });
|
||||
if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) {
|
||||
const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' });
|
||||
if (val.len > 0 and val.len <= 40) return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn sniff(body: []const u8) ?Mime {
|
||||
// 0x0C is form feed
|
||||
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
|
||||
@@ -178,15 +309,30 @@ pub fn sniff(body: []const u8) ?Mime {
|
||||
if (content[0] != '<') {
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
|
||||
// UTF-8 BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
return .{
|
||||
.content_type = .{ .text_plain = {} },
|
||||
.charset = default_charset,
|
||||
.charset_len = default_charset_len,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
|
||||
// UTF-16 big-endian BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
return .{
|
||||
.content_type = .{ .text_plain = {} },
|
||||
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33,
|
||||
.charset_len = 8,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
|
||||
// UTF-16 little-endian BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
return .{
|
||||
.content_type = .{ .text_plain = {} },
|
||||
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33,
|
||||
.charset_len = 8,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -240,6 +386,14 @@ pub fn isHTML(self: *const Mime) bool {
|
||||
return self.content_type == .text_html;
|
||||
}
|
||||
|
||||
pub fn isText(mime: *const Mime) bool {
|
||||
return switch (mime.content_type) {
|
||||
.text_xml, .text_html, .text_javascript, .text_plain, .text_css => true,
|
||||
.application_json => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
// we expect value to be lowercase
|
||||
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
|
||||
@@ -540,6 +694,24 @@ test "Mime: sniff" {
|
||||
|
||||
try expectHTML("<!-->");
|
||||
try expectHTML(" \n\t <!-->");
|
||||
|
||||
{
|
||||
const mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-8", mime.charsetString());
|
||||
}
|
||||
|
||||
{
|
||||
const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-16BE", mime.charsetString());
|
||||
}
|
||||
|
||||
{
|
||||
const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-16LE", mime.charsetString());
|
||||
}
|
||||
}
|
||||
|
||||
const Expectation = struct {
|
||||
@@ -576,3 +748,35 @@ fn expect(expected: Expectation, input: []const u8) !void {
|
||||
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
|
||||
}
|
||||
}
|
||||
|
||||
test "Mime: prescanCharset" {
|
||||
// <meta charset="X">
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
|
||||
try testing.expectEqual("iso-8859-1", Mime.prescanCharset("<html><head><meta charset=\"iso-8859-1\">").?);
|
||||
try testing.expectEqual("shift_jis", Mime.prescanCharset("<meta charset='shift_jis'>").?);
|
||||
|
||||
// Case-insensitive tag matching
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<META charset=\"utf-8\">").?);
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<Meta charset=\"utf-8\">").?);
|
||||
|
||||
// <meta http-equiv="Content-Type" content="text/html; charset=X">
|
||||
try testing.expectEqual(
|
||||
"iso-8859-1",
|
||||
Mime.prescanCharset("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">").?,
|
||||
);
|
||||
|
||||
// No charset found
|
||||
try testing.expectEqual(null, Mime.prescanCharset("<html><head><title>Test</title>"));
|
||||
try testing.expectEqual(null, Mime.prescanCharset(""));
|
||||
try testing.expectEqual(null, Mime.prescanCharset("no html here"));
|
||||
|
||||
// Self-closing meta without charset must not loop forever
|
||||
try testing.expectEqual(null, Mime.prescanCharset("<meta foo=\"bar\"/>"));
|
||||
|
||||
// Charset after 1024 bytes should not be found
|
||||
var long_html: [1100]u8 = undefined;
|
||||
@memset(&long_html, ' ');
|
||||
const suffix = "<meta charset=\"windows-1252\">";
|
||||
@memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
|
||||
try testing.expectEqual(null, Mime.prescanCharset(&long_html));
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const Mime = @import("Mime.zig");
|
||||
@@ -35,6 +34,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");
|
||||
|
||||
@@ -42,7 +42,6 @@ const URL = @import("URL.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const CData = @import("webapi/CData.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const HtmlElement = @import("webapi/element/Html.zig");
|
||||
@@ -58,13 +57,13 @@ const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const MutationObserver = @import("webapi/MutationObserver.zig");
|
||||
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");
|
||||
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const timestamp = @import("../datetime.zig").timestamp;
|
||||
const milliTimestamp = @import("../datetime.zig").milliTimestamp;
|
||||
@@ -143,6 +142,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)
|
||||
@@ -268,6 +268,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),
|
||||
};
|
||||
@@ -295,26 +296,37 @@ 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;
|
||||
|
||||
if (comptime builtin.is_test == false) {
|
||||
// HTML test runner manually calls these as necessary
|
||||
try self.js.scheduler.add(session.browser, struct {
|
||||
fn runIdleTasks(ctx: *anyopaque) !?u32 {
|
||||
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
|
||||
b.runIdleTasks();
|
||||
return 200;
|
||||
}
|
||||
}.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
|
||||
if (parent == null) {
|
||||
// HTML test runner manually calls these as necessary
|
||||
try self.js.scheduler.add(session.browser, struct {
|
||||
fn runIdleTasks(ctx: *anyopaque) !?u32 {
|
||||
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
|
||||
b.runIdleTasks();
|
||||
return 200;
|
||||
}
|
||||
}.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +365,7 @@ pub fn deinit(self: *Page, abort_http: bool) void {
|
||||
}
|
||||
|
||||
self._script_manager.deinit();
|
||||
self._style_manager.deinit();
|
||||
|
||||
session.releaseArena(self.call_arena);
|
||||
}
|
||||
@@ -368,12 +381,9 @@ pub fn getTitle(self: *Page) !?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add comon headers for a request:
|
||||
// * cookies
|
||||
// Add common headers for a request:
|
||||
// * referer
|
||||
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void {
|
||||
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
|
||||
|
||||
pub fn headersForRequest(self: *Page, headers: *HttpClient.Headers) !void {
|
||||
// Build the referer
|
||||
const referer = blk: {
|
||||
if (self.referer_header == null) {
|
||||
@@ -407,16 +417,9 @@ pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
|
||||
return std.mem.startsWith(u8, url, current_origin);
|
||||
}
|
||||
|
||||
/// Look up a blob URL in this page's registry, walking up the parent chain.
|
||||
/// Look up a blob URL in this page's registry.
|
||||
pub fn lookupBlobUrl(self: *Page, url: []const u8) ?*Blob {
|
||||
var current: ?*Page = self;
|
||||
while (current) |page| {
|
||||
if (page._blob_urls.get(url)) |blob| {
|
||||
return blob;
|
||||
}
|
||||
current = page.parent;
|
||||
}
|
||||
return null;
|
||||
return self._blob_urls.get(url);
|
||||
}
|
||||
|
||||
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void {
|
||||
@@ -441,6 +444,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]);
|
||||
@@ -457,7 +466,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
|
||||
// Content injection
|
||||
if (is_blob) {
|
||||
const blob = self.lookupBlobUrl(request_url) orelse {
|
||||
// For navigation, walk up the parent chain to find blob URLs
|
||||
// (e.g., parent creates blob URL and sets iframe.src to it)
|
||||
const blob = blk: {
|
||||
var current: ?*Page = self.parent;
|
||||
while (current) |page| {
|
||||
if (page._blob_urls.get(request_url)) |b| break :blk b;
|
||||
current = page.parent;
|
||||
}
|
||||
log.warn(.js, "invalid blob", .{ .url = request_url });
|
||||
return error.BlobNotFound;
|
||||
};
|
||||
@@ -522,8 +538,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
if (opts.header) |hdr| {
|
||||
try headers.add(hdr);
|
||||
}
|
||||
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, self.url, &headers);
|
||||
|
||||
// We dispatch page_navigate event before sending the request.
|
||||
// It ensures the event page_navigated is not dispatched before this one.
|
||||
session.notification.dispatch(.page_navigate, &.{
|
||||
@@ -550,6 +564,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
||||
.headers = headers,
|
||||
.body = opts.body,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.cookie_origin = self.url,
|
||||
.resource_type = .document,
|
||||
.notification = self._session.notification,
|
||||
.header_callback = pageHeaderDoneCallback,
|
||||
@@ -580,13 +595,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 },
|
||||
);
|
||||
@@ -709,11 +745,14 @@ pub fn scriptsCompletedLoading(self: *Page) void {
|
||||
}
|
||||
|
||||
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
|
||||
blk: {
|
||||
var ls: JS.Local.Scope = undefined;
|
||||
self.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
var ls: JS.Local.Scope = undefined;
|
||||
self.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
const entered = self.js.enter(&ls.handle_scope);
|
||||
defer entered.exit();
|
||||
|
||||
blk: {
|
||||
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
|
||||
log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
|
||||
break :blk;
|
||||
@@ -722,6 +761,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
|
||||
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
|
||||
};
|
||||
}
|
||||
|
||||
self.pendingLoadCompleted();
|
||||
}
|
||||
|
||||
@@ -848,13 +888,25 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
if (self._parse_state == .pre) {
|
||||
// we lazily do this, because we might need the first chunk of data
|
||||
// to sniff the content type
|
||||
const mime: Mime = blk: {
|
||||
var mime: Mime = blk: {
|
||||
if (transfer.response_header.?.contentType()) |ct| {
|
||||
break :blk try Mime.parse(ct);
|
||||
}
|
||||
break :blk Mime.sniff(data);
|
||||
} orelse .unknown;
|
||||
|
||||
// If the HTTP Content-Type header didn't specify a charset and this is HTML,
|
||||
// prescan the first 1024 bytes for a <meta charset> declaration.
|
||||
if (mime.content_type == .text_html and mime.is_default_charset) {
|
||||
if (Mime.prescanCharset(data)) |charset| {
|
||||
if (charset.len <= 40) {
|
||||
@memcpy(mime.charset[0..charset.len], charset);
|
||||
mime.charset[charset.len] = 0;
|
||||
mime.charset_len = charset.len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.page, "navigate first chunk", .{
|
||||
.content_type = mime.content_type,
|
||||
@@ -976,6 +1028,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
|
||||
});
|
||||
|
||||
parser.parse(html);
|
||||
self._parse_state = .complete;
|
||||
self.documentIsComplete();
|
||||
},
|
||||
else => unreachable,
|
||||
@@ -1091,7 +1144,6 @@ pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
|
||||
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
|
||||
self._pending_loads -= 1;
|
||||
iframe._window = null;
|
||||
page_frame.deinit(true);
|
||||
return error.IFrameLoadError;
|
||||
};
|
||||
|
||||
@@ -2539,6 +2591,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2550,8 +2613,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 });
|
||||
@@ -2563,8 +2628,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 });
|
||||
@@ -3256,14 +3323,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
|
||||
.type = self._type,
|
||||
});
|
||||
}
|
||||
const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{
|
||||
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.clientX = x,
|
||||
.clientY = y,
|
||||
}, self)).asEvent();
|
||||
try self._event_manager.dispatch(target.asEventTarget(), event);
|
||||
}, self);
|
||||
try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent());
|
||||
}
|
||||
|
||||
// callback when the "click" event reaches the pages.
|
||||
@@ -3416,7 +3483,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();
|
||||
@@ -3479,19 +3547,6 @@ pub fn insertText(self: *Page, v: []const u8) !void {
|
||||
}
|
||||
}
|
||||
|
||||
const RequestCookieOpts = struct {
|
||||
is_http: bool = true,
|
||||
is_navigation: bool = false,
|
||||
};
|
||||
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
|
||||
return .{
|
||||
.jar = &self._session.cookie_jar,
|
||||
.origin = self.url,
|
||||
.is_http = opts.is_http,
|
||||
.is_navigation = opts.is_navigation,
|
||||
};
|
||||
}
|
||||
|
||||
fn asUint(comptime string: anytype) std.meta.Int(
|
||||
.unsigned,
|
||||
@bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0
|
||||
@@ -3507,7 +3562,7 @@ fn asUint(comptime string: anytype) std.meta.Int(
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "WebApi: Page" {
|
||||
const filter: testing.LogFilter = .init(.http);
|
||||
const filter: testing.LogFilter = .init(&.{ .http, .js });
|
||||
defer filter.deinit();
|
||||
|
||||
try testing.htmlRunner("page", .{});
|
||||
|
||||
238
src/browser/Runner.zig
Normal file
238
src/browser/Runner.zig
Normal file
@@ -0,0 +1,238 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const Session = @import("Session.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const Runner = @This();
|
||||
|
||||
page: *Page,
|
||||
session: *Session,
|
||||
http_client: *HttpClient,
|
||||
|
||||
pub const Opts = struct {};
|
||||
|
||||
pub fn init(session: *Session, _: Opts) !Runner {
|
||||
const page = &(session.page orelse return error.NoPage);
|
||||
|
||||
return .{
|
||||
.page = page,
|
||||
.session = session,
|
||||
.http_client = session.browser.http_client,
|
||||
};
|
||||
}
|
||||
|
||||
pub const WaitOpts = struct {
|
||||
ms: u32,
|
||||
until: lp.Config.WaitUntil = .done,
|
||||
};
|
||||
pub fn wait(self: *Runner, opts: WaitOpts) !void {
|
||||
_ = try self._wait(false, opts);
|
||||
}
|
||||
|
||||
pub const CDPWaitResult = enum {
|
||||
done,
|
||||
cdp_socket,
|
||||
};
|
||||
pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult {
|
||||
return self._wait(true, opts);
|
||||
}
|
||||
|
||||
fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult {
|
||||
var timer = try std.time.Timer.start();
|
||||
var ms_remaining = opts.ms;
|
||||
|
||||
const tick_opts = TickOpts{
|
||||
.ms = 200,
|
||||
.until = opts.until,
|
||||
};
|
||||
while (true) {
|
||||
const tick_result = self._tick(is_cdp, tick_opts) catch |err| {
|
||||
switch (err) {
|
||||
error.JsError => {}, // already logged (with hopefully more context)
|
||||
else => log.err(.browser, "session wait", .{
|
||||
.err = err,
|
||||
.url = self.page.url,
|
||||
}),
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
const next_ms = switch (tick_result) {
|
||||
.ok => |next_ms| next_ms,
|
||||
.done => return .done,
|
||||
.cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable,
|
||||
};
|
||||
|
||||
const ms_elapsed = timer.lap() / 1_000_000;
|
||||
if (ms_elapsed >= ms_remaining) {
|
||||
return .done;
|
||||
}
|
||||
ms_remaining -= @intCast(ms_elapsed);
|
||||
if (next_ms > 0) {
|
||||
std.Thread.sleep(std.time.ns_per_ms * next_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const TickOpts = struct {
|
||||
ms: u32,
|
||||
until: lp.Config.WaitUntil = .done,
|
||||
};
|
||||
|
||||
pub const TickResult = union(enum) {
|
||||
done,
|
||||
ok: u32,
|
||||
};
|
||||
pub fn tick(self: *Runner, opts: TickOpts) !TickResult {
|
||||
return switch (try self._tick(false, opts)) {
|
||||
.ok => |ms| .{ .ok = ms },
|
||||
.done => .done,
|
||||
.cdp_socket => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub const CDPTickResult = union(enum) {
|
||||
done,
|
||||
cdp_socket,
|
||||
ok: u32,
|
||||
};
|
||||
pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult {
|
||||
return self._tick(true, opts);
|
||||
}
|
||||
|
||||
fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
|
||||
const page = self.page;
|
||||
const http_client = self.http_client;
|
||||
|
||||
switch (page._parse_state) {
|
||||
.pre, .raw, .text, .image => {
|
||||
// The main page hasn't started/finished navigating.
|
||||
// There's no JS to run, and no reason to run the scheduler.
|
||||
if (http_client.active == 0 and (comptime is_cdp) == false) {
|
||||
// haven't started navigating, I guess.
|
||||
return .done;
|
||||
}
|
||||
|
||||
// Either we have active http connections, or we're in CDP
|
||||
// mode with an extra socket. Either way, we're waiting
|
||||
// for http traffic
|
||||
const http_result = try http_client.tick(@intCast(opts.ms));
|
||||
if ((comptime is_cdp) and http_result == .cdp_socket) {
|
||||
return .cdp_socket;
|
||||
}
|
||||
return .{ .ok = 0 };
|
||||
},
|
||||
.html, .complete => {
|
||||
const session = self.session;
|
||||
if (session.queued_navigation.items.len != 0) {
|
||||
try session.processQueuedNavigation();
|
||||
self.page = &session.page.?; // might have changed
|
||||
return .{ .ok = 0 };
|
||||
}
|
||||
const browser = session.browser;
|
||||
|
||||
// The HTML page was parsed. We now either have JS scripts to
|
||||
// download, or scheduled tasks to execute, or both.
|
||||
|
||||
// scheduler.run could trigger new http transfers, so do not
|
||||
// store http_client.active BEFORE this call and then use
|
||||
// it AFTER.
|
||||
try browser.runMacrotasks();
|
||||
|
||||
// Each call to this runs scheduled load events.
|
||||
try page.dispatchLoad();
|
||||
|
||||
const http_active = http_client.active;
|
||||
const total_network_activity = http_active + http_client.intercepted;
|
||||
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
|
||||
page.notifyNetworkAlmostIdle();
|
||||
}
|
||||
if (page._notified_network_idle.check(total_network_activity == 0)) {
|
||||
page.notifyNetworkIdle();
|
||||
}
|
||||
|
||||
if (http_active == 0 and (comptime is_cdp == false)) {
|
||||
// we don't need to consider http_client.intercepted here
|
||||
// because is_cdp is true, and that can only be
|
||||
// the case when interception isn't possible.
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(http_client.intercepted == 0);
|
||||
}
|
||||
|
||||
if (browser.hasBackgroundTasks()) {
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
// background tasks. We'll wait for them.
|
||||
browser.waitForBackgroundTasks();
|
||||
}
|
||||
|
||||
switch (opts.until) {
|
||||
.done => {},
|
||||
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
|
||||
return .done;
|
||||
},
|
||||
.load => if (page._load_state == .complete) {
|
||||
return .done;
|
||||
},
|
||||
.networkidle => if (page._notified_network_idle == .done) {
|
||||
return .done;
|
||||
},
|
||||
}
|
||||
|
||||
// We never advertise a wait time of more than 20, there can
|
||||
// always be new background tasks to run.
|
||||
if (browser.msToNextMacrotask()) |ms_to_next_task| {
|
||||
return .{ .ok = @min(ms_to_next_task, 20) };
|
||||
}
|
||||
return .done;
|
||||
}
|
||||
|
||||
// We're here because we either have active HTTP
|
||||
// connections, or is_cdp == false (aka, there's
|
||||
// an cdp_socket registered with the http client).
|
||||
// We should continue to run tasks, so we minimize how long
|
||||
// we'll poll for network I/O.
|
||||
var ms_to_wait = @min(opts.ms, browser.msToNextMacrotask() orelse 200);
|
||||
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
|
||||
// if we have background tasks, we don't want to wait too
|
||||
// long for a message from the client. We want to go back
|
||||
// to the top of the loop and run macrotasks.
|
||||
ms_to_wait = 10;
|
||||
}
|
||||
const http_result = try http_client.tick(@intCast(@min(opts.ms, ms_to_wait)));
|
||||
if ((comptime is_cdp) and http_result == .cdp_socket) {
|
||||
return .cdp_socket;
|
||||
}
|
||||
return .{ .ok = 0 };
|
||||
},
|
||||
.err => |err| {
|
||||
page._parse_state = .{ .raw_done = @errorName(err) };
|
||||
return err;
|
||||
},
|
||||
.raw_done => return .done,
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,10 @@ const String = @import("../string.zig").String;
|
||||
const js = @import("js/js.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const Page = @import("Page.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
|
||||
const Element = @import("webapi/Element.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArrayList = std.ArrayList;
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
@@ -63,9 +61,6 @@ shutdown: bool = false,
|
||||
|
||||
client: *HttpClient,
|
||||
allocator: Allocator,
|
||||
buffer_pool: BufferPool,
|
||||
|
||||
script_pool: std.heap.MemoryPool(Script),
|
||||
|
||||
// We can download multiple sync modules in parallel, but we want to process
|
||||
// them in order. We can't use an std.DoublyLinkedList, like the other script types,
|
||||
@@ -101,18 +96,14 @@ pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptM
|
||||
.imported_modules = .empty,
|
||||
.client = http_client,
|
||||
.static_scripts_done = false,
|
||||
.buffer_pool = BufferPool.init(allocator, 5),
|
||||
.page_notified_of_completion = false,
|
||||
.script_pool = std.heap.MemoryPool(Script).init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ScriptManager) void {
|
||||
// necessary to free any buffers scripts may be referencing
|
||||
// necessary to free any arenas scripts may be referencing
|
||||
self.reset();
|
||||
|
||||
self.buffer_pool.deinit();
|
||||
self.script_pool.deinit();
|
||||
self.imported_modules.deinit(self.allocator);
|
||||
// we don't deinit self.importmap b/c we use the page's arena for its
|
||||
// allocations.
|
||||
@@ -121,7 +112,10 @@ pub fn deinit(self: *ScriptManager) void {
|
||||
pub fn reset(self: *ScriptManager) void {
|
||||
var it = self.imported_modules.valueIterator();
|
||||
while (it.next()) |value_ptr| {
|
||||
self.buffer_pool.release(value_ptr.buffer);
|
||||
switch (value_ptr.state) {
|
||||
.done => |script| script.deinit(),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
self.imported_modules.clearRetainingCapacity();
|
||||
|
||||
@@ -138,13 +132,13 @@ pub fn reset(self: *ScriptManager) void {
|
||||
fn clearList(list: *std.DoublyLinkedList) void {
|
||||
while (list.popFirst()) |n| {
|
||||
const script: *Script = @fieldParentPtr("node", n);
|
||||
script.deinit(true);
|
||||
script.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers {
|
||||
fn getHeaders(self: *ScriptManager) !net_http.Headers {
|
||||
var headers = try self.client.newHeaders();
|
||||
try self.page.headersForRequest(self.page.arena, url, &headers);
|
||||
try self.page.headersForRequest(&headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -191,19 +185,26 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
return;
|
||||
};
|
||||
|
||||
var handover = false;
|
||||
const page = self.page;
|
||||
|
||||
const arena = try page.getArena(.{ .debug = "addFromElement" });
|
||||
errdefer if (!handover) {
|
||||
page.releaseArena(arena);
|
||||
};
|
||||
|
||||
var source: Script.Source = undefined;
|
||||
var remote_url: ?[:0]const u8 = null;
|
||||
const base_url = page.base();
|
||||
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||
if (try parseDataURI(page.arena, src)) |data_uri| {
|
||||
if (try parseDataURI(arena, src)) |data_uri| {
|
||||
source = .{ .@"inline" = data_uri };
|
||||
} else {
|
||||
remote_url = try URL.resolve(page.arena, base_url, src, .{});
|
||||
remote_url = try URL.resolve(arena, base_url, src, .{});
|
||||
source = .{ .remote = .{} };
|
||||
}
|
||||
} else {
|
||||
var buf = std.Io.Writer.Allocating.init(page.arena);
|
||||
var buf = std.Io.Writer.Allocating.init(arena);
|
||||
try element.asNode().getChildTextContent(&buf.writer);
|
||||
try buf.writer.writeByte(0);
|
||||
const data = buf.written();
|
||||
@@ -211,6 +212,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
if (inline_source.len == 0) {
|
||||
// we haven't set script_element._executed = true yet, which is good.
|
||||
// If content is appended to the script, we will execute it then.
|
||||
page.releaseArena(arena);
|
||||
return;
|
||||
}
|
||||
source = .{ .@"inline" = inline_source };
|
||||
@@ -218,15 +220,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
|
||||
// Only set _executed (already-started) when we actually have content to execute
|
||||
script_element._executed = true;
|
||||
|
||||
const script = try self.script_pool.create();
|
||||
errdefer self.script_pool.destroy(script);
|
||||
|
||||
const is_inline = source == .@"inline";
|
||||
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.kind = kind,
|
||||
.node = .{},
|
||||
.arena = arena,
|
||||
.manager = self,
|
||||
.source = source,
|
||||
.script_element = script_element,
|
||||
@@ -270,7 +270,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
if (is_blocking == false) {
|
||||
self.scriptList(script).remove(&script.node);
|
||||
}
|
||||
script.deinit(true);
|
||||
// Let the outer errdefer handle releasing the arena if client.request fails
|
||||
}
|
||||
|
||||
try self.client.request(.{
|
||||
@@ -278,9 +278,10 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
.ctx = script,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(url),
|
||||
.headers = try self.getHeaders(),
|
||||
.blocking = is_blocking,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.resource_type = .script,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
@@ -289,6 +290,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
});
|
||||
handover = true;
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
@@ -318,7 +320,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
}
|
||||
if (script.status == 0) {
|
||||
// an error (that we already logged)
|
||||
script.deinit(true);
|
||||
script.deinit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -327,7 +329,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
|
||||
self.is_evaluating = true;
|
||||
defer {
|
||||
self.is_evaluating = was_evaluating;
|
||||
script.deinit(true);
|
||||
script.deinit();
|
||||
}
|
||||
return script.eval(page);
|
||||
}
|
||||
@@ -359,11 +361,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
||||
}
|
||||
errdefer _ = self.imported_modules.remove(url);
|
||||
|
||||
const script = try self.script_pool.create();
|
||||
errdefer self.script_pool.destroy(script);
|
||||
const page = self.page;
|
||||
const arena = try page.getArena(.{ .debug = "preloadImport" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.kind = .module,
|
||||
.arena = arena,
|
||||
.url = url,
|
||||
.node = .{},
|
||||
.manager = self,
|
||||
@@ -373,11 +378,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
||||
.mode = .import,
|
||||
};
|
||||
|
||||
gop.value_ptr.* = ImportedModule{
|
||||
.manager = self,
|
||||
};
|
||||
|
||||
const page = self.page;
|
||||
gop.value_ptr.* = ImportedModule{};
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
@@ -392,13 +393,20 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
||||
});
|
||||
}
|
||||
|
||||
try self.client.request(.{
|
||||
// This seems wrong since we're not dealing with an async import (unlike
|
||||
// getAsyncModule below), but all we're trying to do here is pre-load the
|
||||
// script for execution at some point in the future (when waitForImport is
|
||||
// called).
|
||||
self.async_scripts.append(&script.node);
|
||||
|
||||
self.client.request(.{
|
||||
.url = url,
|
||||
.ctx = script,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(url),
|
||||
.headers = try self.getHeaders(),
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.resource_type = .script,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
@@ -406,13 +414,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
});
|
||||
|
||||
// This seems wrong since we're not dealing with an async import (unlike
|
||||
// getAsyncModule below), but all we're trying to do here is pre-load the
|
||||
// script for execution at some point in the future (when waitForImport is
|
||||
// called).
|
||||
self.async_scripts.append(&script.node);
|
||||
}) catch |err| {
|
||||
self.async_scripts.remove(&script.node);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
||||
@@ -433,12 +438,12 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
||||
_ = try client.tick(200);
|
||||
continue;
|
||||
},
|
||||
.done => {
|
||||
.done => |script| {
|
||||
var shared = false;
|
||||
const buffer = entry.value_ptr.buffer;
|
||||
const waiters = entry.value_ptr.waiters;
|
||||
|
||||
if (waiters == 0) {
|
||||
if (waiters == 1) {
|
||||
self.imported_modules.removeByPtr(entry.key_ptr);
|
||||
} else {
|
||||
shared = true;
|
||||
@@ -447,7 +452,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
||||
return .{
|
||||
.buffer = buffer,
|
||||
.shared = shared,
|
||||
.buffer_pool = &self.buffer_pool,
|
||||
.script = script,
|
||||
};
|
||||
},
|
||||
.err => return error.Failed,
|
||||
@@ -456,11 +461,14 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
|
||||
}
|
||||
|
||||
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
|
||||
const script = try self.script_pool.create();
|
||||
errdefer self.script_pool.destroy(script);
|
||||
const page = self.page;
|
||||
const arena = try page.getArena(.{ .debug = "getAsyncImport" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const script = try arena.create(Script);
|
||||
script.* = .{
|
||||
.kind = .module,
|
||||
.arena = arena,
|
||||
.url = url,
|
||||
.node = .{},
|
||||
.manager = self,
|
||||
@@ -473,7 +481,6 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
||||
} },
|
||||
};
|
||||
|
||||
const page = self.page;
|
||||
if (comptime IS_DEBUG) {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
@@ -496,23 +503,26 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
||||
self.is_evaluating = true;
|
||||
defer self.is_evaluating = was_evaluating;
|
||||
|
||||
try self.client.request(.{
|
||||
self.async_scripts.append(&script.node);
|
||||
self.client.request(.{
|
||||
.url = url,
|
||||
.method = .GET,
|
||||
.frame_id = page._frame_id,
|
||||
.headers = try self.getHeaders(url),
|
||||
.headers = try self.getHeaders(),
|
||||
.ctx = script,
|
||||
.resource_type = .script,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.cookie_origin = page.url,
|
||||
.notification = page._session.notification,
|
||||
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
|
||||
.header_callback = Script.headerCallback,
|
||||
.data_callback = Script.dataCallback,
|
||||
.done_callback = Script.doneCallback,
|
||||
.error_callback = Script.errorCallback,
|
||||
});
|
||||
|
||||
self.async_scripts.append(&script.node);
|
||||
}) catch |err| {
|
||||
self.async_scripts.remove(&script.node);
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
// Called from the Page to let us know it's done parsing the HTML. Necessary that
|
||||
@@ -537,18 +547,18 @@ fn evaluate(self: *ScriptManager) void {
|
||||
var script: *Script = @fieldParentPtr("node", n);
|
||||
switch (script.mode) {
|
||||
.async => {
|
||||
defer script.deinit(true);
|
||||
defer script.deinit();
|
||||
script.eval(page);
|
||||
},
|
||||
.import_async => |ia| {
|
||||
defer script.deinit(false);
|
||||
if (script.status < 200 or script.status > 299) {
|
||||
script.deinit();
|
||||
ia.callback(ia.data, error.FailedToLoad);
|
||||
} else {
|
||||
ia.callback(ia.data, .{
|
||||
.shared = false,
|
||||
.script = script,
|
||||
.buffer = script.source.remote,
|
||||
.buffer_pool = &self.buffer_pool,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -574,7 +584,7 @@ fn evaluate(self: *ScriptManager) void {
|
||||
}
|
||||
defer {
|
||||
_ = self.defer_scripts.popFirst();
|
||||
script.deinit(true);
|
||||
script.deinit();
|
||||
}
|
||||
script.eval(page);
|
||||
}
|
||||
@@ -625,11 +635,12 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
|
||||
}
|
||||
|
||||
pub const Script = struct {
|
||||
complete: bool,
|
||||
kind: Kind,
|
||||
complete: bool,
|
||||
status: u16 = 0,
|
||||
source: Source,
|
||||
url: []const u8,
|
||||
arena: Allocator,
|
||||
mode: ExecutionMode,
|
||||
node: std.DoublyLinkedList.Node,
|
||||
script_element: ?*Element.Html.Script,
|
||||
@@ -644,7 +655,6 @@ pub const Script = struct {
|
||||
debug_transfer_aborted: bool = false,
|
||||
debug_transfer_bytes_received: usize = 0,
|
||||
debug_transfer_notified_fail: bool = false,
|
||||
debug_transfer_redirecting: bool = false,
|
||||
debug_transfer_intercept_state: u8 = 0,
|
||||
debug_transfer_auth_challenge: bool = false,
|
||||
debug_transfer_easy_id: usize = 0,
|
||||
@@ -680,11 +690,8 @@ pub const Script = struct {
|
||||
import_async: ImportAsync,
|
||||
};
|
||||
|
||||
fn deinit(self: *Script, comptime release_buffer: bool) void {
|
||||
if ((comptime release_buffer) and self.source == .remote) {
|
||||
self.manager.buffer_pool.release(self.source.remote);
|
||||
}
|
||||
self.manager.script_pool.destroy(self);
|
||||
fn deinit(self: *Script) void {
|
||||
self.manager.page.releaseArena(self.arena);
|
||||
}
|
||||
|
||||
fn startCallback(transfer: *HttpClient.Transfer) !void {
|
||||
@@ -723,7 +730,6 @@ pub const Script = struct {
|
||||
.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,
|
||||
@@ -732,10 +738,9 @@ pub const Script = struct {
|
||||
.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,
|
||||
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
|
||||
});
|
||||
self.header_callback_called = true;
|
||||
self.debug_transfer_id = transfer.id;
|
||||
@@ -743,16 +748,15 @@ pub const Script = struct {
|
||||
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;
|
||||
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 = self.manager.buffer_pool.get();
|
||||
var buffer: std.ArrayList(u8) = .empty;
|
||||
if (transfer.getContentLength()) |cl| {
|
||||
try buffer.ensureTotalCapacity(self.manager.allocator, cl);
|
||||
try buffer.ensureTotalCapacity(self.arena, cl);
|
||||
}
|
||||
self.source = .{ .remote = buffer };
|
||||
return true;
|
||||
@@ -766,7 +770,7 @@ pub const Script = struct {
|
||||
};
|
||||
}
|
||||
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
|
||||
try self.source.remote.appendSlice(self.manager.allocator, data);
|
||||
try self.source.remote.appendSlice(self.arena, data);
|
||||
}
|
||||
|
||||
fn doneCallback(ctx: *anyopaque) !void {
|
||||
@@ -783,9 +787,8 @@ pub const Script = struct {
|
||||
} else if (self.mode == .import) {
|
||||
manager.async_scripts.remove(&self.node);
|
||||
const entry = manager.imported_modules.getPtr(self.url).?;
|
||||
entry.state = .done;
|
||||
entry.state = .{ .done = self };
|
||||
entry.buffer = self.source.remote;
|
||||
self.deinit(false);
|
||||
}
|
||||
manager.evaluate();
|
||||
}
|
||||
@@ -811,7 +814,7 @@ pub const Script = struct {
|
||||
const manager = self.manager;
|
||||
manager.scriptList(self).remove(&self.node);
|
||||
if (manager.shutdown) {
|
||||
self.deinit(true);
|
||||
self.deinit();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -823,7 +826,7 @@ pub const Script = struct {
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
self.deinit(true);
|
||||
self.deinit();
|
||||
manager.evaluate();
|
||||
}
|
||||
|
||||
@@ -951,76 +954,6 @@ pub const Script = struct {
|
||||
}
|
||||
};
|
||||
|
||||
const BufferPool = struct {
|
||||
count: usize,
|
||||
available: List = .{},
|
||||
allocator: Allocator,
|
||||
max_concurrent_transfers: u8,
|
||||
mem_pool: std.heap.MemoryPool(Container),
|
||||
|
||||
const List = std.SinglyLinkedList;
|
||||
|
||||
const Container = struct {
|
||||
node: List.Node,
|
||||
buf: std.ArrayList(u8),
|
||||
};
|
||||
|
||||
fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
|
||||
return .{
|
||||
.available = .{},
|
||||
.count = 0,
|
||||
.allocator = allocator,
|
||||
.max_concurrent_transfers = max_concurrent_transfers,
|
||||
.mem_pool = std.heap.MemoryPool(Container).init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *BufferPool) void {
|
||||
const allocator = self.allocator;
|
||||
|
||||
var node = self.available.first;
|
||||
while (node) |n| {
|
||||
const container: *Container = @fieldParentPtr("node", n);
|
||||
container.buf.deinit(allocator);
|
||||
node = n.next;
|
||||
}
|
||||
self.mem_pool.deinit();
|
||||
}
|
||||
|
||||
fn get(self: *BufferPool) std.ArrayList(u8) {
|
||||
const node = self.available.popFirst() orelse {
|
||||
// return a new buffer
|
||||
return .{};
|
||||
};
|
||||
|
||||
self.count -= 1;
|
||||
const container: *Container = @fieldParentPtr("node", node);
|
||||
defer self.mem_pool.destroy(container);
|
||||
return container.buf;
|
||||
}
|
||||
|
||||
fn release(self: *BufferPool, buffer: ArrayList(u8)) void {
|
||||
// create mutable copy
|
||||
var b = buffer;
|
||||
|
||||
if (self.count == self.max_concurrent_transfers) {
|
||||
b.deinit(self.allocator);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = self.mem_pool.create() catch |err| {
|
||||
b.deinit(self.allocator);
|
||||
log.err(.http, "SM BufferPool release", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
b.clearRetainingCapacity();
|
||||
container.* = .{ .buf = b, .node = .{} };
|
||||
self.count += 1;
|
||||
self.available.prepend(&container.node);
|
||||
}
|
||||
};
|
||||
|
||||
const ImportAsync = struct {
|
||||
data: *anyopaque,
|
||||
callback: ImportAsync.Callback,
|
||||
@@ -1030,12 +963,12 @@ const ImportAsync = struct {
|
||||
|
||||
pub const ModuleSource = struct {
|
||||
shared: bool,
|
||||
buffer_pool: *BufferPool,
|
||||
script: *Script,
|
||||
buffer: std.ArrayList(u8),
|
||||
|
||||
pub fn deinit(self: *ModuleSource) void {
|
||||
if (self.shared == false) {
|
||||
self.buffer_pool.release(self.buffer);
|
||||
self.script.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,15 +978,14 @@ pub const ModuleSource = struct {
|
||||
};
|
||||
|
||||
const ImportedModule = struct {
|
||||
manager: *ScriptManager,
|
||||
waiters: u16 = 1,
|
||||
state: State = .loading,
|
||||
buffer: std.ArrayList(u8) = .{},
|
||||
waiters: u16 = 1,
|
||||
|
||||
const State = enum {
|
||||
const State = union(enum) {
|
||||
err,
|
||||
done,
|
||||
loading,
|
||||
done: *Script,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,196 +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.
|
||||
const ms_to_next_task = try browser.runMacrotasks();
|
||||
|
||||
// Each call to this runs scheduled load events.
|
||||
try page.dispatchLoad();
|
||||
|
||||
const http_active = http_client.active;
|
||||
const total_network_activity = http_active + http_client.intercepted;
|
||||
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
|
||||
page.notifyNetworkAlmostIdle();
|
||||
}
|
||||
if (page._notified_network_idle.check(total_network_activity == 0)) {
|
||||
page.notifyNetworkIdle();
|
||||
}
|
||||
|
||||
if (http_active == 0 and exit_when_done) {
|
||||
// we don't need to consider http_client.intercepted here
|
||||
// because exit_when_done is true, and that can only be
|
||||
// the case when interception isn't possible.
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(http_client.intercepted == 0);
|
||||
}
|
||||
|
||||
var ms: u64 = ms_to_next_task orelse blk: {
|
||||
if (wait_ms - ms_remaining < 100) {
|
||||
if (comptime builtin.is_test) {
|
||||
return .done;
|
||||
}
|
||||
// Look, we want to exit ASAP, but we don't want
|
||||
// to exit so fast that we've run none of the
|
||||
// background jobs.
|
||||
break :blk 50;
|
||||
}
|
||||
|
||||
if (browser.hasBackgroundTasks()) {
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
// background tasks. We'll wait for them.
|
||||
browser.waitForBackgroundTasks();
|
||||
break :blk 20;
|
||||
}
|
||||
|
||||
// No http transfers, no cdp extra socket, no
|
||||
// scheduled tasks, we're done.
|
||||
return .done;
|
||||
};
|
||||
|
||||
if (ms > ms_remaining) {
|
||||
// Same as above, except we have a scheduled task,
|
||||
// it just happens to be too far into the future
|
||||
// compared to how long we were told to wait.
|
||||
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 lowPriority tasks, so we
|
||||
// minimize how long we'll poll for network I/O.
|
||||
var ms_to_wait = @min(200, ms_to_next_task orelse 200);
|
||||
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
|
||||
// if we have background tasks, we don't want to wait too
|
||||
// long for a message from the client. We want to go back
|
||||
// to the top of the loop and run macrotasks.
|
||||
ms_to_wait = 10;
|
||||
}
|
||||
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
|
||||
// data on a socket we aren't handling, return to caller
|
||||
return .cdp_socket;
|
||||
}
|
||||
}
|
||||
},
|
||||
.err => |err| {
|
||||
page._parse_state = .{ .raw_done = @errorName(err) };
|
||||
return err;
|
||||
},
|
||||
.raw_done => {
|
||||
if (exit_when_done) {
|
||||
return .done;
|
||||
}
|
||||
// we _could_ http_client.tick(ms_to_wait), but this has
|
||||
// the same result, and I feel is more correct.
|
||||
return .no_page;
|
||||
},
|
||||
}
|
||||
|
||||
const ms_elapsed = timer.lap() / 1_000_000;
|
||||
if (ms_elapsed >= ms_remaining) {
|
||||
return .done;
|
||||
}
|
||||
ms_remaining -= @intCast(ms_elapsed);
|
||||
}
|
||||
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| {
|
||||
@@ -521,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
|
||||
@@ -538,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.?;
|
||||
|
||||
@@ -548,10 +339,11 @@ fn processQueuedNavigation(self: *Session) !void {
|
||||
continue;
|
||||
}
|
||||
|
||||
try self.processFrameNavigation(page, qn);
|
||||
self.processFrameNavigation(page, qn) catch |err| {
|
||||
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
// Clear the queue after first pass
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -588,7 +382,8 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
|
||||
|
||||
errdefer iframe._window = null;
|
||||
|
||||
if (page._parent_notified) {
|
||||
const parent_notified = page._parent_notified;
|
||||
if (parent_notified) {
|
||||
// we already notified the parent that we had loaded
|
||||
parent._pending_loads += 1;
|
||||
}
|
||||
@@ -598,7 +393,19 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
|
||||
page.* = undefined;
|
||||
|
||||
try Page.init(page, frame_id, self, parent);
|
||||
errdefer page.deinit(true);
|
||||
errdefer {
|
||||
for (parent.frames.items, 0..) |frame, i| {
|
||||
if (frame == page) {
|
||||
parent.frames_sorted = false;
|
||||
_ = parent.frames.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parent_notified) {
|
||||
parent._pending_loads -= 1;
|
||||
}
|
||||
page.deinit(true);
|
||||
}
|
||||
|
||||
page.iframe = iframe;
|
||||
iframe._window = page.window;
|
||||
@@ -619,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);
|
||||
@@ -659,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];
|
||||
@@ -347,25 +357,38 @@ pub fn isHTTPS(raw: [:0]const u8) bool {
|
||||
|
||||
pub fn getHostname(raw: [:0]const u8) []const u8 {
|
||||
const host = getHost(raw);
|
||||
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host;
|
||||
return host[0..pos];
|
||||
const port_sep = findPortSeparator(host) orelse return host;
|
||||
return host[0..port_sep];
|
||||
}
|
||||
|
||||
pub fn getPort(raw: [:0]const u8) []const u8 {
|
||||
const host = getHost(raw);
|
||||
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return "";
|
||||
const port_sep = findPortSeparator(host) orelse return "";
|
||||
return host[port_sep + 1 ..];
|
||||
}
|
||||
|
||||
if (pos + 1 >= host.len) {
|
||||
return "";
|
||||
// Finds the colon separating host from port, handling IPv6 bracket notation.
|
||||
// For IPv6 like "[::1]:8080", returns position of ":" after "]".
|
||||
// For IPv6 like "[::1]" (no port), returns null.
|
||||
// For regular hosts, returns position of last ":" if followed by digits.
|
||||
fn findPortSeparator(host: []const u8) ?usize {
|
||||
if (host.len > 0 and host[0] == '[') {
|
||||
// IPv6: find closing bracket, port separator must be after it
|
||||
const bracket_end = std.mem.indexOfScalar(u8, host, ']') orelse return null;
|
||||
if (bracket_end + 1 < host.len and host[bracket_end + 1] == ':') {
|
||||
return bracket_end + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Regular host: find last colon and verify it's followed by digits
|
||||
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return null;
|
||||
if (pos + 1 >= host.len) return null;
|
||||
|
||||
for (host[pos + 1 ..]) |c| {
|
||||
if (c < '0' or c > '9') {
|
||||
return "";
|
||||
}
|
||||
if (c < '0' or c > '9') return null;
|
||||
}
|
||||
|
||||
return host[pos + 1 ..];
|
||||
return pos;
|
||||
}
|
||||
|
||||
pub fn getSearch(raw: [:0]const u8) []const u8 {
|
||||
@@ -393,21 +416,12 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
|
||||
return null;
|
||||
}
|
||||
|
||||
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..], "/?#");
|
||||
const authority_end = if (authority_end_relative) |end|
|
||||
authority_start + end
|
||||
else
|
||||
raw.len;
|
||||
const auth = parseAuthority(raw) orelse return null;
|
||||
const has_user_info = auth.has_user_info;
|
||||
const authority_end = auth.host_end;
|
||||
|
||||
// Check for port in the host:port section
|
||||
const host_part = raw[authority_start..authority_end];
|
||||
const host_part = auth.getHost(raw);
|
||||
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
|
||||
const port = host_part[colon_pos_in_host + 1 ..];
|
||||
|
||||
@@ -448,31 +462,18 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
|
||||
}
|
||||
|
||||
fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
|
||||
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
|
||||
const auth = parseAuthority(raw) orelse return null;
|
||||
if (!auth.has_user_info) return null;
|
||||
|
||||
// User info is from authority_start to host_start - 1 (excluding the @)
|
||||
const scheme_end = std.mem.indexOf(u8, raw, "://").?;
|
||||
const authority_start = scheme_end + 3;
|
||||
|
||||
const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null;
|
||||
const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len;
|
||||
|
||||
const full_pos = authority_start + pos;
|
||||
if (full_pos < path_start) {
|
||||
return raw[authority_start..full_pos];
|
||||
}
|
||||
|
||||
return null;
|
||||
return raw[authority_start .. auth.host_start - 1];
|
||||
}
|
||||
|
||||
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| {
|
||||
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];
|
||||
const auth = parseAuthority(raw) orelse return "";
|
||||
return auth.getHost(raw);
|
||||
}
|
||||
|
||||
// Returns true if these two URLs point to the same document.
|
||||
@@ -587,11 +588,13 @@ pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocato
|
||||
const search = getSearch(current);
|
||||
const 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 +605,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 +622,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);
|
||||
}
|
||||
@@ -745,6 +752,47 @@ pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
|
||||
return result.items;
|
||||
}
|
||||
|
||||
const AuthorityInfo = struct {
|
||||
host_start: usize,
|
||||
host_end: usize,
|
||||
has_user_info: bool,
|
||||
|
||||
fn getHost(self: AuthorityInfo, raw: []const u8) []const u8 {
|
||||
return raw[self.host_start..self.host_end];
|
||||
}
|
||||
};
|
||||
|
||||
// Parses the authority component of a URL, correctly handling userinfo.
|
||||
// Returns null if the URL doesn't have a valid scheme (no "://").
|
||||
// SECURITY: Only looks for @ within the authority portion (before /?#)
|
||||
// to prevent path-based @ injection attacks.
|
||||
fn parseAuthority(raw: []const u8) ?AuthorityInfo {
|
||||
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
|
||||
const authority_start = scheme_end + 3;
|
||||
|
||||
// Find end of authority FIRST (start of path/query/fragment or end of string)
|
||||
const authority_end = if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |end|
|
||||
authority_start + end
|
||||
else
|
||||
raw.len;
|
||||
|
||||
// Only look for @ within the authority portion, not in path/query/fragment
|
||||
const authority_portion = raw[authority_start..authority_end];
|
||||
if (std.mem.indexOf(u8, authority_portion, "@")) |pos| {
|
||||
return .{
|
||||
.host_start = authority_start + pos + 1,
|
||||
.host_end = authority_end,
|
||||
.has_user_info = true,
|
||||
};
|
||||
}
|
||||
|
||||
return .{
|
||||
.host_start = authority_start,
|
||||
.host_end = authority_end,
|
||||
.has_user_info = false,
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "URL: isCompleteHTTPUrl" {
|
||||
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
|
||||
@@ -1413,4 +1461,112 @@ test "URL: getHost" {
|
||||
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
|
||||
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
|
||||
try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
|
||||
|
||||
// SECURITY: @ in path must NOT be treated as userinfo separator
|
||||
try testing.expectEqualSlices(u8, "evil.example.com", getHost("http://evil.example.com/@victim.example.com/"));
|
||||
try testing.expectEqualSlices(u8, "evil.example.com", getHost("https://evil.example.com/path/@victim.example.com"));
|
||||
|
||||
// IPv6 addresses
|
||||
try testing.expectEqualSlices(u8, "[::1]:8080", getHost("http://[::1]:8080/path"));
|
||||
try testing.expectEqualSlices(u8, "[::1]", getHost("http://[::1]/path"));
|
||||
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHost("https://[2001:db8::1]/"));
|
||||
}
|
||||
|
||||
test "URL: getHostname" {
|
||||
// Regular hosts
|
||||
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com:8080/path"));
|
||||
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com/path"));
|
||||
|
||||
// IPv6 with port
|
||||
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]:8080/path"));
|
||||
|
||||
// IPv6 without port - must return full bracket notation
|
||||
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]/path"));
|
||||
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/"));
|
||||
}
|
||||
|
||||
test "URL: getPort" {
|
||||
// Regular hosts
|
||||
try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path"));
|
||||
try testing.expectEqualSlices(u8, "", getPort("https://example.com/path"));
|
||||
|
||||
// IPv6 with port
|
||||
try testing.expectEqualSlices(u8, "8080", getPort("http://[::1]:8080/path"));
|
||||
try testing.expectEqualSlices(u8, "3000", getPort("http://[2001:db8::1]:3000/"));
|
||||
|
||||
// IPv6 without port - colons inside brackets must not be treated as port separator
|
||||
try testing.expectEqualSlices(u8, "", getPort("http://[::1]/path"));
|
||||
try testing.expectEqualSlices(u8, "", getPort("https://[2001:db8::1]/"));
|
||||
}
|
||||
|
||||
test "URL: setPathname percent-encodes" {
|
||||
// Use arena allocator to match production usage (setPathname makes intermediate allocations)
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const allocator = arena.allocator();
|
||||
|
||||
// Spaces must be encoded as %20
|
||||
const result1 = try setPathname("http://a/", "c d", allocator);
|
||||
try testing.expectEqualSlices(u8, "http://a/c%20d", result1);
|
||||
|
||||
// Already-encoded sequences must not be double-encoded
|
||||
const result2 = try setPathname("https://example.com/path", "/already%20encoded", allocator);
|
||||
try testing.expectEqualSlices(u8, "https://example.com/already%20encoded", result2);
|
||||
|
||||
// Query and hash must be preserved
|
||||
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
|
||||
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
|
||||
}
|
||||
|
||||
test "URL: getOrigin" {
|
||||
defer testing.reset();
|
||||
|
||||
const Case = struct {
|
||||
url: [:0]const u8,
|
||||
expected: ?[]const u8,
|
||||
};
|
||||
|
||||
const cases = [_]Case{
|
||||
// Basic HTTP/HTTPS origins
|
||||
.{ .url = "http://example.com/path", .expected = "http://example.com" },
|
||||
.{ .url = "https://example.com/path", .expected = "https://example.com" },
|
||||
.{ .url = "https://example.com:8080/path", .expected = "https://example.com:8080" },
|
||||
|
||||
// Default ports should be stripped
|
||||
.{ .url = "http://example.com:80/path", .expected = "http://example.com" },
|
||||
.{ .url = "https://example.com:443/path", .expected = "https://example.com" },
|
||||
|
||||
// User info should be stripped from origin
|
||||
.{ .url = "http://user:pass@example.com/path", .expected = "http://example.com" },
|
||||
.{ .url = "https://user@example.com:8080/path", .expected = "https://example.com:8080" },
|
||||
|
||||
// Non-HTTP schemes return null
|
||||
.{ .url = "ftp://example.com/path", .expected = null },
|
||||
.{ .url = "file:///path/to/file", .expected = null },
|
||||
.{ .url = "about:blank", .expected = null },
|
||||
|
||||
// Query and fragment should not affect origin
|
||||
.{ .url = "https://example.com?query=1", .expected = "https://example.com" },
|
||||
.{ .url = "https://example.com#fragment", .expected = "https://example.com" },
|
||||
.{ .url = "https://example.com/path?q=1#frag", .expected = "https://example.com" },
|
||||
|
||||
// SECURITY: @ in path must NOT be treated as userinfo separator
|
||||
// This would be a Same-Origin Policy bypass if mishandled
|
||||
.{ .url = "http://evil.example.com/@victim.example.com/", .expected = "http://evil.example.com" },
|
||||
.{ .url = "https://evil.example.com/path/@victim.example.com/steal", .expected = "https://evil.example.com" },
|
||||
.{ .url = "http://evil.example.com/@victim.example.com:443/", .expected = "http://evil.example.com" },
|
||||
|
||||
// @ in query/fragment must also not affect origin
|
||||
.{ .url = "https://example.com/path?user=foo@bar.com", .expected = "https://example.com" },
|
||||
.{ .url = "https://example.com/path#user@host", .expected = "https://example.com" },
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
const result = try getOrigin(testing.arena_allocator, case.url);
|
||||
if (case.expected) |expected| {
|
||||
try testing.expectString(expected, result.?);
|
||||
} else {
|
||||
try testing.expectEqual(null, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
137
src/browser/actions.zig
Normal file
137
src/browser/actions.zig
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("../lightpanda.zig");
|
||||
const DOMNode = @import("webapi/Node.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
const Page = @import("Page.zig");
|
||||
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;
|
||||
|
||||
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.clientX = 0,
|
||||
.clientY = 0,
|
||||
}, page);
|
||||
|
||||
page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
|
||||
lp.log.err(.app, "click failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
|
||||
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
input.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill input failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
||||
textarea.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill textarea failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else if (el.is(Element.Html.Select)) |select| {
|
||||
select.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill select failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else {
|
||||
return error.InvalidNodeType;
|
||||
}
|
||||
|
||||
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
|
||||
};
|
||||
|
||||
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
|
||||
if (node) |n| {
|
||||
const el = n.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
if (x) |val| {
|
||||
el.setScrollLeft(val, page) catch |err| {
|
||||
lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
if (y) |val| {
|
||||
el.setScrollTop(val, page) catch |err| {
|
||||
lp.log.err(.app, "setScrollTop failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
|
||||
};
|
||||
} else {
|
||||
page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| {
|
||||
lp.log.err(.app, "scroll failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ pub const InteractivityType = enum {
|
||||
};
|
||||
|
||||
pub const InteractiveElement = struct {
|
||||
backendNodeId: ?u32 = null,
|
||||
node: *Node,
|
||||
tag_name: []const u8,
|
||||
role: ?[]const u8,
|
||||
@@ -55,6 +56,11 @@ pub const InteractiveElement = struct {
|
||||
pub fn jsonStringify(self: *const InteractiveElement, 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);
|
||||
|
||||
@@ -123,6 +129,15 @@ pub const InteractiveElement = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Populate backendNodeId on each interactive element by registering
|
||||
/// their nodes in the given registry. Works with both CDP and MCP registries.
|
||||
pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void {
|
||||
for (elements) |*el| {
|
||||
const registered = try registry.register(el.node);
|
||||
el.backendNodeId = registered.id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all interactive elements under `root`.
|
||||
pub fn collectInteractiveElements(
|
||||
root: *Node,
|
||||
@@ -133,6 +148,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 +163,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 +177,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 +227,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,
|
||||
@@ -253,17 +274,52 @@ pub fn classifyInteractivity(
|
||||
return null;
|
||||
}
|
||||
|
||||
fn isInteractiveRole(role: []const u8) bool {
|
||||
const interactive_roles = [_][]const u8{
|
||||
"button", "link", "tab", "menuitem",
|
||||
"menuitemcheckbox", "menuitemradio", "switch", "checkbox",
|
||||
"radio", "slider", "spinbutton", "searchbox",
|
||||
"combobox", "option", "treeitem",
|
||||
};
|
||||
for (interactive_roles) |r| {
|
||||
if (std.ascii.eqlIgnoreCase(role, r)) return true;
|
||||
}
|
||||
return false;
|
||||
pub fn isInteractiveRole(role: []const u8) bool {
|
||||
const MAX_LEN = "menuitemcheckbox".len;
|
||||
if (role.len > MAX_LEN) return false;
|
||||
var buf: [MAX_LEN]u8 = undefined;
|
||||
const lowered = std.ascii.lowerString(&buf, role);
|
||||
const interactive_roles = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "button", {} },
|
||||
.{ "checkbox", {} },
|
||||
.{ "combobox", {} },
|
||||
.{ "iframe", {} },
|
||||
.{ "link", {} },
|
||||
.{ "listbox", {} },
|
||||
.{ "menuitem", {} },
|
||||
.{ "menuitemcheckbox", {} },
|
||||
.{ "menuitemradio", {} },
|
||||
.{ "option", {} },
|
||||
.{ "radio", {} },
|
||||
.{ "searchbox", {} },
|
||||
.{ "slider", {} },
|
||||
.{ "spinbutton", {} },
|
||||
.{ "switch", {} },
|
||||
.{ "tab", {} },
|
||||
.{ "textbox", {} },
|
||||
.{ "treeitem", {} },
|
||||
});
|
||||
return interactive_roles.has(lowered);
|
||||
}
|
||||
|
||||
pub fn isContentRole(role: []const u8) bool {
|
||||
const MAX_LEN = "columnheader".len;
|
||||
if (role.len > MAX_LEN) return false;
|
||||
var buf: [MAX_LEN]u8 = undefined;
|
||||
const lowered = std.ascii.lowerString(&buf, role);
|
||||
const content_roles = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "article", {} },
|
||||
.{ "cell", {} },
|
||||
.{ "columnheader", {} },
|
||||
.{ "gridcell", {} },
|
||||
.{ "heading", {} },
|
||||
.{ "listitem", {} },
|
||||
.{ "main", {} },
|
||||
.{ "navigation", {} },
|
||||
.{ "region", {} },
|
||||
.{ "rowheader", {} },
|
||||
});
|
||||
return content_roles.has(lowered);
|
||||
}
|
||||
|
||||
fn getRole(el: *Element) ?[]const u8 {
|
||||
@@ -371,36 +427,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| {
|
||||
@@ -519,6 +545,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);
|
||||
|
||||
@@ -40,8 +40,8 @@ prev_context: *Context,
|
||||
|
||||
// Takes the raw v8 isolate and extracts the context from it.
|
||||
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
|
||||
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
|
||||
initWithContext(self, Context.fromC(v8_context), v8_context);
|
||||
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
|
||||
initWithContext(self, ctx, v8_context);
|
||||
}
|
||||
|
||||
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
|
||||
@@ -128,7 +128,7 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
|
||||
const new_this_handle = info.getThis();
|
||||
var this = js.Object{ .local = local, .handle = new_this_handle };
|
||||
if (@typeInfo(ReturnType) == .error_union) {
|
||||
const non_error_res = res catch |err| return err;
|
||||
const non_error_res = try res;
|
||||
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
|
||||
} else {
|
||||
this = try local.mapZigInstanceToJs(new_this_handle, res);
|
||||
@@ -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,
|
||||
@@ -537,9 +538,7 @@ pub const Function = struct {
|
||||
|
||||
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
|
||||
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
|
||||
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
|
||||
|
||||
const ctx = Context.fromC(v8_context);
|
||||
const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
|
||||
const info = FunctionCallbackInfo{ .handle = info_handle };
|
||||
|
||||
var hs: js.HandleScope = undefined;
|
||||
|
||||
@@ -22,7 +22,6 @@ const log = @import("../../log.zig");
|
||||
|
||||
const js = @import("js.zig");
|
||||
const Env = @import("Env.zig");
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Scheduler = @import("Scheduler.zig");
|
||||
|
||||
@@ -63,7 +62,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 +80,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,
|
||||
@@ -119,12 +130,22 @@ const ModuleEntry = struct {
|
||||
resolver_promise: ?js.Promise.Global = null,
|
||||
};
|
||||
|
||||
pub fn fromC(c_context: *const v8.Context) *Context {
|
||||
pub fn fromC(c_context: *const v8.Context) ?*Context {
|
||||
return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
|
||||
}
|
||||
|
||||
pub fn fromIsolate(isolate: js.Isolate) *Context {
|
||||
return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?);
|
||||
/// Returns the Context and v8::Context for the given isolate.
|
||||
/// If the current context is from a destroyed Context (e.g., navigated-away iframe),
|
||||
/// falls back to the incumbent context (the calling context).
|
||||
pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } {
|
||||
const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?;
|
||||
if (fromC(v8_context)) |ctx| {
|
||||
return .{ ctx, v8_context };
|
||||
}
|
||||
// The current context's Context struct has been freed (e.g., iframe navigated away).
|
||||
// Fall back to the incumbent context (the calling context).
|
||||
const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?;
|
||||
return .{ fromC(v8_incumbent).?, v8_incumbent };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Context) void {
|
||||
@@ -155,6 +176,11 @@ pub fn deinit(self: *Context) void {
|
||||
|
||||
self.session.releaseOrigin(self.origin);
|
||||
|
||||
// Clear the embedder data so that if V8 keeps this context alive
|
||||
// (because objects created in it are still referenced), we don't
|
||||
// have a dangling pointer to our freed Context struct.
|
||||
v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null);
|
||||
|
||||
v8.v8__Global__Reset(&self.handle);
|
||||
env.isolate.notifyContextDisposed();
|
||||
// There can be other tasks associated with this context that we need to
|
||||
@@ -167,13 +193,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||
const env = self.env;
|
||||
const isolate = env.isolate;
|
||||
|
||||
const origin = try self.session.getOrCreateOrigin(key);
|
||||
errdefer self.session.releaseOrigin(origin);
|
||||
|
||||
try self.origin.transferTo(origin);
|
||||
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
|
||||
self.origin.deinit(env.app);
|
||||
|
||||
const origin = try self.session.getOrCreateOrigin(key);
|
||||
|
||||
self.session.releaseOrigin(self.origin);
|
||||
self.origin = origin;
|
||||
|
||||
{
|
||||
@@ -189,26 +213,28 @@ 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 fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const resolved = js.Local.resolveValue(obj);
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
}
|
||||
return;
|
||||
};
|
||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
|
||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
|
||||
}
|
||||
|
||||
pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const resolved = js.Local.resolveValue(obj);
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -216,11 +242,12 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
|
||||
return;
|
||||
};
|
||||
v8.v8__Global__ClearWeak(&fc.global);
|
||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
|
||||
v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
|
||||
}
|
||||
|
||||
pub fn strongRef(self: *Context, obj: anytype) void {
|
||||
const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
|
||||
const resolved = js.Local.resolveValue(obj);
|
||||
const fc = self.identity.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
|
||||
if (comptime IS_DEBUG) {
|
||||
// should not be possible
|
||||
std.debug.assert(false);
|
||||
@@ -230,6 +257,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;
|
||||
@@ -252,6 +321,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
|
||||
return l.toLocal(global);
|
||||
}
|
||||
|
||||
pub fn getIncumbent(self: *Context) *Page {
|
||||
return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
|
||||
}
|
||||
|
||||
pub fn stringToPersistedFunction(
|
||||
self: *Context,
|
||||
function_body: []const u8,
|
||||
@@ -303,15 +376,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
|
||||
}
|
||||
|
||||
const owned_url = try arena.dupeZ(u8, url);
|
||||
if (cacheable and !gop.found_existing) {
|
||||
gop.key_ptr.* = owned_url;
|
||||
}
|
||||
const m = try compileModule(local, src, owned_url);
|
||||
|
||||
if (cacheable) {
|
||||
// compileModule is synchronous - nothing can modify the cache during compilation
|
||||
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
|
||||
gop.value_ptr.module = try m.persist();
|
||||
if (!gop.found_existing) {
|
||||
gop.key_ptr.* = owned_url;
|
||||
}
|
||||
}
|
||||
|
||||
break :blk .{ m, owned_url };
|
||||
@@ -473,7 +546,7 @@ fn resolveModuleCallback(
|
||||
) callconv(.c) ?*const v8.Module {
|
||||
_ = import_attributes;
|
||||
|
||||
const self = fromC(c_context.?);
|
||||
const self = fromC(c_context.?).?;
|
||||
const local = js.Local{
|
||||
.ctx = self,
|
||||
.handle = c_context.?,
|
||||
@@ -506,7 +579,7 @@ pub fn dynamicModuleCallback(
|
||||
_ = host_defined_options;
|
||||
_ = import_attrs;
|
||||
|
||||
const self = fromC(c_context.?);
|
||||
const self = fromC(c_context.?).?;
|
||||
const local = js.Local{
|
||||
.ctx = self,
|
||||
.handle = c_context.?,
|
||||
@@ -524,13 +597,13 @@ pub fn dynamicModuleCallback(
|
||||
|
||||
break :blk js.String.toSliceZ(.{ .local = &local, .handle = resource_name.? }) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback1" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
};
|
||||
|
||||
const specifier = js.String.toSliceZ(.{ .local = &local, .handle = v8_specifier.? }) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback2" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
|
||||
const normalized_specifier = self.script_manager.?.resolveSpecifier(
|
||||
@@ -539,21 +612,21 @@ pub fn dynamicModuleCallback(
|
||||
specifier,
|
||||
) catch |err| {
|
||||
log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" });
|
||||
return @constCast((local.rejectPromise("Out of memory") catch return null).handle);
|
||||
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
|
||||
};
|
||||
|
||||
const promise = self._dynamicModuleCallback(normalized_specifier, resource, &local) catch |err| blk: {
|
||||
log.err(.js, "dynamic module callback", .{
|
||||
.err = err,
|
||||
});
|
||||
break :blk local.rejectPromise("Failed to load module") catch return null;
|
||||
break :blk local.rejectPromise(.{ .generic_error = "Out of memory" });
|
||||
};
|
||||
return @constCast(promise.handle);
|
||||
}
|
||||
|
||||
pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void {
|
||||
// @HandleScope implement this without a fat context/local..
|
||||
const self = fromC(c_context.?);
|
||||
const self = fromC(c_context.?).?;
|
||||
var local = js.Local{
|
||||
.ctx = self,
|
||||
.handle = c_context.?,
|
||||
|
||||
@@ -26,7 +26,6 @@ const App = @import("../../App.zig");
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const bridge = @import("bridge.zig");
|
||||
const Origin = @import("Origin.zig");
|
||||
const Context = @import("Context.zig");
|
||||
const Isolate = @import("Isolate.zig");
|
||||
const Platform = @import("Platform.zig");
|
||||
@@ -254,8 +253,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 +306,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
|
||||
@@ -382,8 +398,7 @@ pub fn runMicrotasks(self: *Env) void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runMacrotasks(self: *Env) !?u64 {
|
||||
var ms_to_next_task: ?u64 = null;
|
||||
pub fn runMacrotasks(self: *Env) !void {
|
||||
for (self.contexts[0..self.context_count]) |ctx| {
|
||||
if (comptime builtin.is_test == false) {
|
||||
// I hate this comptime check as much as you do. But we have tests
|
||||
@@ -398,13 +413,17 @@ pub fn runMacrotasks(self: *Env) !?u64 {
|
||||
var hs: js.HandleScope = undefined;
|
||||
const entered = ctx.enter(&hs);
|
||||
defer entered.exit();
|
||||
|
||||
const ms = (try ctx.scheduler.run()) orelse continue;
|
||||
if (ms_to_next_task == null or ms < ms_to_next_task.?) {
|
||||
ms_to_next_task = ms;
|
||||
}
|
||||
try ctx.scheduler.run();
|
||||
}
|
||||
return ms_to_next_task;
|
||||
}
|
||||
|
||||
pub fn msToNextMacrotask(self: *Env) ?u64 {
|
||||
var next_task: u64 = std.math.maxInt(u64);
|
||||
for (self.contexts[0..self.context_count]) |ctx| {
|
||||
const candidate = ctx.scheduler.msToNextHigh() orelse continue;
|
||||
next_task = @min(candidate, next_task);
|
||||
}
|
||||
return if (next_task == std.math.maxInt(u64)) null else next_task;
|
||||
}
|
||||
|
||||
pub fn pumpMessageLoop(self: *const Env) void {
|
||||
@@ -492,20 +511,25 @@ pub fn terminate(self: *const Env) void {
|
||||
}
|
||||
|
||||
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
|
||||
const promise_event = v8.v8__PromiseRejectMessage__GetEvent(&message_handle);
|
||||
if (promise_event != v8.kPromiseRejectWithNoHandler and promise_event != v8.kPromiseHandlerAddedAfterReject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
|
||||
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
|
||||
const js_isolate = js.Isolate{ .handle = v8_isolate };
|
||||
const ctx = Context.fromIsolate(js_isolate);
|
||||
const isolate = js.Isolate{ .handle = v8_isolate };
|
||||
const ctx, const v8_context = Context.fromIsolate(isolate);
|
||||
|
||||
const local = js.Local{
|
||||
.ctx = ctx,
|
||||
.isolate = js_isolate,
|
||||
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
|
||||
.isolate = isolate,
|
||||
.handle = v8_context,
|
||||
.call_arena = ctx.call_arena,
|
||||
};
|
||||
|
||||
const page = ctx.page;
|
||||
page.window.unhandledPromiseRejection(.{
|
||||
page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
|
||||
.local = &local,
|
||||
.handle = &message_handle,
|
||||
}, page) catch |err| {
|
||||
|
||||
@@ -210,10 +210,10 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
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 +237,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 +257,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
75
src/browser/js/Identity.zig
Normal file
75
src/browser/js/Identity.zig
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Identity manages the mapping between Zig instances and their v8::Object wrappers.
|
||||
// This provides object identity semantics - the same Zig instance always maps to
|
||||
// the same JS object within a given Identity scope.
|
||||
//
|
||||
// Main world contexts share a single Identity (on Session), ensuring that
|
||||
// `window.top.document === top's document` works across same-origin frames.
|
||||
//
|
||||
// Isolated worlds (CDP inspector) have their own Identity, ensuring their
|
||||
// v8::Global wrappers don't leak into the main world.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const v8 = js.v8;
|
||||
|
||||
const Identity = @This();
|
||||
|
||||
// Maps Zig instance pointers to their v8::Global(Object) wrappers.
|
||||
identity_map: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
|
||||
// Tracked global v8 objects that need to be released on cleanup.
|
||||
globals: std.ArrayList(v8.Global) = .empty,
|
||||
|
||||
// Temporary v8 globals that can be released early. Key is global.data_ptr.
|
||||
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
|
||||
// Finalizer callbacks for weak references. Key is @intFromPtr of the Zig instance.
|
||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty,
|
||||
|
||||
pub fn deinit(self: *Identity) void {
|
||||
{
|
||||
var it = self.finalizer_callbacks.valueIterator();
|
||||
while (it.next()) |finalizer| {
|
||||
finalizer.*.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.identity_map.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
|
||||
for (self.globals.items) |*global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
|
||||
{
|
||||
var it = self.temps.valueIterator();
|
||||
while (it.next()) |global| {
|
||||
v8.v8__Global__Reset(global);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).?;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
const log = @import("../../log.zig");
|
||||
const string = @import("../../string.zig");
|
||||
@@ -33,7 +32,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const v8 = js.v8;
|
||||
const CallOpts = Caller.CallOpts;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// Where js.Context has a lifetime tied to the page, and holds the
|
||||
// v8::Global<v8::Context>, this has a much shorter lifetime and holds a
|
||||
@@ -202,20 +200,20 @@ pub fn compileAndRun(self: *const Local, src: []const u8, name: ?[]const u8) !js
|
||||
// we can just grab it from the identity_map)
|
||||
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 +242,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 +274,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 +1204,15 @@ pub fn stackTrace(self: *const Local) !?[]const u8 {
|
||||
}
|
||||
|
||||
// == Promise Helpers ==
|
||||
pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
|
||||
pub fn rejectPromise(self: *const Local, err: js.PromiseResolver.RejectError) js.Promise {
|
||||
var resolver = js.PromiseResolver.init(self);
|
||||
resolver.reject("Local.rejectPromise", value);
|
||||
resolver.rejectError("Local.rejectPromise", err);
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
|
||||
var resolver = js.PromiseResolver.init(self);
|
||||
resolver.rejectError("Local.rejectPromise", value);
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,38 +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,
|
||||
|
||||
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;
|
||||
@@ -86,168 +62,12 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
|
||||
.rc = 1,
|
||||
.arena = arena,
|
||||
.key = owned_key,
|
||||
.globals = .empty,
|
||||
.temps = .empty,
|
||||
.security_token = token_global,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Origin, app: *App) void {
|
||||
// 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 transferTo(self: *Origin, dest: *Origin) !void {
|
||||
const arena = dest.arena;
|
||||
|
||||
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
|
||||
for (self.globals.items) |obj| {
|
||||
dest.globals.appendAssumeCapacity(obj);
|
||||
}
|
||||
self.globals.clearRetainingCapacity();
|
||||
|
||||
{
|
||||
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
|
||||
var it = self.temps.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.temps.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
|
||||
var it = self.finalizer_callbacks.iterator();
|
||||
while (it.next()) |kv| {
|
||||
kv.value_ptr.*.origin = dest;
|
||||
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.finalizer_callbacks.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
|
||||
var it = self.identity_map.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.identity_map.clearRetainingCapacity();
|
||||
}
|
||||
}
|
||||
|
||||
// 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,6 +16,7 @@
|
||||
// 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;
|
||||
|
||||
@@ -63,10 +64,10 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
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 +81,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 +97,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
|
||||
const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const DOMException = @import("../webapi/DOMException.zig");
|
||||
|
||||
const PromiseResolver = @This();
|
||||
|
||||
local: *const js.Local,
|
||||
@@ -63,6 +66,43 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
|
||||
};
|
||||
}
|
||||
|
||||
pub const RejectError = union(enum) {
|
||||
/// Not to be confused with `DOMException`; this is bare `Error`.
|
||||
generic_error: []const u8,
|
||||
range_error: []const u8,
|
||||
reference_error: []const u8,
|
||||
syntax_error: []const u8,
|
||||
type_error: []const u8,
|
||||
/// DOM exceptions are unknown to V8, belongs to web standards.
|
||||
dom_exception: struct { err: anyerror },
|
||||
};
|
||||
|
||||
/// Rejects the promise w/ an error object.
|
||||
pub fn rejectError(
|
||||
self: PromiseResolver,
|
||||
comptime source: []const u8,
|
||||
err: RejectError,
|
||||
) void {
|
||||
const handle = switch (err) {
|
||||
.generic_error => |msg| self.local.isolate.createError(msg),
|
||||
.range_error => |msg| self.local.isolate.createRangeError(msg),
|
||||
.reference_error => |msg| self.local.isolate.createReferenceError(msg),
|
||||
.syntax_error => |msg| self.local.isolate.createSyntaxError(msg),
|
||||
.type_error => |msg| self.local.isolate.createTypeError(msg),
|
||||
// "Exceptional".
|
||||
.dom_exception => |exception| {
|
||||
self._reject(DOMException.fromError(exception.err) orelse unreachable) catch |reject_err| {
|
||||
log.err(.bug, "rejectDomException", .{ .source = source, .err = reject_err, .persistent = false });
|
||||
};
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
|
||||
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
|
||||
};
|
||||
}
|
||||
|
||||
fn _reject(self: PromiseResolver, value: anytype) !void {
|
||||
const local = self.local;
|
||||
const js_val = try local.zigValueToJs(value, .{});
|
||||
|
||||
@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
|
||||
});
|
||||
}
|
||||
|
||||
pub fn run(self: *Scheduler) !?u64 {
|
||||
_ = try self.runQueue(&self.low_priority);
|
||||
return self.runQueue(&self.high_priority);
|
||||
pub fn run(self: *Scheduler) !void {
|
||||
const now = milliTimestamp(.monotonic);
|
||||
try self.runQueue(&self.low_priority, now);
|
||||
try self.runQueue(&self.high_priority, now);
|
||||
}
|
||||
|
||||
pub fn hasReadyTasks(self: *Scheduler) bool {
|
||||
@@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool {
|
||||
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
|
||||
}
|
||||
|
||||
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
|
||||
if (queue.count() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn msToNextHigh(self: *Scheduler) ?u64 {
|
||||
const task = self.high_priority.peek() orelse return null;
|
||||
const now = milliTimestamp(.monotonic);
|
||||
if (task.run_at <= now) {
|
||||
return 0;
|
||||
}
|
||||
return @intCast(task.run_at - now);
|
||||
}
|
||||
|
||||
fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void {
|
||||
if (queue.count() == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (queue.peek()) |*task_| {
|
||||
if (task_.run_at > now) {
|
||||
return @intCast(task_.run_at - now);
|
||||
return;
|
||||
}
|
||||
var task = queue.remove();
|
||||
if (comptime IS_DEBUG) {
|
||||
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
|
||||
try self.low_priority.add(task);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
|
||||
|
||||
@@ -25,7 +25,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const v8 = js.v8;
|
||||
const JsApis = bridge.JsApis;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Snapshot = @This();
|
||||
|
||||
@@ -137,7 +136,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 +418,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 +428,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 +481,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 +501,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 +579,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);
|
||||
}
|
||||
|
||||
@@ -300,10 +300,10 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
|
||||
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
|
||||
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 +361,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 +381,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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,16 +18,12 @@
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("../../log.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
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 +113,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 +195,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 +204,7 @@ pub const Accessor = struct {
|
||||
var accessor = Accessor{
|
||||
.cache = opts.cache,
|
||||
.static = opts.static,
|
||||
.deletable = opts.deletable,
|
||||
};
|
||||
|
||||
if (@typeInfo(@TypeOf(getter)) != .null) {
|
||||
@@ -725,6 +722,8 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/collections.zig"),
|
||||
@import("../webapi/Console.zig"),
|
||||
@import("../webapi/Crypto.zig"),
|
||||
@import("../webapi/Permissions.zig"),
|
||||
@import("../webapi/StorageManager.zig"),
|
||||
@import("../webapi/CSS.zig"),
|
||||
@import("../webapi/css/CSSRule.zig"),
|
||||
@import("../webapi/css/CSSRuleList.zig"),
|
||||
@@ -848,7 +847,10 @@ pub const JsApis = flattenTypes(&.{
|
||||
@import("../webapi/event/FocusEvent.zig"),
|
||||
@import("../webapi/event/WheelEvent.zig"),
|
||||
@import("../webapi/event/TextEvent.zig"),
|
||||
@import("../webapi/event/InputEvent.zig"),
|
||||
@import("../webapi/event/PromiseRejectionEvent.zig"),
|
||||
@import("../webapi/event/SubmitEvent.zig"),
|
||||
@import("../webapi/event/FormDataEvent.zig"),
|
||||
@import("../webapi/MessageChannel.zig"),
|
||||
@import("../webapi/MessagePort.zig"),
|
||||
@import("../webapi/media/MediaError.zig"),
|
||||
@@ -898,6 +900,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");
|
||||
|
||||
54
src/browser/links.zig
Normal file
54
src/browser/links.zig
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Page = @import("Page.zig");
|
||||
const Selector = @import("webapi/selector/Selector.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Collect all links (href attributes from anchor tags) under `root`.
|
||||
/// Returns a slice of strings allocated with `arena`.
|
||||
pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 {
|
||||
var links: std.ArrayList([]const u8) = .empty;
|
||||
|
||||
if (Selector.querySelectorAll(root, "a[href]", page)) |list| {
|
||||
defer list.deinit(page._session);
|
||||
|
||||
for (list._nodes) |node| {
|
||||
if (node.is(Element.Html.Anchor)) |anchor| {
|
||||
const href = anchor.getHref(page) catch |err| {
|
||||
@import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err });
|
||||
continue;
|
||||
};
|
||||
|
||||
if (href.len > 0) {
|
||||
try links.append(arena, href);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else |err| {
|
||||
@import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err });
|
||||
return err;
|
||||
}
|
||||
|
||||
return links.items;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const std = @import("std");
|
||||
const Page = @import("Page.zig");
|
||||
const URL = @import("URL.zig");
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
const CData = @import("webapi/CData.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const isAllWhitespace = @import("../string.zig").isAllWhitespace;
|
||||
@@ -124,352 +123,362 @@ fn hasVisibleContent(root: *Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
|
||||
if (!state.last_char_was_newline) {
|
||||
try writer.writeByte('\n');
|
||||
state.last_char_was_newline = true;
|
||||
const Context = struct {
|
||||
state: State,
|
||||
writer: *std.Io.Writer,
|
||||
page: *Page,
|
||||
|
||||
fn ensureNewline(self: *Context) !void {
|
||||
if (!self.state.last_char_was_newline) {
|
||||
try self.writer.writeByte('\n');
|
||||
self.state.last_char_was_newline = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self: *Context, node: *Node) error{WriteFailed}!void {
|
||||
switch (node._type) {
|
||||
.document, .document_fragment => {
|
||||
try self.renderChildren(node);
|
||||
},
|
||||
.element => |el| {
|
||||
try self.renderElement(el);
|
||||
},
|
||||
.cdata => |cd| {
|
||||
if (node.is(Node.CData.Text)) |_| {
|
||||
var text = cd.getData().str();
|
||||
if (self.state.pre_node) |pre| {
|
||||
if (node.parentNode() == pre and node.nextSibling() == null) {
|
||||
text = std.mem.trimRight(u8, text, " \t\r\n");
|
||||
}
|
||||
}
|
||||
try self.renderText(text);
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChildren(self: *Context, parent: *Node) !void {
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try self.render(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderElement(self: *Context, el: *Element) !void {
|
||||
const tag = el.getTag();
|
||||
|
||||
if (!isVisibleElement(el)) return;
|
||||
|
||||
// --- Opening Tag Logic ---
|
||||
|
||||
// Ensure block elements start on a new line (double newline for paragraphs etc)
|
||||
if (tag.isBlock() and !self.state.in_table) {
|
||||
try self.ensureNewline();
|
||||
if (shouldAddSpacing(tag)) {
|
||||
try self.writer.writeByte('\n');
|
||||
}
|
||||
} else if (tag == .li or tag == .tr) {
|
||||
try self.ensureNewline();
|
||||
}
|
||||
|
||||
// Prefixes
|
||||
switch (tag) {
|
||||
.h1 => try self.writer.writeAll("# "),
|
||||
.h2 => try self.writer.writeAll("## "),
|
||||
.h3 => try self.writer.writeAll("### "),
|
||||
.h4 => try self.writer.writeAll("#### "),
|
||||
.h5 => try self.writer.writeAll("##### "),
|
||||
.h6 => try self.writer.writeAll("###### "),
|
||||
.ul => {
|
||||
if (self.state.list_depth < self.state.list_stack.len) {
|
||||
self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
|
||||
self.state.list_depth += 1;
|
||||
}
|
||||
},
|
||||
.ol => {
|
||||
if (self.state.list_depth < self.state.list_stack.len) {
|
||||
self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
|
||||
self.state.list_depth += 1;
|
||||
}
|
||||
},
|
||||
.li => {
|
||||
const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
|
||||
for (0..indent) |_| try self.writer.writeAll(" ");
|
||||
|
||||
if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
|
||||
const current_list = &self.state.list_stack[self.state.list_depth - 1];
|
||||
try self.writer.print("{d}. ", .{current_list.index});
|
||||
current_list.index += 1;
|
||||
} else {
|
||||
try self.writer.writeAll("- ");
|
||||
}
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.table => {
|
||||
self.state.in_table = true;
|
||||
self.state.table_row_index = 0;
|
||||
self.state.table_col_count = 0;
|
||||
},
|
||||
.tr => {
|
||||
self.state.table_col_count = 0;
|
||||
try self.writer.writeByte('|');
|
||||
},
|
||||
.td, .th => {
|
||||
// Note: leading pipe handled by previous cell closing or tr opening
|
||||
self.state.last_char_was_newline = false;
|
||||
try self.writer.writeByte(' ');
|
||||
},
|
||||
.blockquote => {
|
||||
try self.writer.writeAll("> ");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.pre => {
|
||||
try self.writer.writeAll("```\n");
|
||||
self.state.pre_node = el.asNode();
|
||||
self.state.last_char_was_newline = true;
|
||||
},
|
||||
.code => {
|
||||
if (self.state.pre_node == null) {
|
||||
try self.writer.writeByte('`');
|
||||
self.state.in_code = true;
|
||||
self.state.last_char_was_newline = false;
|
||||
}
|
||||
},
|
||||
.b, .strong => {
|
||||
try self.writer.writeAll("**");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.i, .em => {
|
||||
try self.writer.writeAll("*");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.s, .del => {
|
||||
try self.writer.writeAll("~~");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.hr => {
|
||||
try self.writer.writeAll("---\n");
|
||||
self.state.last_char_was_newline = true;
|
||||
return;
|
||||
},
|
||||
.br => {
|
||||
if (self.state.in_table) {
|
||||
try self.writer.writeByte(' ');
|
||||
} else {
|
||||
try self.writer.writeByte('\n');
|
||||
self.state.last_char_was_newline = true;
|
||||
}
|
||||
return;
|
||||
},
|
||||
.img => {
|
||||
try self.writer.writeAll(";
|
||||
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||
const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src;
|
||||
try self.writer.writeAll(absolute_src);
|
||||
}
|
||||
try self.writer.writeAll(")");
|
||||
self.state.last_char_was_newline = false;
|
||||
return;
|
||||
},
|
||||
.anchor => {
|
||||
const has_content = hasVisibleContent(el.asNode());
|
||||
const label = getAnchorLabel(el);
|
||||
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
|
||||
|
||||
if (!has_content and label == null and href_raw == null) return;
|
||||
|
||||
const has_block = hasBlockDescendant(el.asNode());
|
||||
const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
|
||||
|
||||
if (has_block) {
|
||||
try self.renderChildren(el.asNode());
|
||||
if (href) |h| {
|
||||
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||
try self.writer.writeAll("([](");
|
||||
try self.writer.writeAll(h);
|
||||
try self.writer.writeAll("))\n");
|
||||
self.state.last_char_was_newline = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStandaloneAnchor(el)) {
|
||||
if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
|
||||
try self.writer.writeByte('[');
|
||||
if (has_content) {
|
||||
try self.renderChildren(el.asNode());
|
||||
} else {
|
||||
try self.writer.writeAll(label orelse "");
|
||||
}
|
||||
try self.writer.writeAll("](");
|
||||
if (href) |h| {
|
||||
try self.writer.writeAll(h);
|
||||
}
|
||||
try self.writer.writeAll(")\n");
|
||||
self.state.last_char_was_newline = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try self.writer.writeByte('[');
|
||||
if (has_content) {
|
||||
try self.renderChildren(el.asNode());
|
||||
} else {
|
||||
try self.writer.writeAll(label orelse "");
|
||||
}
|
||||
try self.writer.writeAll("](");
|
||||
if (href) |h| {
|
||||
try self.writer.writeAll(h);
|
||||
}
|
||||
try self.writer.writeByte(')');
|
||||
self.state.last_char_was_newline = false;
|
||||
return;
|
||||
},
|
||||
.input => {
|
||||
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
|
||||
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
|
||||
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
|
||||
try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
|
||||
self.state.last_char_was_newline = false;
|
||||
}
|
||||
return;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// --- Render Children ---
|
||||
try self.renderChildren(el.asNode());
|
||||
|
||||
// --- Closing Tag Logic ---
|
||||
|
||||
// Suffixes
|
||||
switch (tag) {
|
||||
.pre => {
|
||||
if (!self.state.last_char_was_newline) {
|
||||
try self.writer.writeByte('\n');
|
||||
}
|
||||
try self.writer.writeAll("```\n");
|
||||
self.state.pre_node = null;
|
||||
self.state.last_char_was_newline = true;
|
||||
},
|
||||
.code => {
|
||||
if (self.state.pre_node == null) {
|
||||
try self.writer.writeByte('`');
|
||||
self.state.in_code = false;
|
||||
self.state.last_char_was_newline = false;
|
||||
}
|
||||
},
|
||||
.b, .strong => {
|
||||
try self.writer.writeAll("**");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.i, .em => {
|
||||
try self.writer.writeAll("*");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.s, .del => {
|
||||
try self.writer.writeAll("~~");
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
.blockquote => {},
|
||||
.ul, .ol => {
|
||||
if (self.state.list_depth > 0) self.state.list_depth -= 1;
|
||||
},
|
||||
.table => {
|
||||
self.state.in_table = false;
|
||||
},
|
||||
.tr => {
|
||||
try self.writer.writeByte('\n');
|
||||
if (self.state.table_row_index == 0) {
|
||||
try self.writer.writeByte('|');
|
||||
for (0..self.state.table_col_count) |_| {
|
||||
try self.writer.writeAll("---|");
|
||||
}
|
||||
try self.writer.writeByte('\n');
|
||||
}
|
||||
self.state.table_row_index += 1;
|
||||
self.state.last_char_was_newline = true;
|
||||
},
|
||||
.td, .th => {
|
||||
try self.writer.writeAll(" |");
|
||||
self.state.table_col_count += 1;
|
||||
self.state.last_char_was_newline = false;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// Post-block newlines
|
||||
if (tag.isBlock() and !self.state.in_table) {
|
||||
try self.ensureNewline();
|
||||
}
|
||||
}
|
||||
|
||||
fn renderText(self: *Context, text: []const u8) !void {
|
||||
if (text.len == 0) return;
|
||||
|
||||
if (self.state.pre_node) |_| {
|
||||
try self.writer.writeAll(text);
|
||||
self.state.last_char_was_newline = text[text.len - 1] == '\n';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for pure whitespace
|
||||
if (isAllWhitespace(text)) {
|
||||
if (!self.state.last_char_was_newline) {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse whitespace
|
||||
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
|
||||
var first = true;
|
||||
while (it.next()) |word| {
|
||||
if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
|
||||
try self.escape(word);
|
||||
self.state.last_char_was_newline = false;
|
||||
first = false;
|
||||
}
|
||||
|
||||
// Handle trailing whitespace from the original text
|
||||
if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
}
|
||||
|
||||
fn escape(self: *Context, text: []const u8) !void {
|
||||
for (text) |c| {
|
||||
switch (c) {
|
||||
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
|
||||
try self.writer.writeByte('\\');
|
||||
try self.writer.writeByte(c);
|
||||
},
|
||||
else => try self.writer.writeByte(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||
_ = opts;
|
||||
var state = State{};
|
||||
try render(node, &state, writer, page);
|
||||
if (!state.last_char_was_newline) {
|
||||
var ctx: Context = .{
|
||||
.state = .{},
|
||||
.writer = writer,
|
||||
.page = page,
|
||||
};
|
||||
try ctx.render(node);
|
||||
if (!ctx.state.last_char_was_newline) {
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
||||
switch (node._type) {
|
||||
.document, .document_fragment => {
|
||||
try renderChildren(node, state, writer, page);
|
||||
},
|
||||
.element => |el| {
|
||||
try renderElement(el, state, writer, page);
|
||||
},
|
||||
.cdata => |cd| {
|
||||
if (node.is(Node.CData.Text)) |_| {
|
||||
var text = cd.getData().str();
|
||||
if (state.pre_node) |pre| {
|
||||
if (node.parentNode() == pre and node.nextSibling() == null) {
|
||||
text = std.mem.trimRight(u8, text, " \t\r\n");
|
||||
}
|
||||
}
|
||||
try renderText(text, state, writer);
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try render(child, state, writer, page);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
|
||||
const tag = el.getTag();
|
||||
|
||||
if (!isVisibleElement(el)) return;
|
||||
|
||||
// --- Opening Tag Logic ---
|
||||
|
||||
// Ensure block elements start on a new line (double newline for paragraphs etc)
|
||||
if (tag.isBlock() and !state.in_table) {
|
||||
try ensureNewline(state, writer);
|
||||
if (shouldAddSpacing(tag)) {
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
} else if (tag == .li or tag == .tr) {
|
||||
try ensureNewline(state, writer);
|
||||
}
|
||||
|
||||
// Prefixes
|
||||
switch (tag) {
|
||||
.h1 => try writer.writeAll("# "),
|
||||
.h2 => try writer.writeAll("## "),
|
||||
.h3 => try writer.writeAll("### "),
|
||||
.h4 => try writer.writeAll("#### "),
|
||||
.h5 => try writer.writeAll("##### "),
|
||||
.h6 => try writer.writeAll("###### "),
|
||||
.ul => {
|
||||
if (state.list_depth < state.list_stack.len) {
|
||||
state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
|
||||
state.list_depth += 1;
|
||||
}
|
||||
},
|
||||
.ol => {
|
||||
if (state.list_depth < state.list_stack.len) {
|
||||
state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
|
||||
state.list_depth += 1;
|
||||
}
|
||||
},
|
||||
.li => {
|
||||
const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
|
||||
for (0..indent) |_| try writer.writeAll(" ");
|
||||
|
||||
if (state.list_depth > 0 and state.list_stack[state.list_depth - 1].type == .ordered) {
|
||||
const current_list = &state.list_stack[state.list_depth - 1];
|
||||
try writer.print("{d}. ", .{current_list.index});
|
||||
current_list.index += 1;
|
||||
} else {
|
||||
try writer.writeAll("- ");
|
||||
}
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.table => {
|
||||
state.in_table = true;
|
||||
state.table_row_index = 0;
|
||||
state.table_col_count = 0;
|
||||
},
|
||||
.tr => {
|
||||
state.table_col_count = 0;
|
||||
try writer.writeByte('|');
|
||||
},
|
||||
.td, .th => {
|
||||
// Note: leading pipe handled by previous cell closing or tr opening
|
||||
state.last_char_was_newline = false;
|
||||
try writer.writeByte(' ');
|
||||
},
|
||||
.blockquote => {
|
||||
try writer.writeAll("> ");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.pre => {
|
||||
try writer.writeAll("```\n");
|
||||
state.pre_node = el.asNode();
|
||||
state.last_char_was_newline = true;
|
||||
},
|
||||
.code => {
|
||||
if (state.pre_node == null) {
|
||||
try writer.writeByte('`');
|
||||
state.in_code = true;
|
||||
state.last_char_was_newline = false;
|
||||
}
|
||||
},
|
||||
.b, .strong => {
|
||||
try writer.writeAll("**");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.i, .em => {
|
||||
try writer.writeAll("*");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.s, .del => {
|
||||
try writer.writeAll("~~");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.hr => {
|
||||
try writer.writeAll("---\n");
|
||||
state.last_char_was_newline = true;
|
||||
return;
|
||||
},
|
||||
.br => {
|
||||
if (state.in_table) {
|
||||
try writer.writeByte(' ');
|
||||
} else {
|
||||
try writer.writeByte('\n');
|
||||
state.last_char_was_newline = true;
|
||||
}
|
||||
return;
|
||||
},
|
||||
.img => {
|
||||
try writer.writeAll(";
|
||||
if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
|
||||
const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
|
||||
try writer.writeAll(absolute_src);
|
||||
}
|
||||
try writer.writeAll(")");
|
||||
state.last_char_was_newline = false;
|
||||
return;
|
||||
},
|
||||
.anchor => {
|
||||
const has_content = hasVisibleContent(el.asNode());
|
||||
const label = getAnchorLabel(el);
|
||||
const href_raw = el.getAttributeSafe(comptime .wrap("href"));
|
||||
|
||||
if (!has_content and label == null and href_raw == null) return;
|
||||
|
||||
const has_block = hasBlockDescendant(el.asNode());
|
||||
const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
|
||||
|
||||
if (has_block) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
if (href) |h| {
|
||||
if (!state.last_char_was_newline) try writer.writeByte('\n');
|
||||
try writer.writeAll("([](");
|
||||
try writer.writeAll(h);
|
||||
try writer.writeAll("))\n");
|
||||
state.last_char_was_newline = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStandaloneAnchor(el)) {
|
||||
if (!state.last_char_was_newline) try writer.writeByte('\n');
|
||||
try writer.writeByte('[');
|
||||
if (has_content) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
} else {
|
||||
try writer.writeAll(label orelse "");
|
||||
}
|
||||
try writer.writeAll("](");
|
||||
if (href) |h| {
|
||||
try writer.writeAll(h);
|
||||
}
|
||||
try writer.writeAll(")\n");
|
||||
state.last_char_was_newline = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try writer.writeByte('[');
|
||||
if (has_content) {
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
} else {
|
||||
try writer.writeAll(label orelse "");
|
||||
}
|
||||
try writer.writeAll("](");
|
||||
if (href) |h| {
|
||||
try writer.writeAll(h);
|
||||
}
|
||||
try writer.writeByte(')');
|
||||
state.last_char_was_newline = false;
|
||||
return;
|
||||
},
|
||||
.input => {
|
||||
const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
|
||||
if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
|
||||
const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
|
||||
try writer.writeAll(if (checked) "[x] " else "[ ] ");
|
||||
state.last_char_was_newline = false;
|
||||
}
|
||||
return;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// --- Render Children ---
|
||||
try renderChildren(el.asNode(), state, writer, page);
|
||||
|
||||
// --- Closing Tag Logic ---
|
||||
|
||||
// Suffixes
|
||||
switch (tag) {
|
||||
.pre => {
|
||||
if (!state.last_char_was_newline) {
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
try writer.writeAll("```\n");
|
||||
state.pre_node = null;
|
||||
state.last_char_was_newline = true;
|
||||
},
|
||||
.code => {
|
||||
if (state.pre_node == null) {
|
||||
try writer.writeByte('`');
|
||||
state.in_code = false;
|
||||
state.last_char_was_newline = false;
|
||||
}
|
||||
},
|
||||
.b, .strong => {
|
||||
try writer.writeAll("**");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.i, .em => {
|
||||
try writer.writeAll("*");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.s, .del => {
|
||||
try writer.writeAll("~~");
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
.blockquote => {},
|
||||
.ul, .ol => {
|
||||
if (state.list_depth > 0) state.list_depth -= 1;
|
||||
},
|
||||
.table => {
|
||||
state.in_table = false;
|
||||
},
|
||||
.tr => {
|
||||
try writer.writeByte('\n');
|
||||
if (state.table_row_index == 0) {
|
||||
try writer.writeByte('|');
|
||||
for (0..state.table_col_count) |_| {
|
||||
try writer.writeAll("---|");
|
||||
}
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
state.table_row_index += 1;
|
||||
state.last_char_was_newline = true;
|
||||
},
|
||||
.td, .th => {
|
||||
try writer.writeAll(" |");
|
||||
state.table_col_count += 1;
|
||||
state.last_char_was_newline = false;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
// Post-block newlines
|
||||
if (tag.isBlock() and !state.in_table) {
|
||||
try ensureNewline(state, writer);
|
||||
}
|
||||
}
|
||||
|
||||
fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
|
||||
if (text.len == 0) return;
|
||||
|
||||
if (state.pre_node) |_| {
|
||||
try writer.writeAll(text);
|
||||
state.last_char_was_newline = text[text.len - 1] == '\n';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for pure whitespace
|
||||
if (isAllWhitespace(text)) {
|
||||
if (!state.last_char_was_newline) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Collapse whitespace
|
||||
var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
|
||||
var first = true;
|
||||
while (it.next()) |word| {
|
||||
if (!first or (!state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
|
||||
try escapeMarkdown(writer, word);
|
||||
state.last_char_was_newline = false;
|
||||
first = false;
|
||||
}
|
||||
|
||||
// Handle trailing whitespace from the original text
|
||||
if (!first and !state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
}
|
||||
|
||||
fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
|
||||
for (text) |c| {
|
||||
switch (c) {
|
||||
'\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
|
||||
try writer.writeByte('\\');
|
||||
try writer.writeByte(c);
|
||||
},
|
||||
else => try writer.writeByte(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
|
||||
const testing = @import("../testing.zig");
|
||||
const page = try testing.test_session.createPage();
|
||||
|
||||
@@ -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>
|
||||
-->
|
||||
|
||||
@@ -125,6 +125,19 @@
|
||||
</script>
|
||||
|
||||
|
||||
<script id="CanvasRenderingContext2D#canvas">
|
||||
{
|
||||
const element = document.createElement("canvas");
|
||||
const ctx = element.getContext("2d");
|
||||
testing.expectEqual(ctx.canvas, element);
|
||||
// Setting dimensions via ctx.canvas should update the element.
|
||||
ctx.canvas.width = 40;
|
||||
ctx.canvas.height = 25;
|
||||
testing.expectEqual(element.width, 40);
|
||||
testing.expectEqual(element.height, 25);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="getter">
|
||||
{
|
||||
const element = document.createElement("canvas");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,11 +24,10 @@
|
||||
|
||||
<script id=byId name="test1">
|
||||
testing.expectEqual(1, document.querySelector.length);
|
||||
testing.expectError("SyntaxError: Syntax Error", () => document.querySelector(''));
|
||||
testing.expectError("SyntaxError", () => document.querySelector(''));
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(12, err.code);
|
||||
testing.expectEqual("SyntaxError", err.name);
|
||||
testing.expectEqual("Syntax Error", err.message);
|
||||
}, () => document.querySelector(''));
|
||||
|
||||
testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name'));
|
||||
|
||||
@@ -34,11 +34,10 @@
|
||||
</script>
|
||||
|
||||
<script id=script1 name="test1">
|
||||
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
|
||||
testing.expectError("SyntaxError", () => document.querySelectorAll(''));
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(12, err.code);
|
||||
testing.expectEqual("SyntaxError", err.name);
|
||||
testing.expectEqual("Syntax Error", err.message);
|
||||
}, () => document.querySelectorAll(''));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(3, err.code);
|
||||
testing.expectEqual('Hierarchy Error', err.message);
|
||||
testing.expectEqual('HierarchyRequestError', err.name);
|
||||
testing.expectEqual(true, err instanceof DOMException);
|
||||
testing.expectEqual(true, err instanceof Error);
|
||||
}, () => link.appendChild(content));
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
}
|
||||
|
||||
{
|
||||
// Empty XML is a parse error (no root element)
|
||||
const parser = new DOMParser();
|
||||
testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
|
||||
let d = parser.parseFromString('', 'text/xml');
|
||||
testing.expectEqual('<parsererror>error</parsererror>', new XMLSerializer().serializeToString(d));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(8, err.code);
|
||||
testing.expectEqual("NotFoundError", err.name);
|
||||
testing.expectEqual("Not Found", err.message);
|
||||
}, () => el1.removeAttributeNode(script_id_node));
|
||||
|
||||
testing.expectEqual(an1, el1.removeAttributeNode(an1));
|
||||
|
||||
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>
|
||||
@@ -12,7 +12,7 @@
|
||||
testing.expectEqual('', $('#a0').href);
|
||||
|
||||
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
|
||||
testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href);
|
||||
testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
|
||||
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
|
||||
|
||||
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);
|
||||
|
||||
@@ -532,6 +532,6 @@
|
||||
testing.expectEqual(true, result);
|
||||
});
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, asyncBlockDispatched));
|
||||
testing.onload(() => testing.expectEqual(true, asyncBlockDispatched));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
|
||||
|
||||
form.action = '/hello';
|
||||
testing.expectEqual(testing.ORIGIN + 'hello', form.action)
|
||||
testing.expectEqual(testing.ORIGIN + '/hello', form.action)
|
||||
|
||||
form.action = 'https://lightpanda.io/hello';
|
||||
testing.expectEqual('https://lightpanda.io/hello', form.action)
|
||||
@@ -343,3 +343,164 @@
|
||||
testing.expectEqual('', form.elements['choice'].value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test: requestSubmit() fires the submit event (unlike submit()) -->
|
||||
<form id="test_form2" action="/should-not-navigate2" method="get">
|
||||
<input name="q" value="test2">
|
||||
</form>
|
||||
|
||||
<script id="requestSubmit_fires_submit_event">
|
||||
{
|
||||
const form = $('#test_form2');
|
||||
let submitFired = false;
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitFired = true;
|
||||
});
|
||||
|
||||
form.requestSubmit();
|
||||
|
||||
testing.expectEqual(true, submitFired);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test: requestSubmit() with preventDefault stops navigation -->
|
||||
<form id="test_form3" action="/should-not-navigate3" method="get">
|
||||
<input name="q" value="test3">
|
||||
</form>
|
||||
|
||||
<script id="requestSubmit_respects_preventDefault">
|
||||
{
|
||||
const form = $('#test_form3');
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
form.requestSubmit();
|
||||
|
||||
// Form submission was prevented, so no navigation should be scheduled
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test: requestSubmit() with non-submit-button submitter throws TypeError -->
|
||||
<form id="test_form_rs1" action="/should-not-navigate4" method="get">
|
||||
<input id="rs1_text" type="text" name="q" value="test">
|
||||
<input id="rs1_submit" type="submit" value="Go">
|
||||
<input id="rs1_image" type="image" src="x.png">
|
||||
<button id="rs1_btn_submit" type="submit">Submit</button>
|
||||
<button id="rs1_btn_reset" type="reset">Reset</button>
|
||||
<button id="rs1_btn_button" type="button">Button</button>
|
||||
</form>
|
||||
|
||||
<script id="requestSubmit_rejects_non_submit_button">
|
||||
{
|
||||
const form = $('#test_form_rs1');
|
||||
form.addEventListener('submit', (e) => e.preventDefault());
|
||||
|
||||
// A text input is not a submit button — should throw TypeError
|
||||
testing.expectError('TypeError', () => {
|
||||
form.requestSubmit($('#rs1_text'));
|
||||
});
|
||||
|
||||
// A reset button is not a submit button — should throw TypeError
|
||||
testing.expectError('TypeError', () => {
|
||||
form.requestSubmit($('#rs1_btn_reset'));
|
||||
});
|
||||
|
||||
// A <button type="button"> is not a submit button — should throw TypeError
|
||||
testing.expectError('TypeError', () => {
|
||||
form.requestSubmit($('#rs1_btn_button'));
|
||||
});
|
||||
|
||||
// A <div> is not a submit button — should throw TypeError
|
||||
const div = document.createElement('div');
|
||||
form.appendChild(div);
|
||||
testing.expectError('TypeError', () => {
|
||||
form.requestSubmit(div);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test: requestSubmit() accepts valid submit buttons -->
|
||||
<script id="requestSubmit_accepts_submit_buttons">
|
||||
{
|
||||
const form = $('#test_form_rs1');
|
||||
let submitCount = 0;
|
||||
form.addEventListener('submit', (e) => { e.preventDefault(); submitCount++; });
|
||||
|
||||
// <input type="submit"> is a valid submitter
|
||||
form.requestSubmit($('#rs1_submit'));
|
||||
testing.expectEqual(1, submitCount);
|
||||
|
||||
// <input type="image"> is a valid submitter
|
||||
form.requestSubmit($('#rs1_image'));
|
||||
testing.expectEqual(2, submitCount);
|
||||
|
||||
// <button type="submit"> is a valid submitter
|
||||
form.requestSubmit($('#rs1_btn_submit'));
|
||||
testing.expectEqual(3, submitCount);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Test: requestSubmit() with submitter not owned by form throws NotFoundError -->
|
||||
<form id="test_form_rs2" action="/should-not-navigate5" method="get">
|
||||
<input type="text" name="q" value="test">
|
||||
</form>
|
||||
<form id="test_form_rs3">
|
||||
<input id="rs3_submit" type="submit" value="Other Submit">
|
||||
</form>
|
||||
|
||||
<script id="requestSubmit_rejects_wrong_form_submitter">
|
||||
{
|
||||
const form = $('#test_form_rs2');
|
||||
|
||||
// Submit button belongs to a different form — should throw NotFoundError
|
||||
testing.expectError('NotFoundError', () => {
|
||||
form.requestSubmit($('#rs3_submit'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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,15 +29,17 @@
|
||||
|
||||
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'));
|
||||
|
||||
img.src = '/absolute/path.png';
|
||||
testing.expectEqual(testing.ORIGIN + 'absolute/path.png', img.src);
|
||||
testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src);
|
||||
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
|
||||
|
||||
img.src = 'https://example.com/image.png';
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -191,14 +191,14 @@
|
||||
|
||||
let eventCount = 0;
|
||||
let lastEvent = null;
|
||||
|
||||
|
||||
input.addEventListener('selectionchange', (e) => {
|
||||
eventCount++;
|
||||
lastEvent = e;
|
||||
});
|
||||
|
||||
|
||||
testing.expectEqual(0, eventCount);
|
||||
|
||||
|
||||
input.setSelectionRange(0, 5);
|
||||
input.select();
|
||||
input.selectionStart = 3;
|
||||
@@ -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);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
|
||||
|
||||
l2.href = '/over/9000';
|
||||
testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href);
|
||||
testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
|
||||
|
||||
l2.crossOrigin = 'nope';
|
||||
testing.expectEqual('anonymous', l2.crossOrigin);
|
||||
@@ -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,6 +81,27 @@
|
||||
// then set href.
|
||||
link.href = 'https://lightpanda.io/opensource-browser/15';
|
||||
|
||||
testing.eventually(() => testing.expectEqual(true, result));
|
||||
testing.onload(() => testing.expectEqual(true, result));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="refs">
|
||||
{
|
||||
const rels = ['stylesheet', 'preload', 'modulepreload'];
|
||||
const results = rels.map(() => false);
|
||||
rels.forEach((rel, i) => {
|
||||
let link = document.createElement('link')
|
||||
link.rel = rel;
|
||||
link.href = '/nope';
|
||||
link.onload = () => results[i] = true;
|
||||
document.documentElement.appendChild(link);
|
||||
});
|
||||
|
||||
|
||||
testing.onload(() => {
|
||||
results.forEach((r) => {
|
||||
testing.expectEqual(true, r);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,11 +66,10 @@
|
||||
{
|
||||
const container = $('#test-container');
|
||||
|
||||
testing.expectError("SyntaxError: Syntax Error", () => container.matches(''));
|
||||
testing.expectError("SyntaxError", () => container.matches(''));
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(12, err.code);
|
||||
testing.expectEqual("SyntaxError", err.name);
|
||||
testing.expectEqual("Syntax Error", err.message);
|
||||
}, () => container.matches(''));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
const p1 = $('#p1');
|
||||
testing.expectEqual(null, p1.querySelector('#p1'));
|
||||
|
||||
testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector(''));
|
||||
testing.expectError("SyntaxError", () => p1.querySelector(''));
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(12, err.code);
|
||||
testing.expectEqual("SyntaxError", err.name);
|
||||
testing.expectEqual("Syntax Error", err.message);
|
||||
}, () => p1.querySelector(''));
|
||||
|
||||
testing.expectEqual($('#c2'), p1.querySelector('#c2'));
|
||||
|
||||
@@ -24,11 +24,10 @@
|
||||
<script id=errors>
|
||||
{
|
||||
const root = $('#root');
|
||||
testing.expectError("SyntaxError: Syntax Error", () => root.querySelectorAll(''));
|
||||
testing.expectError("SyntaxError", () => root.querySelectorAll(''));
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(12, err.code);
|
||||
testing.expectEqual("SyntaxError", err.name);
|
||||
testing.expectEqual("Syntax Error", err.message);
|
||||
}, () => root.querySelectorAll(''));
|
||||
}
|
||||
</script>
|
||||
|
||||
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>
|
||||
@@ -43,8 +43,8 @@
|
||||
const container = $('#container');
|
||||
|
||||
// Empty selectors
|
||||
testing.expectError("SyntaxError: Syntax Error", () => container.querySelector(''));
|
||||
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
|
||||
testing.expectError("SyntaxError", () => container.querySelector(''));
|
||||
testing.expectError("SyntaxError", () => document.querySelectorAll(''));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
38
src/browser/tests/event/report_error.html
Normal file
38
src/browser/tests/event/report_error.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=onerrorFiveArguments>
|
||||
let called = false;
|
||||
let argCount = 0;
|
||||
window.onerror = function() {
|
||||
called = true;
|
||||
argCount = arguments.length;
|
||||
return true; // suppress default
|
||||
};
|
||||
try { undefinedVariable; } catch(e) { window.reportError(e); }
|
||||
testing.expectEqual(true, called);
|
||||
testing.expectEqual(5, argCount);
|
||||
window.onerror = null;
|
||||
</script>
|
||||
|
||||
<script id=onerrorCalledBeforeEventListener>
|
||||
let callOrder = [];
|
||||
window.onerror = function() { callOrder.push('onerror'); return true; };
|
||||
window.addEventListener('error', function() { callOrder.push('listener'); });
|
||||
try { undefinedVariable; } catch(e) { window.reportError(e); }
|
||||
testing.expectEqual('onerror', callOrder[0]);
|
||||
testing.expectEqual('listener', callOrder[1]);
|
||||
window.onerror = null;
|
||||
</script>
|
||||
|
||||
<script id=onerrorReturnTrueSuppresses>
|
||||
let listenerCalled = false;
|
||||
window.onerror = function() { return true; };
|
||||
window.addEventListener('error', function(e) {
|
||||
// listener still fires even when onerror returns true
|
||||
listenerCalled = true;
|
||||
});
|
||||
try { undefinedVariable; } catch(e) { window.reportError(e); }
|
||||
testing.expectEqual(true, listenerCalled);
|
||||
window.onerror = null;
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
24
src/browser/tests/frames/post_message.html
Normal file
24
src/browser/tests/frames/post_message.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<iframe id="receiver"></iframe>
|
||||
|
||||
<script id="messages">
|
||||
{
|
||||
let reply = null;
|
||||
window.addEventListener('message', (e) => {
|
||||
reply = e.data;
|
||||
});
|
||||
|
||||
const iframe = $('#receiver');
|
||||
iframe.src = 'support/message_receiver.html';
|
||||
iframe.addEventListener('load', () => {
|
||||
iframe.contentWindow.postMessage('ping', '*');
|
||||
});
|
||||
|
||||
testing.onload(() => {
|
||||
testing.expectEqual('pong', reply.data);
|
||||
testing.expectEqual(testing.ORIGIN, reply.origin);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
8
src/browser/tests/frames/support/message_receiver.html
Normal file
8
src/browser/tests/frames/support/message_receiver.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data === 'ping') {
|
||||
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,37 +2,17 @@
|
||||
<script src="testing.js"></script>
|
||||
|
||||
<script id=history>
|
||||
testing.expectEqual('auto', history.scrollRestoration);
|
||||
|
||||
history.scrollRestoration = 'manual';
|
||||
testing.expectEqual('manual', history.scrollRestoration);
|
||||
|
||||
history.scrollRestoration = 'auto';
|
||||
testing.expectEqual('auto', history.scrollRestoration);
|
||||
testing.expectEqual(null, history.state)
|
||||
|
||||
history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/browser/tests/history_after_nav.skip.html');
|
||||
testing.expectEqual({ testInProgress: true }, history.state);
|
||||
|
||||
history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json');
|
||||
history.replaceState({ "new": "field", testComplete: true }, null);
|
||||
|
||||
let state = { "new": "field", testComplete: true };
|
||||
testing.expectEqual(state, history.state);
|
||||
|
||||
let popstateEventFired = false;
|
||||
let popstateEventState = null;
|
||||
|
||||
window.addEventListener('popstate', (event) => {
|
||||
popstateEventFired = true;
|
||||
popstateEventState = event.state;
|
||||
// This test is a bit wonky. But it's trying to test navigation, which is
|
||||
// something we can't do in the main page (we can't navigate away from this
|
||||
// page and still assertOk in the test runner).
|
||||
// If support/history.html has a failed assertion, it'll log the error and
|
||||
// stop the script. If it succeeds, it'll set support_history_completed
|
||||
// which we can use here to assume everything passed.
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(true, window.support_history_completed);
|
||||
testing.expectEqual(true, window.support_history_popstateEventFired);
|
||||
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);
|
||||
});
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual(true, popstateEventFired);
|
||||
testing.expectEqual({testInProgress: true }, popstateEventState);
|
||||
})
|
||||
|
||||
history.back();
|
||||
</script>
|
||||
|
||||
<iframe id=frame src="support/history.html"></iframe>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user