mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-24 09:23:04 -04:00
Merge branch 'main' into css-improvements
This commit is contained in:
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.4'
|
||||
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:
|
||||
|
||||
10
.github/workflows/e2e-integration-test.yml
vendored
10
.github/workflows/e2e-integration-test.yml
vendored
@@ -20,11 +20,9 @@ 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
|
||||
|
||||
@@ -32,7 +30,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
|
||||
|
||||
69
.github/workflows/e2e-test.yml
vendored
69
.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,8 +48,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
|
||||
|
||||
@@ -61,7 +55,7 @@ jobs:
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -182,39 +176,41 @@ jobs:
|
||||
name: wba-test
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
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 \
|
||||
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 +220,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 +234,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -247,7 +242,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 +328,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 +351,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 +374,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' }}
|
||||
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
|
||||
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
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 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- 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.4
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
|
||||
8
Makefile
8
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:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -190,8 +190,6 @@ Here are the key features we have implemented:
|
||||
|
||||
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
|
||||
@@ -200,10 +198,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**:
|
||||
|
||||
|
||||
@@ -27,12 +27,14 @@ pub fn build(b: *Build) !void {
|
||||
const manifest = Manifest.init(b);
|
||||
|
||||
const git_commit = b.option([]const u8, "git_commit", "Current git commit");
|
||||
const git_version = b.option([]const u8, "git_version", "Current git version (from tag)");
|
||||
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
|
||||
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
|
||||
|
||||
var opts = b.addOptions();
|
||||
opts.addOption([]const u8, "version", manifest.version);
|
||||
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
|
||||
opts.addOption(?[]const u8, "git_version", git_version orelse null);
|
||||
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
|
||||
|
||||
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
.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.4.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
|
||||
},
|
||||
// .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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -47,7 +49,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
|
||||
};
|
||||
var visibility_cache: Element.VisibilityCache = .empty;
|
||||
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| {
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache, 0) catch |err| {
|
||||
log.err(.app, "semantic tree json dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -62,7 +64,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
|
||||
};
|
||||
var visibility_cache: Element.VisibilityCache = .empty;
|
||||
var pointer_events_cache: Element.PointerEventsCache = .empty;
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| {
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache, 0) catch |err| {
|
||||
log.err(.app, "semantic tree text dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
@@ -75,7 +77,7 @@ const OptionData = struct {
|
||||
};
|
||||
|
||||
const NodeData = struct {
|
||||
id: u32,
|
||||
id: CDPNode.Id,
|
||||
axn: AXNode,
|
||||
role: []const u8,
|
||||
name: ?[]const u8,
|
||||
@@ -86,7 +88,9 @@ 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, visibility_cache: ?*Element.VisibilityCache, pointer_events_cache: ?*Element.PointerEventsCache) !void {
|
||||
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, visibility_cache: ?*Element.VisibilityCache, pointer_events_cache: ?*Element.PointerEventsCache, 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();
|
||||
@@ -178,7 +182,23 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -217,7 +237,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, visibility_cache, pointer_events_cache);
|
||||
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, visibility_cache, pointer_events_cache, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,36 +413,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");
|
||||
@@ -452,3 +481,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);
|
||||
}
|
||||
|
||||
@@ -64,17 +64,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();
|
||||
@@ -242,7 +242,10 @@ 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();
|
||||
}
|
||||
|
||||
@@ -91,25 +91,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.
|
||||
|
||||
@@ -110,6 +110,8 @@ use_proxy: bool,
|
||||
// Current TLS verification state, applied per-connection in makeRequest.
|
||||
tls_verify: bool = true,
|
||||
|
||||
obey_robots: bool,
|
||||
|
||||
cdp_client: ?CDPClient = null,
|
||||
|
||||
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
|
||||
@@ -154,6 +156,7 @@ pub fn init(allocator: Allocator, network: *Network) !*Client {
|
||||
.http_proxy = http_proxy,
|
||||
.use_proxy = http_proxy != null,
|
||||
.tls_verify = network.config.tlsVerifyHost(),
|
||||
.obey_robots = network.config.obeyRobots(),
|
||||
.transfer_pool = transfer_pool,
|
||||
};
|
||||
|
||||
@@ -257,34 +260,33 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus {
|
||||
}
|
||||
|
||||
pub fn request(self: *Client, req: Request) !void {
|
||||
if (self.network.config.obeyRobots()) {
|
||||
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
|
||||
errdefer self.allocator.free(robots_url);
|
||||
|
||||
// If we have this robots cached, we can take a fast path.
|
||||
if (self.network.robot_store.get(robots_url)) |robot_entry| {
|
||||
defer self.allocator.free(robots_url);
|
||||
|
||||
switch (robot_entry) {
|
||||
// If we have a found robots entry, we check it.
|
||||
.present => |robots| {
|
||||
const path = URL.getPathname(req.url);
|
||||
if (!robots.isAllowed(path)) {
|
||||
req.error_callback(req.ctx, error.RobotsBlocked);
|
||||
return;
|
||||
}
|
||||
},
|
||||
// Otherwise, we assume we won't find it again.
|
||||
.absent => {},
|
||||
}
|
||||
|
||||
return self.processRequest(req);
|
||||
}
|
||||
|
||||
return self.fetchRobotsThenProcessRequest(robots_url, req);
|
||||
if (self.obey_robots == false) {
|
||||
return self.processRequest(req);
|
||||
}
|
||||
|
||||
return self.processRequest(req);
|
||||
const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
|
||||
errdefer self.allocator.free(robots_url);
|
||||
|
||||
// If we have this robots cached, we can take a fast path.
|
||||
if (self.network.robot_store.get(robots_url)) |robot_entry| {
|
||||
defer self.allocator.free(robots_url);
|
||||
|
||||
switch (robot_entry) {
|
||||
// If we have a found robots entry, we check it.
|
||||
.present => |robots| {
|
||||
const path = URL.getPathname(req.url);
|
||||
if (!robots.isAllowed(path)) {
|
||||
req.error_callback(req.ctx, error.RobotsBlocked);
|
||||
return;
|
||||
}
|
||||
},
|
||||
// Otherwise, we assume we won't find it again.
|
||||
.absent => {},
|
||||
}
|
||||
|
||||
return self.processRequest(req);
|
||||
}
|
||||
return self.fetchRobotsThenProcessRequest(robots_url, req);
|
||||
}
|
||||
|
||||
fn processRequest(self: *Client, req: Request) !void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -540,6 +686,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 +740,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));
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ const storage = @import("webapi/storage/storage.zig");
|
||||
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.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;
|
||||
@@ -313,14 +314,16 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,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 {
|
||||
@@ -464,7 +460,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;
|
||||
};
|
||||
@@ -716,11 +719,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;
|
||||
@@ -729,6 +735,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
|
||||
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
|
||||
};
|
||||
}
|
||||
|
||||
self.pendingLoadCompleted();
|
||||
}
|
||||
|
||||
@@ -855,13 +862,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,
|
||||
@@ -3273,14 +3292,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.
|
||||
@@ -3524,13 +3543,16 @@ 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", .{});
|
||||
}
|
||||
|
||||
test "WebApi: Frames" {
|
||||
const filter: testing.LogFilter = .init(&.{.js});
|
||||
defer filter.deinit();
|
||||
|
||||
try testing.htmlRunner("frames", .{});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,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 +98,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 +114,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 +134,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, arena: Allocator, url: [:0]const u8) !net_http.Headers {
|
||||
var headers = try self.client.newHeaders();
|
||||
try self.page.headersForRequest(self.page.arena, url, &headers);
|
||||
try self.page.headersForRequest(arena, url, &headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -191,19 +187,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 +214,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 +222,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 +272,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,7 +280,7 @@ 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(arena, url),
|
||||
.blocking = is_blocking,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.resource_type = .script,
|
||||
@@ -289,6 +291,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 +321,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 +330,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 +362,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 +379,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,12 +394,18 @@ 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(arena, url),
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
.resource_type = .script,
|
||||
.notification = page._session.notification,
|
||||
@@ -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,11 +503,12 @@ 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(arena, url),
|
||||
.ctx = script,
|
||||
.resource_type = .script,
|
||||
.cookie_jar = &page._session.cookie_jar,
|
||||
@@ -510,9 +518,10 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
|
||||
.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 +546,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 +583,7 @@ fn evaluate(self: *ScriptManager) void {
|
||||
}
|
||||
defer {
|
||||
_ = self.defer_scripts.popFirst();
|
||||
script.deinit(true);
|
||||
script.deinit();
|
||||
}
|
||||
script.eval(page);
|
||||
}
|
||||
@@ -625,11 +634,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,
|
||||
@@ -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 {
|
||||
@@ -750,9 +757,9 @@ pub const Script = struct {
|
||||
}
|
||||
|
||||
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 +773,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 +790,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 +817,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 +829,7 @@ pub const Script = struct {
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
self.deinit(true);
|
||||
self.deinit();
|
||||
manager.evaluate();
|
||||
}
|
||||
|
||||
@@ -951,76 +957,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 +966,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 +981,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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
// 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();
|
||||
try browser.runMacrotasks();
|
||||
|
||||
// Each call to this runs scheduled load events.
|
||||
try page.dispatchLoad();
|
||||
@@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
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;
|
||||
}
|
||||
var ms = blk: {
|
||||
// if (wait_ms - ms_remaining < 100) {
|
||||
// if (comptime builtin.is_test) {
|
||||
// return .done;
|
||||
// }
|
||||
// // Look, we want to exit ASAP, but we don't want
|
||||
// // to exit so fast that we've run none of the
|
||||
// // background jobs.
|
||||
// break :blk 50;
|
||||
// }
|
||||
|
||||
if (browser.hasBackgroundTasks()) {
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
@@ -441,9 +441,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
break :blk 20;
|
||||
}
|
||||
|
||||
// No http transfers, no cdp extra socket, no
|
||||
// scheduled tasks, we're done.
|
||||
return .done;
|
||||
break :blk browser.msToNextMacrotask() orelse return .done;
|
||||
};
|
||||
|
||||
if (ms > ms_remaining) {
|
||||
@@ -470,9 +468,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
// 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);
|
||||
// We should continue to run tasks, so we minimize how long
|
||||
// we'll poll for network I/O.
|
||||
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
|
||||
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
|
||||
// if we have background tasks, we don't want to wait too
|
||||
// long for a message from the client. We want to go back
|
||||
|
||||
104
src/browser/actions.zig
Normal file
104
src/browser/actions.zig
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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");
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -259,17 +259,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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -537,9 +537,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;
|
||||
|
||||
@@ -119,12 +119,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 +165,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,12 +182,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
|
||||
const env = self.env;
|
||||
const isolate = env.isolate;
|
||||
|
||||
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
|
||||
|
||||
const origin = try self.session.getOrCreateOrigin(key);
|
||||
errdefer self.session.releaseOrigin(origin);
|
||||
|
||||
try self.origin.transferTo(origin);
|
||||
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
|
||||
self.origin.deinit(env.app);
|
||||
try origin.takeover(self.origin);
|
||||
|
||||
self.origin = origin;
|
||||
|
||||
@@ -255,6 +269,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,
|
||||
@@ -306,15 +324,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 };
|
||||
@@ -476,7 +494,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.?,
|
||||
@@ -509,7 +527,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.?,
|
||||
@@ -556,7 +574,7 @@ pub fn dynamicModuleCallback(
|
||||
|
||||
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.?,
|
||||
|
||||
@@ -382,8 +382,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 +397,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 +495,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| {
|
||||
|
||||
@@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
|
||||
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();
|
||||
}
|
||||
|
||||
pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise {
|
||||
var resolver = js.PromiseResolver.init(self);
|
||||
resolver.resolve("Local.resolvePromise", value);
|
||||
|
||||
@@ -68,6 +68,8 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
|
||||
// if v8 hasn't called the finalizer directly itself.
|
||||
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
|
||||
|
||||
taken_over: std.ArrayList(*Origin),
|
||||
|
||||
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
|
||||
const arena = try app.arena_pool.acquire();
|
||||
errdefer app.arena_pool.release(arena);
|
||||
@@ -86,14 +88,19 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
|
||||
.rc = 1,
|
||||
.arena = arena,
|
||||
.key = owned_key,
|
||||
.globals = .empty,
|
||||
.temps = .empty,
|
||||
.globals = .empty,
|
||||
.taken_over = .empty,
|
||||
.security_token = token_global,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Origin, app: *App) void {
|
||||
for (self.taken_over.items) |o| {
|
||||
o.deinit(app);
|
||||
}
|
||||
|
||||
// Call finalizers before releasing anything
|
||||
{
|
||||
var it = self.finalizer_callbacks.valueIterator();
|
||||
@@ -196,42 +203,44 @@ pub fn createFinalizerCallback(
|
||||
return fc;
|
||||
}
|
||||
|
||||
pub fn transferTo(self: *Origin, dest: *Origin) !void {
|
||||
const arena = dest.arena;
|
||||
pub fn takeover(self: *Origin, original: *Origin) !void {
|
||||
const arena = self.arena;
|
||||
|
||||
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
|
||||
for (self.globals.items) |obj| {
|
||||
dest.globals.appendAssumeCapacity(obj);
|
||||
try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
|
||||
for (original.globals.items) |obj| {
|
||||
self.globals.appendAssumeCapacity(obj);
|
||||
}
|
||||
self.globals.clearRetainingCapacity();
|
||||
original.globals.clearRetainingCapacity();
|
||||
|
||||
{
|
||||
try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
|
||||
var it = self.temps.iterator();
|
||||
try self.temps.ensureUnusedCapacity(arena, original.temps.count());
|
||||
var it = original.temps.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.temps.clearRetainingCapacity();
|
||||
original.temps.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
|
||||
var it = self.finalizer_callbacks.iterator();
|
||||
try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
|
||||
var it = original.finalizer_callbacks.iterator();
|
||||
while (it.next()) |kv| {
|
||||
kv.value_ptr.*.origin = dest;
|
||||
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
kv.value_ptr.*.origin = self;
|
||||
try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.finalizer_callbacks.clearRetainingCapacity();
|
||||
original.finalizer_callbacks.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
{
|
||||
try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
|
||||
var it = self.identity_map.iterator();
|
||||
try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
|
||||
var it = original.identity_map.iterator();
|
||||
while (it.next()) |kv| {
|
||||
try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
|
||||
}
|
||||
self.identity_map.clearRetainingCapacity();
|
||||
original.identity_map.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
try self.taken_over.append(self.arena, original);
|
||||
}
|
||||
|
||||
// A type that has a finalizer can have its finalizer called one of two ways.
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
|
||||
const js = @import("js.zig");
|
||||
const v8 = js.v8;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
const DOMException = @import("../webapi/DOMException.zig");
|
||||
|
||||
const PromiseResolver = @This();
|
||||
|
||||
@@ -63,14 +65,19 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
|
||||
};
|
||||
}
|
||||
|
||||
const RejectError = union(enum) {
|
||||
pub const RejectError = union(enum) {
|
||||
generic: []const u8,
|
||||
type_error: []const u8,
|
||||
dom_exception: anyerror,
|
||||
};
|
||||
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
|
||||
const handle = switch (err) {
|
||||
.type_error => |str| self.local.isolate.createTypeError(str),
|
||||
.generic => |str| self.local.isolate.createError(str),
|
||||
.dom_exception => |exception| {
|
||||
self.reject(source, DOMException.fromError(exception));
|
||||
return;
|
||||
},
|
||||
};
|
||||
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
|
||||
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -725,6 +725,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,6 +850,7 @@ 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/MessageChannel.zig"),
|
||||
@import("../webapi/MessagePort.zig"),
|
||||
|
||||
@@ -124,352 +124,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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,123 @@
|
||||
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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -84,3 +84,24 @@
|
||||
testing.eventually(() => 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.eventually(() => {
|
||||
results.forEach((r) => {
|
||||
testing.expectEqual(true, r);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
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>
|
||||
25
src/browser/tests/frames/post_message.html
Normal file
25
src/browser/tests/frames/post_message.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<iframe id="receiver"></iframe>
|
||||
|
||||
<script id="messages">
|
||||
{
|
||||
let reply = null;
|
||||
window.addEventListener('message', (e) => {
|
||||
console.warn('reply')
|
||||
reply = e.data;
|
||||
});
|
||||
|
||||
const iframe = $('#receiver');
|
||||
iframe.src = 'support/message_receiver.html';
|
||||
iframe.addEventListener('load', () => {
|
||||
iframe.contentWindow.postMessage('ping', '*');
|
||||
});
|
||||
|
||||
testing.eventually(() => {
|
||||
testing.expectEqual('pong', reply.data);
|
||||
testing.expectEqual(testing.ORIGIN, reply.origin);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
9
src/browser/tests/frames/support/message_receiver.html
Normal file
9
src/browser/tests/frames/support/message_receiver.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<script>
|
||||
window.addEventListener('message', (e) => {
|
||||
console.warn('Frame Message', e.data);
|
||||
if (e.data === 'ping') {
|
||||
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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.eventually(() => {
|
||||
testing.expectEqual(true, popstateEventFired);
|
||||
testing.expectEqual({testInProgress: true }, popstateEventState);
|
||||
})
|
||||
|
||||
history.back();
|
||||
testing.expectEqual(true, window.support_history_completed);
|
||||
testing.expectEqual(true, window.support_history_popstateEventFired);
|
||||
testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<iframe id=frame src="support/history.html"></iframe>
|
||||
|
||||
14
src/browser/tests/mcp_actions.html
Normal file
14
src/browser/tests/mcp_actions.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<button id="btn" onclick="window.clicked = true;">Click Me</button>
|
||||
<input id="inp" oninput="window.inputVal = this.value" onchange="window.changed = true;">
|
||||
<select id="sel" onchange="window.selChanged = this.value">
|
||||
<option value="opt1">Option 1</option>
|
||||
<option value="opt2">Option 2</option>
|
||||
</select>
|
||||
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
|
||||
<div style="height: 500px;">Long content</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,3 +27,44 @@
|
||||
testing.expectEqual(false, navigator.javaEnabled());
|
||||
testing.expectEqual(false, navigator.webdriver);
|
||||
</script>
|
||||
|
||||
<script id=permission_query>
|
||||
testing.async(async (restore) => {
|
||||
const p = navigator.permissions.query({ name: 'notifications' });
|
||||
testing.expectTrue(p instanceof Promise);
|
||||
const status = await p;
|
||||
restore();
|
||||
testing.expectEqual('prompt', status.state);
|
||||
testing.expectEqual('notifications', status.name);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=storage_estimate>
|
||||
testing.async(async (restore) => {
|
||||
const p = navigator.storage.estimate();
|
||||
testing.expectTrue(p instanceof Promise);
|
||||
|
||||
const estimate = await p;
|
||||
restore();
|
||||
testing.expectEqual(0, estimate.usage);
|
||||
testing.expectEqual(1024 * 1024 * 1024, estimate.quota);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=deviceMemory>
|
||||
testing.expectEqual(8, navigator.deviceMemory);
|
||||
</script>
|
||||
|
||||
<script id=getBattery>
|
||||
testing.async(async (restore) => {
|
||||
const p = navigator.getBattery();
|
||||
try {
|
||||
await p;
|
||||
testing.fail('getBattery should reject');
|
||||
} catch (err) {
|
||||
restore();
|
||||
testing.expectEqual('NotSupportedError', err.name);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -203,3 +203,39 @@
|
||||
testing.expectEqual(true, response.body !== null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=fetch_blob_url>
|
||||
testing.async(async (restore) => {
|
||||
// Create a blob and get its URL
|
||||
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const response = await fetch(blobUrl);
|
||||
restore();
|
||||
|
||||
testing.expectEqual(200, response.status);
|
||||
testing.expectEqual(true, response.ok);
|
||||
testing.expectEqual(blobUrl, response.url);
|
||||
testing.expectEqual('text/plain', response.headers.get('Content-Type'));
|
||||
|
||||
const text = await response.text();
|
||||
testing.expectEqual('Hello from blob!', text);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=abort>
|
||||
testing.async(async (restore) => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
try {
|
||||
await fetch('http://127.0.0.1:9582/xhr', { signal: controller.signal });
|
||||
testain.fail('fetch should have been aborted');
|
||||
} catch (e) {
|
||||
restore();
|
||||
testing.expectEqual("AbortError", e.name);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -283,3 +283,26 @@
|
||||
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=xhr_blob_url>
|
||||
testing.async(async (restore) => {
|
||||
// Create a blob and get its URL
|
||||
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
await new Promise((resolve) => {
|
||||
req.onload = resolve;
|
||||
req.open('GET', blobUrl);
|
||||
req.send();
|
||||
});
|
||||
|
||||
restore();
|
||||
testing.expectEqual(200, req.status);
|
||||
testing.expectEqual('Hello from blob!', req.responseText);
|
||||
testing.expectEqual(blobUrl, req.responseURL);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(8, err.code);
|
||||
testing.expectEqual("NotFoundError", err.name);
|
||||
testing.expectEqual("Not Found", err.message);
|
||||
}, () => d1.insertBefore(document.createElement('div'), d2));
|
||||
|
||||
let c1 = document.createElement('div');
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(8, err.code);
|
||||
testing.expectEqual("NotFoundError", err.name);
|
||||
testing.expectEqual("Not Found", err.message);
|
||||
}, () => $('#d1').removeChild($('#p1')));
|
||||
|
||||
const p1 = $('#p1');
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
testing.withError((err) => {
|
||||
testing.expectEqual(3, err.code);
|
||||
testing.expectEqual("HierarchyRequestError", err.name);
|
||||
testing.expectEqual("Hierarchy Error", err.message);
|
||||
}, () => d1.replaceChild(c4, c3));
|
||||
|
||||
testing.expectEqual(c2, d1.replaceChild(c4, c2));
|
||||
|
||||
@@ -451,12 +451,12 @@
|
||||
const p1 = $('#p1');
|
||||
|
||||
// Test setStart with offset beyond node length
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.setStart(p1, 999);
|
||||
});
|
||||
|
||||
// Test with negative offset (wraps to large u32)
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.setStart(p1.firstChild, -1);
|
||||
});
|
||||
}
|
||||
@@ -468,12 +468,12 @@
|
||||
const p1 = $('#p1');
|
||||
|
||||
// Test setEnd with offset beyond node length
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.setEnd(p1, 999);
|
||||
});
|
||||
|
||||
// Test with text node
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.setEnd(p1.firstChild, 9999);
|
||||
});
|
||||
}
|
||||
@@ -525,11 +525,11 @@
|
||||
range.setEnd(p1, 1);
|
||||
|
||||
// Test comparePoint with invalid offset
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.comparePoint(p1, 20);
|
||||
});
|
||||
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.comparePoint(p1.firstChild, -1);
|
||||
});
|
||||
}
|
||||
@@ -650,11 +650,11 @@
|
||||
range.setEnd(p1, 1);
|
||||
|
||||
// Invalid offset should throw IndexSizeError
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.isPointInRange(p1, 999);
|
||||
});
|
||||
|
||||
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
|
||||
testing.expectError('IndexSizeError:', () => {
|
||||
range.isPointInRange(p1.firstChild, 9999);
|
||||
});
|
||||
}
|
||||
@@ -854,11 +854,11 @@
|
||||
range2.setStart(p, 0);
|
||||
|
||||
// Invalid how parameter should throw NotSupportedError
|
||||
testing.expectError('NotSupportedError: Not Supported', () => {
|
||||
testing.expectError('NotSupportedError:', () => {
|
||||
range1.compareBoundaryPoints(4, range2);
|
||||
});
|
||||
|
||||
testing.expectError('NotSupportedError: Not Supported', () => {
|
||||
testing.expectError('NotSupportedError:', () => {
|
||||
range1.compareBoundaryPoints(99, range2);
|
||||
});
|
||||
}
|
||||
@@ -883,7 +883,7 @@
|
||||
range2.setEnd(foreignP, 1);
|
||||
|
||||
// Comparing ranges in different documents should throw WrongDocumentError
|
||||
testing.expectError('WrongDocumentError: wrong_document_error', () => {
|
||||
testing.expectError('WrongDocumentError:', () => {
|
||||
range1.compareBoundaryPoints(Range.START_TO_START, range2);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div id="host2"></div>
|
||||
<div id="host3"></div>
|
||||
|
||||
<!-- <script id="attachShadow_open">
|
||||
<script id="attachShadow_open">
|
||||
{
|
||||
const host = $('#host1');
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
@@ -140,7 +140,7 @@
|
||||
shadow.replaceChildren('New content');
|
||||
testing.expectEqual('New content', shadow.innerHTML);
|
||||
}
|
||||
</script> -->
|
||||
</script>
|
||||
|
||||
<script id="getElementById">
|
||||
{
|
||||
@@ -154,3 +154,16 @@
|
||||
testing.expectEqual(null, shadow.getElementById('nonexistent'));
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<script id=adoptedStyleSheets>
|
||||
{
|
||||
const host = document.createElement('div');
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const acss = shadow.adoptedStyleSheets;
|
||||
testing.expectEqual(0, acss.length);
|
||||
acss.push(new CSSStyleSheet());
|
||||
testing.expectEqual(1, acss.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
33
src/browser/tests/support/history.html
Normal file
33
src/browser/tests/support/history.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<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, testing.BASE_URL + 'history_after_nav.skip.html');
|
||||
testing.expectEqual({ testInProgress: true }, history.state);
|
||||
|
||||
history.pushState({ testInProgress: false }, null, testing.ORIGIN + '/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.top.support_history_completed = true;
|
||||
window.addEventListener('popstate', (event) => {
|
||||
window.top.window.support_history_popstateEventFired = true;
|
||||
window.top.window.support_history_popstateEventState = event.state;
|
||||
});
|
||||
|
||||
history.back();
|
||||
</script>
|
||||
|
||||
@@ -99,8 +99,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// our test runner sets this to true
|
||||
const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true;
|
||||
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
|
||||
|
||||
window.testing = {
|
||||
fail: fail,
|
||||
@@ -114,17 +113,17 @@
|
||||
eventually: eventually,
|
||||
IS_TEST_RUNNER: IS_TEST_RUNNER,
|
||||
HOST: '127.0.0.1',
|
||||
ORIGIN: 'http://127.0.0.1:9582/',
|
||||
ORIGIN: 'http://127.0.0.1:9582',
|
||||
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
|
||||
};
|
||||
|
||||
if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
|
||||
if (IS_TEST_RUNNER === false) {
|
||||
// The page is running in a different browser. Probably a developer making sure
|
||||
// a test is correct. There are a few tweaks we need to do to make this a
|
||||
// seemless, namely around adapting paths/urls.
|
||||
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
|
||||
window.testing.HOST = location.hostname;
|
||||
window.testing.ORIGIN = location.origin + '/';
|
||||
window.testing.ORIGIN = location.origin;
|
||||
window.testing.BASE_URL = location.origin + '/src/browser/tests/';
|
||||
window.addEventListener('load', testing.assertOk);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
|
||||
|
||||
// length % 4 == 1 must still throw
|
||||
testing.expectError('InvalidCharacterError: Invalid Character', () => {
|
||||
testing.expectError('InvalidCharacterError', () => {
|
||||
atob('Y');
|
||||
});
|
||||
</script>
|
||||
|
||||
34
src/browser/tests/window/window_event.html
Normal file
34
src/browser/tests/window/window_event.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=windowEventUndefinedOutsideHandler>
|
||||
testing.expectEqual(undefined, window.event);
|
||||
</script>
|
||||
|
||||
<script id=windowEventSetDuringWindowHandler>
|
||||
var capturedEvent = null;
|
||||
|
||||
window.addEventListener('test-event', function(e) {
|
||||
capturedEvent = window.event;
|
||||
});
|
||||
|
||||
var ev = new Event('test-event');
|
||||
window.dispatchEvent(ev);
|
||||
|
||||
testing.expectEqual(ev, capturedEvent);
|
||||
testing.expectEqual(undefined, window.event);
|
||||
</script>
|
||||
|
||||
<script id=windowEventRestoredAfterHandler>
|
||||
var captured2 = null;
|
||||
|
||||
window.addEventListener('test-event-2', function(e) {
|
||||
captured2 = window.event;
|
||||
});
|
||||
|
||||
var ev2 = new Event('test-event-2');
|
||||
window.dispatchEvent(ev2);
|
||||
|
||||
testing.expectEqual(ev2, captured2);
|
||||
testing.expectEqual(undefined, window.event);
|
||||
</script>
|
||||
@@ -104,13 +104,27 @@ pub fn getMessage(self: *const DOMException) []const u8 {
|
||||
}
|
||||
return switch (self._code) {
|
||||
.none => "",
|
||||
.invalid_character_error => "Invalid Character",
|
||||
.index_size_error => "Index or size is negative or greater than the allowed amount",
|
||||
.syntax_error => "Syntax Error",
|
||||
.not_supported => "Not Supported",
|
||||
.not_found => "Not Found",
|
||||
.hierarchy_error => "Hierarchy Error",
|
||||
else => @tagName(self._code),
|
||||
.hierarchy_error => "The operation would yield an incorrect node tree",
|
||||
.wrong_document_error => "The object is in the wrong document",
|
||||
.invalid_character_error => "The string contains invalid characters",
|
||||
.no_modification_allowed_error => "The object can not be modified",
|
||||
.not_found => "The object can not be found here",
|
||||
.not_supported => "The operation is not supported",
|
||||
.inuse_attribute_error => "The attribute already in use",
|
||||
.invalid_state_error => "The object is in an invalid state",
|
||||
.syntax_error => "The string did not match the expected pattern",
|
||||
.invalid_modification_error => "The object can not be modified in this way",
|
||||
.namespace_error => "The operation is not allowed by Namespaces in XML",
|
||||
.invalid_access_error => "The object does not support the operation or argument",
|
||||
.security_error => "The operation is insecure",
|
||||
.network_error => "A network error occurred",
|
||||
.abort_error => "The operation was aborted",
|
||||
.url_mismatch_error => "The given URL does not match another URL",
|
||||
.quota_exceeded_error => "The quota has been exceeded",
|
||||
.timeout_error => "The operation timed out",
|
||||
.invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation",
|
||||
.data_clone_error => "The object can not be cloned",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +365,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
|
||||
return (try KeyboardEvent.init("", null, page)).asEvent();
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, normalized, "inputevent")) {
|
||||
const InputEvent = @import("event/InputEvent.zig");
|
||||
return (try InputEvent.init("", null, page)).asEvent();
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
|
||||
const MouseEvent = @import("event/MouseEvent.zig");
|
||||
return (try MouseEvent.init("", null, page)).asEvent();
|
||||
|
||||
@@ -93,12 +93,12 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
|
||||
}
|
||||
|
||||
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
|
||||
self._callback.release();
|
||||
if ((comptime IS_DEBUG) and !shutdown) {
|
||||
std.debug.assert(self._observing.items.len == 0);
|
||||
if (shutdown) {
|
||||
self._callback.release();
|
||||
session.releaseArena(self._arena);
|
||||
} else if (comptime IS_DEBUG) {
|
||||
std.debug.assert(false);
|
||||
}
|
||||
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
|
||||
@@ -111,7 +111,6 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
|
||||
|
||||
// Register with page if this is our first observation
|
||||
if (self._observing.items.len == 0) {
|
||||
page.js.strongRef(self);
|
||||
try page.registerIntersectionObserver(self);
|
||||
}
|
||||
|
||||
@@ -146,22 +145,18 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (self._observing.items.len == 0) {
|
||||
page.js.safeWeakRef(self);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
|
||||
page.unregisterIntersectionObserver(self);
|
||||
self._observing.clearRetainingCapacity();
|
||||
self._previous_states.clearRetainingCapacity();
|
||||
|
||||
for (self._pending_entries.items) |entry| {
|
||||
entry.deinit(false, page._session);
|
||||
}
|
||||
self._pending_entries.clearRetainingCapacity();
|
||||
page.js.safeWeakRef(self);
|
||||
|
||||
self._observing.clearRetainingCapacity();
|
||||
page.unregisterIntersectionObserver(self);
|
||||
}
|
||||
|
||||
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
|
||||
@@ -363,7 +358,6 @@ pub const JsApi = struct {
|
||||
pub const name = "IntersectionObserver";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(IntersectionObserver.deinit);
|
||||
};
|
||||
|
||||
|
||||
@@ -86,12 +86,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
|
||||
}
|
||||
|
||||
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
|
||||
self._callback.release();
|
||||
if ((comptime IS_DEBUG) and !shutdown) {
|
||||
std.debug.assert(self._observing.items.len == 0);
|
||||
if (shutdown) {
|
||||
self._callback.release();
|
||||
session.releaseArena(self._arena);
|
||||
} else if (comptime IS_DEBUG) {
|
||||
std.debug.assert(false);
|
||||
}
|
||||
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
|
||||
@@ -158,7 +158,6 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
|
||||
|
||||
// Register with page if this is our first observation
|
||||
if (self._observing.items.len == 0) {
|
||||
page.js.strongRef(self);
|
||||
try page.registerMutationObserver(self);
|
||||
}
|
||||
|
||||
@@ -169,13 +168,13 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
|
||||
}
|
||||
|
||||
pub fn disconnect(self: *MutationObserver, page: *Page) void {
|
||||
page.unregisterMutationObserver(self);
|
||||
self._observing.clearRetainingCapacity();
|
||||
for (self._pending_records.items) |record| {
|
||||
record.deinit(false, page._session);
|
||||
}
|
||||
self._pending_records.clearRetainingCapacity();
|
||||
page.js.safeWeakRef(self);
|
||||
|
||||
self._observing.clearRetainingCapacity();
|
||||
page.unregisterMutationObserver(self);
|
||||
}
|
||||
|
||||
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
|
||||
@@ -441,7 +440,6 @@ pub const JsApi = struct {
|
||||
pub const name = "MutationObserver";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(MutationObserver.deinit);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,13 +18,21 @@
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
|
||||
const PluginArray = @import("PluginArray.zig");
|
||||
const Permissions = @import("Permissions.zig");
|
||||
const StorageManager = @import("StorageManager.zig");
|
||||
|
||||
const Navigator = @This();
|
||||
_pad: bool = false,
|
||||
_plugins: PluginArray = .{},
|
||||
_permissions: Permissions = .{},
|
||||
_storage: StorageManager = .{},
|
||||
|
||||
pub const init: Navigator = .{};
|
||||
|
||||
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
|
||||
return &self._plugins;
|
||||
}
|
||||
|
||||
pub fn getPermissions(self: *Navigator) *Permissions {
|
||||
return &self._permissions;
|
||||
}
|
||||
|
||||
pub fn getStorage(self: *Navigator) *StorageManager {
|
||||
return &self._storage;
|
||||
}
|
||||
|
||||
pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise {
|
||||
log.info(.not_implemented, "navigator.getBattery", .{});
|
||||
return page.js.local.?.rejectErrorPromise(.{ .dom_exception = error.NotSupported });
|
||||
}
|
||||
|
||||
pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
|
||||
try validateProtocolHandlerScheme(scheme);
|
||||
try validateProtocolHandlerURL(url, page);
|
||||
@@ -144,6 +165,7 @@ pub const JsApi = struct {
|
||||
pub const onLine = bridge.property(true, .{ .template = false });
|
||||
pub const cookieEnabled = bridge.property(true, .{ .template = false });
|
||||
pub const hardwareConcurrency = bridge.property(4, .{ .template = false });
|
||||
pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false });
|
||||
pub const maxTouchPoints = bridge.property(0, .{ .template = false });
|
||||
pub const vendor = bridge.property("", .{ .template = false });
|
||||
pub const product = bridge.property("Gecko", .{ .template = false });
|
||||
@@ -156,4 +178,12 @@ pub const JsApi = struct {
|
||||
|
||||
// Methods
|
||||
pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{});
|
||||
pub const getBattery = bridge.function(Navigator.getBattery, .{});
|
||||
pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
|
||||
pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "WebApi: Navigator" {
|
||||
try testing.htmlRunner("navigator", .{});
|
||||
}
|
||||
|
||||
94
src/browser/webapi/Permissions.zig
Normal file
94
src/browser/webapi/Permissions.zig
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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 js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
const Session = @import("../Session.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn registerTypes() []const type {
|
||||
return &.{ Permissions, PermissionStatus };
|
||||
}
|
||||
|
||||
const Permissions = @This();
|
||||
|
||||
// Padding to avoid zero-size struct pointer collisions
|
||||
_pad: bool = false,
|
||||
|
||||
const QueryDescriptor = struct {
|
||||
name: []const u8,
|
||||
};
|
||||
// We always report 'prompt' (the default safe value — neither granted nor denied).
|
||||
pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise {
|
||||
const arena = try page.getArena(.{ .debug = "PermissionStatus" });
|
||||
errdefer page.releaseArena(arena);
|
||||
|
||||
const status = try arena.create(PermissionStatus);
|
||||
status.* = .{
|
||||
._arena = arena,
|
||||
._state = "prompt",
|
||||
._name = try arena.dupe(u8, qd.name),
|
||||
};
|
||||
return page.js.local.?.resolvePromise(status);
|
||||
}
|
||||
|
||||
const PermissionStatus = struct {
|
||||
_arena: Allocator,
|
||||
_name: []const u8,
|
||||
_state: []const u8,
|
||||
|
||||
pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void {
|
||||
session.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
fn getName(self: *const PermissionStatus) []const u8 {
|
||||
return self._name;
|
||||
}
|
||||
|
||||
fn getState(self: *const PermissionStatus) []const u8 {
|
||||
return self._state;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(PermissionStatus);
|
||||
pub const Meta = struct {
|
||||
pub const name = "PermissionStatus";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(PermissionStatus.deinit);
|
||||
};
|
||||
pub const name = bridge.accessor(getName, null, .{});
|
||||
pub const state = bridge.accessor(getState, null, .{});
|
||||
};
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Permissions);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "Permissions";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const empty_with_no_proto = true;
|
||||
};
|
||||
|
||||
pub const query = bridge.function(Permissions.query, .{ .dom_exception = true });
|
||||
};
|
||||
@@ -40,6 +40,7 @@ _mode: Mode,
|
||||
_host: *Element,
|
||||
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
|
||||
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
|
||||
_adopted_style_sheets: ?js.Object.Global = null,
|
||||
|
||||
pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
|
||||
return page._factory.documentFragment(ShadowRoot{
|
||||
@@ -99,6 +100,20 @@ pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn getAdoptedStyleSheets(self: *ShadowRoot, page: *Page) !js.Object.Global {
|
||||
if (self._adopted_style_sheets) |ass| {
|
||||
return ass;
|
||||
}
|
||||
const js_arr = page.js.local.?.newArray(0);
|
||||
const js_obj = js_arr.toObject();
|
||||
self._adopted_style_sheets = try js_obj.persist();
|
||||
return self._adopted_style_sheets.?;
|
||||
}
|
||||
|
||||
pub fn setAdoptedStyleSheets(self: *ShadowRoot, sheets: js.Object) !void {
|
||||
self._adopted_style_sheets = try sheets.persist();
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(ShadowRoot);
|
||||
|
||||
@@ -121,6 +136,7 @@ pub const JsApi = struct {
|
||||
}
|
||||
return self.getElementById(try value.toZig([]const u8), page);
|
||||
}
|
||||
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
71
src/browser/webapi/StorageManager.zig
Normal file
71
src/browser/webapi/StorageManager.zig
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const js = @import("../js/js.zig");
|
||||
const Page = @import("../Page.zig");
|
||||
|
||||
pub fn registerTypes() []const type {
|
||||
return &.{ StorageManager, StorageEstimate };
|
||||
}
|
||||
|
||||
const StorageManager = @This();
|
||||
|
||||
_pad: bool = false,
|
||||
|
||||
pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise {
|
||||
const est = try page._factory.create(StorageEstimate{
|
||||
._usage = 0,
|
||||
._quota = 1024 * 1024 * 1024, // 1 GiB
|
||||
});
|
||||
return page.js.local.?.resolvePromise(est);
|
||||
}
|
||||
|
||||
const StorageEstimate = struct {
|
||||
_quota: u64,
|
||||
_usage: u64,
|
||||
|
||||
fn getUsage(self: *const StorageEstimate) u64 {
|
||||
return self._usage;
|
||||
}
|
||||
|
||||
fn getQuota(self: *const StorageEstimate) u64 {
|
||||
return self._quota;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(StorageEstimate);
|
||||
pub const Meta = struct {
|
||||
pub const name = "StorageEstimate";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
};
|
||||
pub const quota = bridge.accessor(getQuota, null, .{});
|
||||
pub const usage = bridge.accessor(getUsage, null, .{});
|
||||
};
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(StorageManager);
|
||||
pub const Meta = struct {
|
||||
pub const name = "StorageManager";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const empty_with_no_proto = true;
|
||||
};
|
||||
pub const estimate = bridge.function(StorageManager.estimate, .{});
|
||||
};
|
||||
@@ -66,7 +66,10 @@ _on_load: ?js.Function.Global = null,
|
||||
_on_pageshow: ?js.Function.Global = null,
|
||||
_on_popstate: ?js.Function.Global = null,
|
||||
_on_error: ?js.Function.Global = null,
|
||||
_on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error
|
||||
_on_message: ?js.Function.Global = null,
|
||||
_on_rejection_handled: ?js.Function.Global = null,
|
||||
_on_unhandled_rejection: ?js.Function.Global = null,
|
||||
_current_event: ?*Event = null,
|
||||
_location: *Location,
|
||||
_timer_id: u30 = 0,
|
||||
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
|
||||
@@ -89,6 +92,10 @@ pub fn asEventTarget(self: *Window) *EventTarget {
|
||||
return self._proto;
|
||||
}
|
||||
|
||||
pub fn getEvent(self: *const Window) ?*Event {
|
||||
return self._current_event;
|
||||
}
|
||||
|
||||
pub fn getSelf(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
@@ -208,6 +215,22 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void {
|
||||
self._on_error = getFunctionFromSetter(setter);
|
||||
}
|
||||
|
||||
pub fn getOnMessage(self: *const Window) ?js.Function.Global {
|
||||
return self._on_message;
|
||||
}
|
||||
|
||||
pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void {
|
||||
self._on_message = getFunctionFromSetter(setter);
|
||||
}
|
||||
|
||||
pub fn getOnRejectionHandled(self: *const Window) ?js.Function.Global {
|
||||
return self._on_rejection_handled;
|
||||
}
|
||||
|
||||
pub fn setOnRejectionHandled(self: *Window, setter: ?FunctionSetter) void {
|
||||
self._on_rejection_handled = getFunctionFromSetter(setter);
|
||||
}
|
||||
|
||||
pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global {
|
||||
return self._on_unhandled_rejection;
|
||||
}
|
||||
@@ -334,7 +357,11 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
|
||||
|
||||
const event = error_event.asEvent();
|
||||
event._prevent_default = prevent_default;
|
||||
try page._event_manager.dispatch(self.asEventTarget(), event);
|
||||
// Pass null as handler: onerror was already called above with 5 args.
|
||||
// We still dispatch so that addEventListener('error', ...) listeners fire.
|
||||
try page._event_manager.dispatchDirect(self.asEventTarget(), event, null, .{
|
||||
.context = "window.reportError",
|
||||
});
|
||||
|
||||
if (comptime builtin.is_test == false) {
|
||||
if (!event._prevent_default) {
|
||||
@@ -369,19 +396,26 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
|
||||
// In a full implementation, we would validate the origin
|
||||
_ = target_origin;
|
||||
|
||||
// postMessage queues a task (not a microtask), so use the scheduler
|
||||
const arena = try page.getArena(.{ .debug = "Window.schedule" });
|
||||
errdefer page.releaseArena(arena);
|
||||
// self = the window that will get the message
|
||||
// page = the context calling postMessage
|
||||
const target_page = self._page;
|
||||
const source_window = target_page.js.getIncumbent().window;
|
||||
|
||||
const origin = try self._location.getOrigin(page);
|
||||
const arena = try target_page.getArena(.{ .debug = "Window.postMessage" });
|
||||
errdefer target_page.releaseArena(arena);
|
||||
|
||||
// Origin should be the source window's origin (where the message came from)
|
||||
const origin = try source_window._location.getOrigin(page);
|
||||
const callback = try arena.create(PostMessageCallback);
|
||||
callback.* = .{
|
||||
.page = page,
|
||||
.arena = arena,
|
||||
.message = message,
|
||||
.page = target_page,
|
||||
.source = source_window,
|
||||
.origin = try arena.dupe(u8, origin),
|
||||
};
|
||||
try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
|
||||
|
||||
try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
|
||||
.name = "postMessage",
|
||||
.low_priority = false,
|
||||
.finalizer = PostMessageCallback.cancelled,
|
||||
@@ -547,7 +581,7 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
|
||||
return self.scrollTo(.{ .x = absx }, absy, page);
|
||||
}
|
||||
|
||||
pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void {
|
||||
pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.js, "unhandled rejection", .{
|
||||
.value = rejection.reason(),
|
||||
@@ -555,13 +589,20 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
|
||||
});
|
||||
}
|
||||
|
||||
const event_name, const attribute_callback = blk: {
|
||||
if (no_handler) {
|
||||
break :blk .{ "unhandledrejection", self._on_unhandled_rejection };
|
||||
}
|
||||
break :blk .{ "rejectionhandled", self._on_rejection_handled };
|
||||
};
|
||||
|
||||
const target = self.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
|
||||
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
|
||||
if (page._event_manager.hasDirectListeners(target, event_name, attribute_callback)) {
|
||||
const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
|
||||
.reason = if (rejection.reason()) |r| try r.temp() else null,
|
||||
.promise = try rejection.promise().temp(),
|
||||
}, page)).asEvent();
|
||||
try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
|
||||
try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,6 +743,7 @@ const ScheduleCallback = struct {
|
||||
|
||||
const PostMessageCallback = struct {
|
||||
page: *Page,
|
||||
source: *Window,
|
||||
arena: Allocator,
|
||||
origin: []const u8,
|
||||
message: js.Value.Temp,
|
||||
@@ -712,7 +754,7 @@ const PostMessageCallback = struct {
|
||||
|
||||
fn cancelled(ctx: *anyopaque) void {
|
||||
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
|
||||
self.page.releaseArena(self.arena);
|
||||
self.deinit();
|
||||
}
|
||||
|
||||
fn run(ctx: *anyopaque) !?u32 {
|
||||
@@ -722,14 +764,17 @@ const PostMessageCallback = struct {
|
||||
const page = self.page;
|
||||
const window = page.window;
|
||||
|
||||
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
|
||||
.data = self.message,
|
||||
.origin = self.origin,
|
||||
.source = window,
|
||||
.bubbles = false,
|
||||
.cancelable = false,
|
||||
}, page)).asEvent();
|
||||
try page._event_manager.dispatch(window.asEventTarget(), event);
|
||||
const event_target = window.asEventTarget();
|
||||
if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
|
||||
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
|
||||
.data = self.message,
|
||||
.origin = self.origin,
|
||||
.source = self.source,
|
||||
.bubbles = false,
|
||||
.cancelable = false,
|
||||
}, page)).asEvent();
|
||||
try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -783,7 +828,10 @@ pub const JsApi = struct {
|
||||
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
|
||||
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
|
||||
pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{});
|
||||
pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{});
|
||||
pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{});
|
||||
pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});
|
||||
pub const event = bridge.accessor(Window.getEvent, null, .{ .null_as_undefined = true });
|
||||
pub const fetch = bridge.function(Window.fetch, .{});
|
||||
pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});
|
||||
pub const setTimeout = bridge.function(Window.setTimeout, .{});
|
||||
@@ -853,3 +901,7 @@ test "WebApi: Window" {
|
||||
test "WebApi: Window scroll" {
|
||||
try testing.htmlRunner("window_scroll.html", .{});
|
||||
}
|
||||
|
||||
test "WebApi: Window.onerror" {
|
||||
try testing.htmlRunner("event/report_error.html", .{});
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ pub const JsApi = struct {
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "WebApi: CSSStyleSheet" {
|
||||
const filter: testing.LogFilter = .init(.js);
|
||||
const filter: testing.LogFilter = .init(&.{.js});
|
||||
defer filter.deinit();
|
||||
try testing.htmlRunner("css/stylesheet.html", .{});
|
||||
}
|
||||
|
||||
@@ -117,6 +117,47 @@ pub fn submit(self: *Form, page: *Page) !void {
|
||||
return page.submitForm(null, self, .{ .fire_event = false });
|
||||
}
|
||||
|
||||
/// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit
|
||||
/// Like submit(), but fires the submit event and validates the form.
|
||||
pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void {
|
||||
const submitter_element = if (submitter) |s| blk: {
|
||||
// The submitter must be a submit button.
|
||||
if (!isSubmitButton(s)) return error.TypeError;
|
||||
|
||||
// The submitter's form owner must be this form element.
|
||||
const submitter_form = getFormOwner(s, page);
|
||||
if (submitter_form == null or submitter_form.? != self) return error.NotFound;
|
||||
|
||||
break :blk s;
|
||||
} else self.asElement();
|
||||
|
||||
return page.submitForm(submitter_element, self, .{});
|
||||
}
|
||||
|
||||
/// Returns true if the element is a submit button per the HTML spec:
|
||||
/// - <input type="submit"> or <input type="image">
|
||||
/// - <button type="submit"> (including default, since button's default type is "submit")
|
||||
fn isSubmitButton(element: *Element) bool {
|
||||
if (element.is(Input)) |input| {
|
||||
return input._input_type == .submit or input._input_type == .image;
|
||||
}
|
||||
if (element.is(Button)) |button| {
|
||||
return std.mem.eql(u8, button.getType(), "submit");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns the form owner of a submittable element (Input or Button).
|
||||
fn getFormOwner(element: *Element, page: *Page) ?*Form {
|
||||
if (element.is(Input)) |input| {
|
||||
return input.getForm(page);
|
||||
}
|
||||
if (element.is(Button)) |button| {
|
||||
return button.getForm(page);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(Form);
|
||||
pub const Meta = struct {
|
||||
@@ -132,6 +173,7 @@ pub const JsApi = struct {
|
||||
pub const elements = bridge.accessor(Form.getElements, null, .{});
|
||||
pub const length = bridge.accessor(Form.getLength, null, .{});
|
||||
pub const submit = bridge.function(Form.submit, .{});
|
||||
pub const requestSubmit = bridge.function(Form.requestSubmit, .{ .dom_exception = true });
|
||||
};
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
|
||||
@@ -28,6 +28,7 @@ const HtmlElement = @import("../Html.zig");
|
||||
const Form = @import("Form.zig");
|
||||
const Selection = @import("../../Selection.zig");
|
||||
const Event = @import("../../Event.zig");
|
||||
const InputEvent = @import("../../event/InputEvent.zig");
|
||||
|
||||
const Input = @This();
|
||||
|
||||
@@ -103,6 +104,11 @@ fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void {
|
||||
try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
|
||||
}
|
||||
|
||||
fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
|
||||
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
|
||||
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
|
||||
}
|
||||
|
||||
pub fn asElement(self: *Input) *Element {
|
||||
return self._proto._proto;
|
||||
}
|
||||
@@ -425,6 +431,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void {
|
||||
try self.setValue(new_value, page);
|
||||
},
|
||||
}
|
||||
try self.dispatchInputEvent(str, "insertText", page);
|
||||
}
|
||||
|
||||
pub fn getSelectionDirection(self: *const Input) []const u8 {
|
||||
|
||||
@@ -93,13 +93,21 @@ pub fn linkAddedCallback(self: *Link, page: *Page) !void {
|
||||
}
|
||||
|
||||
const element = self.asElement();
|
||||
// Exit if rel not set.
|
||||
|
||||
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
|
||||
// Exit if rel is not stylesheet.
|
||||
if (!std.mem.eql(u8, rel, "stylesheet")) return;
|
||||
// Exit if href not set.
|
||||
const loadable_rels = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "stylesheet", {} },
|
||||
.{ "preload", {} },
|
||||
.{ "modulepreload", {} },
|
||||
});
|
||||
if (loadable_rels.has(rel) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
|
||||
if (href.len == 0) return;
|
||||
if (href.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try page._to_load.append(page.arena, self._proto);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const HtmlElement = @import("../Html.zig");
|
||||
const Form = @import("Form.zig");
|
||||
const Selection = @import("../../Selection.zig");
|
||||
const Event = @import("../../Event.zig");
|
||||
const InputEvent = @import("../../event/InputEvent.zig");
|
||||
|
||||
const TextArea = @This();
|
||||
|
||||
@@ -55,6 +56,11 @@ fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void {
|
||||
try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
|
||||
}
|
||||
|
||||
fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
|
||||
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
|
||||
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
|
||||
}
|
||||
|
||||
pub fn asElement(self: *TextArea) *Element {
|
||||
return self._proto._proto;
|
||||
}
|
||||
@@ -189,6 +195,7 @@ pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void {
|
||||
try self.setValue(new_value, page);
|
||||
},
|
||||
}
|
||||
try self.dispatchInputEvent(str, "insertText", page);
|
||||
}
|
||||
|
||||
pub fn getSelectionDirection(self: *const TextArea) []const u8 {
|
||||
|
||||
121
src/browser/webapi/event/InputEvent.zig
Normal file
121
src/browser/webapi/event/InputEvent.zig
Normal file
@@ -0,0 +1,121 @@
|
||||
// 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 String = @import("../../../string.zig").String;
|
||||
const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
const js = @import("../../js/js.zig");
|
||||
|
||||
const Event = @import("../Event.zig");
|
||||
const UIEvent = @import("UIEvent.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const InputEvent = @This();
|
||||
|
||||
_proto: *UIEvent,
|
||||
_data: ?[]const u8,
|
||||
// TODO: add dataTransfer
|
||||
_input_type: []const u8,
|
||||
_is_composing: bool,
|
||||
|
||||
pub const InputEventOptions = struct {
|
||||
data: ?[]const u8 = null,
|
||||
inputType: ?[]const u8 = null,
|
||||
isComposing: bool = false,
|
||||
};
|
||||
|
||||
const Options = Event.inheritOptions(
|
||||
InputEvent,
|
||||
InputEventOptions,
|
||||
);
|
||||
|
||||
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*InputEvent {
|
||||
const arena = try page.getArena(.{ .debug = "InputEvent.trusted" });
|
||||
errdefer page.releaseArena(arena);
|
||||
return initWithTrusted(arena, typ, _opts, true, page);
|
||||
}
|
||||
|
||||
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*InputEvent {
|
||||
const arena = try page.getArena(.{ .debug = "InputEvent" });
|
||||
errdefer page.releaseArena(arena);
|
||||
const type_string = try String.init(arena, typ, .{});
|
||||
return initWithTrusted(arena, type_string, _opts, false, page);
|
||||
}
|
||||
|
||||
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*InputEvent {
|
||||
const opts = _opts orelse Options{};
|
||||
|
||||
const event = try page._factory.uiEvent(
|
||||
arena,
|
||||
typ,
|
||||
InputEvent{
|
||||
._proto = undefined,
|
||||
._data = if (opts.data) |d| try arena.dupe(u8, d) else null,
|
||||
._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "",
|
||||
._is_composing = opts.isComposing,
|
||||
},
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event
|
||||
const rootevt = event._proto._proto;
|
||||
rootevt._bubbles = true;
|
||||
rootevt._cancelable = false;
|
||||
rootevt._composed = true;
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *InputEvent, shutdown: bool, session: *Session) void {
|
||||
self._proto.deinit(shutdown, session);
|
||||
}
|
||||
|
||||
pub fn asEvent(self: *InputEvent) *Event {
|
||||
return self._proto.asEvent();
|
||||
}
|
||||
|
||||
pub fn getData(self: *const InputEvent) ?[]const u8 {
|
||||
return self._data;
|
||||
}
|
||||
|
||||
pub fn getInputType(self: *const InputEvent) []const u8 {
|
||||
return self._input_type;
|
||||
}
|
||||
|
||||
pub fn getIsComposing(self: *const InputEvent) bool {
|
||||
return self._is_composing;
|
||||
}
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = js.Bridge(InputEvent);
|
||||
|
||||
pub const Meta = struct {
|
||||
pub const name = "InputEvent";
|
||||
pub const prototype_chain = bridge.prototypeChain();
|
||||
pub var class_id: bridge.ClassId = undefined;
|
||||
pub const weak = true;
|
||||
pub const finalizer = bridge.finalizer(InputEvent.deinit);
|
||||
};
|
||||
|
||||
pub const constructor = bridge.constructor(InputEvent.init, .{});
|
||||
pub const data = bridge.accessor(InputEvent.getData, null, .{});
|
||||
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
|
||||
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
|
||||
};
|
||||
@@ -219,6 +219,13 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
|
||||
// https://w3c.github.io/uievents/#event-type-keyup
|
||||
const rootevt = event._proto._proto;
|
||||
rootevt._bubbles = true;
|
||||
rootevt._cancelable = true;
|
||||
rootevt._composed = true;
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const EventTarget = @import("../EventTarget.zig");
|
||||
const UIEvent = @import("UIEvent.zig");
|
||||
const PointerEvent = @import("PointerEvent.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const MouseEvent = @This();
|
||||
|
||||
pub const MouseButton = enum(u8) {
|
||||
@@ -83,12 +85,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
|
||||
const arena = try page.getArena(.{ .debug = "MouseEvent" });
|
||||
errdefer page.releaseArena(arena);
|
||||
const type_string = try String.init(arena, typ, .{});
|
||||
return initWithTrusted(arena, type_string, _opts, false, page);
|
||||
}
|
||||
|
||||
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent {
|
||||
const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" });
|
||||
errdefer page.releaseArena(arena);
|
||||
return initWithTrusted(arena, typ, _opts, true, page);
|
||||
}
|
||||
|
||||
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent {
|
||||
const opts = _opts orelse Options{};
|
||||
|
||||
const event = try page._factory.uiEvent(
|
||||
arena,
|
||||
type_string,
|
||||
typ,
|
||||
MouseEvent{
|
||||
._type = .generic,
|
||||
._proto = undefined,
|
||||
@@ -106,7 +117,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
|
||||
},
|
||||
);
|
||||
|
||||
Event.populatePrototypes(event, opts, false);
|
||||
Event.populatePrototypes(event, opts, trusted);
|
||||
return event;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ pub const Type = union(enum) {
|
||||
keyboard_event: *@import("KeyboardEvent.zig"),
|
||||
focus_event: *@import("FocusEvent.zig"),
|
||||
text_event: *@import("TextEvent.zig"),
|
||||
input_event: *@import("InputEvent.zig"),
|
||||
};
|
||||
|
||||
pub const UIEventOptions = struct {
|
||||
@@ -88,6 +89,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T {
|
||||
.keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null,
|
||||
.focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null,
|
||||
.text_event => |e| return if (T == @import("TextEvent.zig")) e else null,
|
||||
.input_event => |e| return if (T == @import("InputEvent.zig")) e else null,
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,11 @@ const js = @import("../../js/js.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const URL = @import("../../URL.zig");
|
||||
|
||||
const Blob = @import("../Blob.zig");
|
||||
const Request = @import("Request.zig");
|
||||
const Response = @import("Response.zig");
|
||||
const AbortSignal = @import("../AbortSignal.zig");
|
||||
const DOMException = @import("../DOMException.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
@@ -38,17 +41,29 @@ _buf: std.ArrayList(u8),
|
||||
_response: *Response,
|
||||
_resolver: js.PromiseResolver.Global,
|
||||
_owns_response: bool,
|
||||
_signal: ?*AbortSignal,
|
||||
|
||||
pub const Input = Request.Input;
|
||||
pub const InitOpts = Request.InitOpts;
|
||||
|
||||
pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
|
||||
const request = try Request.init(input, options, page);
|
||||
const resolver = page.js.local.?.createPromiseResolver();
|
||||
|
||||
if (request._signal) |signal| {
|
||||
if (signal._aborted) {
|
||||
resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError"));
|
||||
return resolver.promise();
|
||||
}
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, request._url, "blob:")) {
|
||||
return handleBlobUrl(request._url, resolver, page);
|
||||
}
|
||||
|
||||
const response = try Response.init(null, .{ .status = 0 }, page);
|
||||
errdefer response.deinit(true, page._session);
|
||||
|
||||
const resolver = page.js.local.?.createPromiseResolver();
|
||||
|
||||
const fetch = try response._arena.create(Fetch);
|
||||
fetch.* = .{
|
||||
._page = page,
|
||||
@@ -57,6 +72,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
|
||||
._resolver = try resolver.persist(),
|
||||
._response = response,
|
||||
._owns_response = true,
|
||||
._signal = request._signal,
|
||||
};
|
||||
|
||||
const http_client = page._session.browser.http_client;
|
||||
@@ -90,6 +106,26 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js.Promise {
|
||||
const blob: *Blob = page.lookupBlobUrl(url) orelse {
|
||||
resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" });
|
||||
return resolver.promise();
|
||||
};
|
||||
|
||||
const response = try Response.init(null, .{ .status = 200 }, page);
|
||||
response._body = try response._arena.dupe(u8, blob._slice);
|
||||
response._url = try response._arena.dupeZ(u8, url);
|
||||
response._type = .basic;
|
||||
|
||||
if (blob._mime.len > 0) {
|
||||
try response._headers.append("Content-Type", blob._mime, page);
|
||||
}
|
||||
|
||||
const js_val = try page.js.local.?.zigValueToJs(response, .{});
|
||||
resolver.resolve("fetch blob done", js_val);
|
||||
return resolver.promise();
|
||||
}
|
||||
|
||||
fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
|
||||
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
|
||||
if (comptime IS_DEBUG) {
|
||||
@@ -101,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
|
||||
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
if (self._signal) |signal| {
|
||||
if (signal._aborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const arena = self._response._arena;
|
||||
if (transfer.getContentLength()) |cl| {
|
||||
try self._buf.ensureTotalCapacity(arena, cl);
|
||||
@@ -150,6 +192,14 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
|
||||
|
||||
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
|
||||
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
|
||||
|
||||
// Check if aborted
|
||||
if (self._signal) |signal| {
|
||||
if (signal._aborted) {
|
||||
return error.Abort;
|
||||
}
|
||||
}
|
||||
|
||||
try self._buf.appendSlice(self._response._arena, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const URL = @import("../URL.zig");
|
||||
const Page = @import("../../Page.zig");
|
||||
const Headers = @import("Headers.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const AbortSignal = @import("../AbortSignal.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Request = @This();
|
||||
@@ -36,6 +37,7 @@ _body: ?[]const u8,
|
||||
_arena: Allocator,
|
||||
_cache: Cache,
|
||||
_credentials: Credentials,
|
||||
_signal: ?*AbortSignal,
|
||||
|
||||
pub const Input = union(enum) {
|
||||
request: *Request,
|
||||
@@ -48,6 +50,7 @@ pub const InitOpts = struct {
|
||||
body: ?[]const u8 = null,
|
||||
cache: Cache = .default,
|
||||
credentials: Credentials = .@"same-origin",
|
||||
signal: ?*AbortSignal = null,
|
||||
};
|
||||
|
||||
const Credentials = enum {
|
||||
@@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
|
||||
.request => |r| r._body,
|
||||
};
|
||||
|
||||
const signal = if (opts.signal) |s|
|
||||
s
|
||||
else switch (input) {
|
||||
.url => null,
|
||||
.request => |r| r._signal,
|
||||
};
|
||||
|
||||
return page._factory.create(Request{
|
||||
._url = url,
|
||||
._arena = arena,
|
||||
@@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
|
||||
._cache = opts.cache,
|
||||
._credentials = opts.credentials,
|
||||
._body = body,
|
||||
._signal = signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 {
|
||||
return @tagName(self._credentials);
|
||||
}
|
||||
|
||||
pub fn getSignal(self: *const Request) ?*AbortSignal {
|
||||
return self._signal;
|
||||
}
|
||||
|
||||
pub fn getHeaders(self: *Request, page: *Page) !*Headers {
|
||||
if (self._headers) |headers| {
|
||||
return headers;
|
||||
@@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request {
|
||||
._cache = self._cache,
|
||||
._credentials = self._credentials,
|
||||
._body = self._body,
|
||||
._signal = self._signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,6 +234,7 @@ pub const JsApi = struct {
|
||||
pub const headers = bridge.accessor(Request.getHeaders, null, .{});
|
||||
pub const cache = bridge.accessor(Request.getCache, null, .{});
|
||||
pub const credentials = bridge.accessor(Request.getCredentials, null, .{});
|
||||
pub const signal = bridge.accessor(Request.getSignal, null, .{});
|
||||
pub const blob = bridge.function(Request.blob, .{});
|
||||
pub const text = bridge.function(Request.text, .{});
|
||||
pub const json = bridge.function(Request.json, .{});
|
||||
|
||||
@@ -29,6 +29,7 @@ const Page = @import("../../Page.zig");
|
||||
const Session = @import("../../Session.zig");
|
||||
|
||||
const Node = @import("../Node.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const Event = @import("../Event.zig");
|
||||
const Headers = @import("Headers.zig");
|
||||
const EventTarget = @import("../EventTarget.zig");
|
||||
@@ -211,6 +212,11 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
}
|
||||
|
||||
const page = self._page;
|
||||
|
||||
if (std.mem.startsWith(u8, self._url, "blob:")) {
|
||||
return self.handleBlobUrl(page);
|
||||
}
|
||||
|
||||
const http_client = page._session.browser.http_client;
|
||||
var headers = try http_client.newHeaders();
|
||||
|
||||
@@ -242,6 +248,39 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
|
||||
page.js.strongRef(self);
|
||||
}
|
||||
|
||||
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
|
||||
const blob = page.lookupBlobUrl(self._url) orelse {
|
||||
self.handleError(error.BlobNotFound);
|
||||
return;
|
||||
};
|
||||
|
||||
self._response_status = 200;
|
||||
self._response_url = self._url;
|
||||
|
||||
try self._response_data.appendSlice(self._arena, blob._slice);
|
||||
self._response_len = blob._slice.len;
|
||||
|
||||
try self.stateChanged(.headers_received, page);
|
||||
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page);
|
||||
try self.stateChanged(.loading, page);
|
||||
try self._proto.dispatch(.progress, .{
|
||||
.total = self._response_len orelse 0,
|
||||
.loaded = self._response_data.items.len,
|
||||
}, page);
|
||||
try self.stateChanged(.done, page);
|
||||
|
||||
const loaded = self._response_data.items.len;
|
||||
try self._proto.dispatch(.load, .{
|
||||
.total = loaded,
|
||||
.loaded = loaded,
|
||||
}, page);
|
||||
try self._proto.dispatch(.load_end, .{
|
||||
.total = loaded,
|
||||
.loaded = loaded,
|
||||
}, page);
|
||||
}
|
||||
|
||||
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
|
||||
return @intFromEnum(self._ready_state);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
@@ -24,6 +25,7 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
setFocusEmulationEnabled,
|
||||
setDeviceMetricsOverride,
|
||||
setTouchEmulationEnabled,
|
||||
setUserAgentOverride,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -31,6 +33,7 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
|
||||
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
|
||||
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
|
||||
.setUserAgentOverride => return setUserAgentOverride(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,3 +67,8 @@ fn setDeviceMetricsOverride(cmd: anytype) !void {
|
||||
fn setTouchEmulationEnabled(cmd: anytype) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn setUserAgentOverride(cmd: anytype) !void {
|
||||
log.info(.app, "setUserAgentOverride ignored", .{});
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ const std = @import("std");
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
disable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.disable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
getSemanticTree,
|
||||
getInteractiveElements,
|
||||
getStructuredData,
|
||||
clickNode,
|
||||
fillNode,
|
||||
scrollNode,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -39,6 +42,9 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
.getSemanticTree => return getSemanticTree(cmd),
|
||||
.getInteractiveElements => return getInteractiveElements(cmd),
|
||||
.getStructuredData => return getStructuredData(cmd),
|
||||
.clickNode => return clickNode(cmd),
|
||||
.fillNode => return fillNode(cmd),
|
||||
.scrollNode => return scrollNode(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,24 +52,32 @@ fn getSemanticTree(cmd: anytype) !void {
|
||||
const Params = struct {
|
||||
format: ?enum { text } = null,
|
||||
prune: ?bool = null,
|
||||
interactiveOnly: ?bool = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
maxDepth: ?u32 = null,
|
||||
};
|
||||
const params = (try cmd.params(Params)) orelse Params{};
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const dom_node = page.document.asNode();
|
||||
|
||||
const dom_node = if (params.backendNodeId) |nodeId|
|
||||
(bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom
|
||||
else
|
||||
page.document.asNode();
|
||||
|
||||
var st = SemanticTree{
|
||||
.dom_node = dom_node,
|
||||
.registry = &bc.node_registry,
|
||||
.page = page,
|
||||
.arena = cmd.arena,
|
||||
.prune = params.prune orelse false,
|
||||
.prune = params.prune orelse true,
|
||||
.interactive_only = params.interactiveOnly orelse false,
|
||||
.max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1,
|
||||
};
|
||||
|
||||
if (params.format) |format| {
|
||||
if (format == .text) {
|
||||
st.prune = params.prune orelse true;
|
||||
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
|
||||
defer aw.deinit();
|
||||
try st.textStringify(&aw.writer);
|
||||
@@ -146,6 +160,76 @@ fn getStructuredData(cmd: anytype) !void {
|
||||
}, .{});
|
||||
}
|
||||
|
||||
fn clickNode(cmd: anytype) !void {
|
||||
const Params = struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
};
|
||||
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
|
||||
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
|
||||
|
||||
lp.actions.click(node.dom, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||
return error.InternalError;
|
||||
};
|
||||
|
||||
return cmd.sendResult(.{}, .{});
|
||||
}
|
||||
|
||||
fn fillNode(cmd: anytype) !void {
|
||||
const Params = struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
text: []const u8,
|
||||
};
|
||||
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
|
||||
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
|
||||
|
||||
lp.actions.fill(node.dom, params.text, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||
return error.InternalError;
|
||||
};
|
||||
|
||||
return cmd.sendResult(.{}, .{});
|
||||
}
|
||||
|
||||
fn scrollNode(cmd: anytype) !void {
|
||||
const Params = struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
x: ?i32 = null,
|
||||
y: ?i32 = null,
|
||||
};
|
||||
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
const maybe_node_id = params.nodeId orelse params.backendNodeId;
|
||||
|
||||
var target_node: ?*DOMNode = null;
|
||||
if (maybe_node_id) |node_id| {
|
||||
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
|
||||
target_node = node.dom;
|
||||
}
|
||||
|
||||
lp.actions.scroll(target_node, params.x, params.y, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) return error.InvalidParam;
|
||||
return error.InternalError;
|
||||
};
|
||||
|
||||
return cmd.sendResult(.{}, .{});
|
||||
}
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.lp: getMarkdown" {
|
||||
var ctx = testing.context();
|
||||
@@ -195,3 +279,63 @@ test "cdp.lp: getStructuredData" {
|
||||
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
|
||||
try testing.expect(result.get("structuredData") != null);
|
||||
}
|
||||
|
||||
test "cdp.lp: action tools" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
const page = try bc.session.createPage();
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
_ = bc.session.wait(5000);
|
||||
|
||||
// Test Click
|
||||
const btn = page.document.getElementById("btn", page).?.asNode();
|
||||
const btn_id = (try bc.node_registry.register(btn)).id;
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "LP.clickNode",
|
||||
.params = .{ .backendNodeId = btn_id },
|
||||
});
|
||||
|
||||
// Test Fill Input
|
||||
const inp = page.document.getElementById("inp", page).?.asNode();
|
||||
const inp_id = (try bc.node_registry.register(inp)).id;
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "LP.fillNode",
|
||||
.params = .{ .backendNodeId = inp_id, .text = "hello" },
|
||||
});
|
||||
|
||||
// Test Fill Select
|
||||
const sel = page.document.getElementById("sel", page).?.asNode();
|
||||
const sel_id = (try bc.node_registry.register(sel)).id;
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "LP.fillNode",
|
||||
.params = .{ .backendNodeId = sel_id, .text = "opt2" },
|
||||
});
|
||||
|
||||
// Test Scroll
|
||||
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
|
||||
const scrollbox_id = (try bc.node_registry.register(scrollbox)).id;
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "LP.scrollNode",
|
||||
.params = .{ .backendNodeId = scrollbox_id, .y = 50 },
|
||||
});
|
||||
|
||||
// Evaluate assertions
|
||||
var ls: lp.js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: lp.js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
|
||||
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ test "cdp.page: getFrameTree" {
|
||||
|
||||
test "cdp.page: captureScreenshot" {
|
||||
const LogFilter = @import("../../testing.zig").LogFilter;
|
||||
const filter: LogFilter = .init(.not_implemented);
|
||||
const filter: LogFilter = .init(&.{.not_implemented});
|
||||
defer filter.deinit();
|
||||
|
||||
var ctx = testing.context();
|
||||
|
||||
@@ -21,9 +21,11 @@ const std = @import("std");
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
disable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.disable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ const std = @import("std");
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
disable,
|
||||
setIgnoreCertificateErrors,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.disable => return cmd.sendResult(null, .{}),
|
||||
.setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
|
||||
pub const SemanticTree = @import("SemanticTree.zig");
|
||||
pub const CDPNode = @import("cdp/Node.zig");
|
||||
pub const interactive = @import("browser/interactive.zig");
|
||||
pub const actions = @import("browser/actions.zig");
|
||||
pub const structured_data = @import("browser/structured_data.zig");
|
||||
pub const mcp = @import("mcp.zig");
|
||||
pub const build_config = @import("build_config");
|
||||
|
||||
63
src/main.zig
63
src/main.zig
@@ -59,7 +59,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
return std.process.cleanExit();
|
||||
},
|
||||
.version => {
|
||||
std.debug.print("{s}\n", .{lp.build_config.git_commit});
|
||||
if (lp.build_config.git_version) |version| {
|
||||
std.debug.print("{s} ({s})\n", .{ version, lp.build_config.git_commit });
|
||||
} else {
|
||||
std.debug.print("{s}\n", .{lp.build_config.git_commit});
|
||||
}
|
||||
return std.process.cleanExit();
|
||||
},
|
||||
else => {},
|
||||
@@ -75,18 +79,21 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
log.opts.filter_scopes = lfs;
|
||||
}
|
||||
|
||||
// must be installed before any other threads
|
||||
const sighandler = try main_arena.create(SigHandler);
|
||||
sighandler.* = .{ .arena = main_arena };
|
||||
try sighandler.install();
|
||||
|
||||
// _app is global to handle graceful shutdown.
|
||||
var app = try App.init(allocator, &args);
|
||||
|
||||
defer app.deinit();
|
||||
|
||||
try sighandler.on(lp.Network.stop, .{&app.network});
|
||||
|
||||
app.telemetry.record(.{ .run = {} });
|
||||
|
||||
switch (args.mode) {
|
||||
.serve => |opts| {
|
||||
const sighandler = try main_arena.create(SigHandler);
|
||||
sighandler.* = .{ .arena = main_arena };
|
||||
try sighandler.install();
|
||||
|
||||
log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() });
|
||||
const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| {
|
||||
log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port });
|
||||
@@ -94,12 +101,22 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
};
|
||||
|
||||
var server = lp.Server.init(app, address) catch |err| {
|
||||
log.fatal(.app, "server run error", .{ .err = err });
|
||||
if (err == error.AddressInUse) {
|
||||
log.fatal(.app, "address already in use", .{
|
||||
.host = opts.host,
|
||||
.port = opts.port,
|
||||
.hint = "Another process is already listening on this address. " ++
|
||||
"Stop the other process or use --port to choose a different port.",
|
||||
});
|
||||
} else {
|
||||
log.fatal(.app, "server run error", .{ .err = err });
|
||||
}
|
||||
return err;
|
||||
};
|
||||
defer server.deinit();
|
||||
|
||||
try sighandler.on(lp.Network.stop, .{&app.network});
|
||||
try sighandler.on(lp.Server.shutdown, .{server});
|
||||
|
||||
app.network.run();
|
||||
},
|
||||
.fetch => |opts| {
|
||||
@@ -122,10 +139,10 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
fetch_opts.writer = &writer.interface;
|
||||
}
|
||||
|
||||
lp.fetch(app, url, fetch_opts) catch |err| {
|
||||
log.fatal(.app, "fetch error", .{ .err = err, .url = url });
|
||||
return err;
|
||||
};
|
||||
var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts });
|
||||
defer worker_thread.join();
|
||||
|
||||
app.network.run();
|
||||
},
|
||||
.mcp => {
|
||||
log.info(.mcp, "starting server", .{});
|
||||
@@ -137,11 +154,27 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
||||
var mcp_server: *lp.mcp.Server = try .init(allocator, app, &stdout.interface);
|
||||
defer mcp_server.deinit();
|
||||
|
||||
var stdin_buf: [64 * 1024]u8 = undefined;
|
||||
var stdin = std.fs.File.stdin().reader(&stdin_buf);
|
||||
var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ mcp_server, app });
|
||||
defer worker_thread.join();
|
||||
|
||||
try lp.mcp.router.processRequests(mcp_server, &stdin.interface);
|
||||
app.network.run();
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
fn fetchThread(app: *App, url: [:0]const u8, fetch_opts: lp.FetchOpts) void {
|
||||
defer app.network.stop();
|
||||
lp.fetch(app, url, fetch_opts) catch |err| {
|
||||
log.fatal(.app, "fetch error", .{ .err = err, .url = url });
|
||||
};
|
||||
}
|
||||
|
||||
fn mcpThread(mcp_server: *lp.mcp.Server, app: *App) void {
|
||||
defer app.network.stop();
|
||||
var stdin_buf: [64 * 1024]u8 = undefined;
|
||||
var stdin = std.fs.File.stdin().reader(&stdin_buf);
|
||||
lp.mcp.router.processRequests(mcp_server, &stdin.interface) catch |err| {
|
||||
log.fatal(.mcp, "mcp error", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
|
||||
|
||||
// 5. Parse error
|
||||
{
|
||||
const filter: testing.LogFilter = .init(.mcp);
|
||||
const filter: testing.LogFilter = .init(&.{.mcp});
|
||||
defer filter.deinit();
|
||||
|
||||
try handleMessage(server, aa, "invalid json");
|
||||
|
||||
@@ -5,6 +5,7 @@ const log = lp.log;
|
||||
const js = lp.js;
|
||||
|
||||
const Element = @import("../browser/webapi/Element.zig");
|
||||
const DOMNode = @import("../browser/webapi/Node.zig");
|
||||
const Selector = @import("../browser/webapi/selector/Selector.zig");
|
||||
const protocol = @import("protocol.zig");
|
||||
const Server = @import("Server.zig");
|
||||
@@ -69,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." }
|
||||
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
|
||||
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
|
||||
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
|
||||
\\ }
|
||||
\\}
|
||||
),
|
||||
@@ -98,6 +101,47 @@ pub const tool_list = [_]protocol.Tool{
|
||||
\\}
|
||||
),
|
||||
},
|
||||
.{
|
||||
.name = "click",
|
||||
.description = "Click on an interactive element.",
|
||||
.inputSchema = protocol.minify(
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." }
|
||||
\\ },
|
||||
\\ "required": ["backendNodeId"]
|
||||
\\}
|
||||
),
|
||||
},
|
||||
.{
|
||||
.name = "fill",
|
||||
.description = "Fill text into an input element.",
|
||||
.inputSchema = protocol.minify(
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." },
|
||||
\\ "text": { "type": "string", "description": "The text to fill into the input element." }
|
||||
\\ },
|
||||
\\ "required": ["backendNodeId", "text"]
|
||||
\\}
|
||||
),
|
||||
},
|
||||
.{
|
||||
.name = "scroll",
|
||||
.description = "Scroll the page or a specific element.",
|
||||
.inputSchema = protocol.minify(
|
||||
\\{
|
||||
\\ "type": "object",
|
||||
\\ "properties": {
|
||||
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." },
|
||||
\\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
|
||||
\\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
|
||||
\\ }
|
||||
\\}
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||
@@ -119,6 +163,8 @@ const ToolStreamingText = struct {
|
||||
action: enum { markdown, links, semantic_tree },
|
||||
registry: ?*CDPNode.Registry = null,
|
||||
arena: ?std.mem.Allocator = null,
|
||||
backendNodeId: ?u32 = null,
|
||||
maxDepth: ?u32 = null,
|
||||
|
||||
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
|
||||
try jw.beginWriteRaw();
|
||||
@@ -154,12 +200,24 @@ const ToolStreamingText = struct {
|
||||
}
|
||||
},
|
||||
.semantic_tree => {
|
||||
var root_node = self.page.document.asNode();
|
||||
if (self.backendNodeId) |node_id| {
|
||||
if (self.registry) |registry| {
|
||||
if (registry.lookup_by_id.get(node_id)) |n| {
|
||||
root_node = n.dom;
|
||||
} else {
|
||||
log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const st = lp.SemanticTree{
|
||||
.dom_node = self.page.document.asNode(),
|
||||
.dom_node = root_node,
|
||||
.registry = self.registry.?,
|
||||
.page = self.page,
|
||||
.arena = self.arena.?,
|
||||
.prune = true,
|
||||
.max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1,
|
||||
};
|
||||
|
||||
st.textStringify(w) catch |err| {
|
||||
@@ -182,6 +240,9 @@ const ToolAction = enum {
|
||||
structuredData,
|
||||
evaluate,
|
||||
semantic_tree,
|
||||
click,
|
||||
fill,
|
||||
scroll,
|
||||
};
|
||||
|
||||
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
|
||||
@@ -193,6 +254,9 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
|
||||
.{ "structuredData", .structuredData },
|
||||
.{ "evaluate", .evaluate },
|
||||
.{ "semantic_tree", .semantic_tree },
|
||||
.{ "click", .click },
|
||||
.{ "fill", .fill },
|
||||
.{ "scroll", .scroll },
|
||||
});
|
||||
|
||||
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||
@@ -221,6 +285,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
|
||||
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
|
||||
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
|
||||
.semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments),
|
||||
.click => try handleClick(server, arena, req.id.?, call_params.arguments),
|
||||
.fill => try handleFill(server, arena, req.id.?, call_params.arguments),
|
||||
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,9 +344,13 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
|
||||
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||
const TreeParams = struct {
|
||||
url: ?[:0]const u8 = null,
|
||||
backendNodeId: ?u32 = null,
|
||||
maxDepth: ?u32 = null,
|
||||
};
|
||||
var tree_args: TreeParams = .{};
|
||||
if (arguments) |args_raw| {
|
||||
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
|
||||
tree_args = args;
|
||||
if (args.url) |u| {
|
||||
try performGoto(server, u, id);
|
||||
}
|
||||
@@ -290,7 +361,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
|
||||
};
|
||||
|
||||
const content = [_]protocol.TextContent(ToolStreamingText){.{
|
||||
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena },
|
||||
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth },
|
||||
}};
|
||||
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
|
||||
}
|
||||
@@ -380,6 +451,87 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value,
|
||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||
}
|
||||
|
||||
fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||
const ClickParams = struct {
|
||||
backendNodeId: CDPNode.Id,
|
||||
};
|
||||
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click");
|
||||
|
||||
const page = server.session.currentPage() orelse {
|
||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||
};
|
||||
|
||||
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
|
||||
return server.sendError(id, .InvalidParams, "Node not found");
|
||||
};
|
||||
|
||||
lp.actions.click(node.dom, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) {
|
||||
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
|
||||
}
|
||||
return server.sendError(id, .InternalError, "Failed to click element");
|
||||
};
|
||||
|
||||
const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }};
|
||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||
}
|
||||
|
||||
fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||
const FillParams = struct {
|
||||
backendNodeId: CDPNode.Id,
|
||||
text: []const u8,
|
||||
};
|
||||
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill");
|
||||
|
||||
const page = server.session.currentPage() orelse {
|
||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||
};
|
||||
|
||||
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
|
||||
return server.sendError(id, .InvalidParams, "Node not found");
|
||||
};
|
||||
|
||||
lp.actions.fill(node.dom, args.text, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) {
|
||||
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
|
||||
}
|
||||
return server.sendError(id, .InternalError, "Failed to fill element");
|
||||
};
|
||||
|
||||
const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }};
|
||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||
}
|
||||
|
||||
fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||
const ScrollParams = struct {
|
||||
backendNodeId: ?CDPNode.Id = null,
|
||||
x: ?i32 = null,
|
||||
y: ?i32 = null,
|
||||
};
|
||||
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll");
|
||||
|
||||
const page = server.session.currentPage() orelse {
|
||||
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||
};
|
||||
|
||||
var target_node: ?*DOMNode = null;
|
||||
if (args.backendNodeId) |node_id| {
|
||||
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
|
||||
return server.sendError(id, .InvalidParams, "Node not found");
|
||||
};
|
||||
target_node = node.dom;
|
||||
}
|
||||
|
||||
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
|
||||
if (err == error.InvalidNodeType) {
|
||||
return server.sendError(id, .InvalidParams, "Node is not an element");
|
||||
}
|
||||
return server.sendError(id, .InternalError, "Failed to scroll");
|
||||
};
|
||||
|
||||
const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }};
|
||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
||||
}
|
||||
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
|
||||
if (arguments == null) {
|
||||
try server.sendError(id, .InvalidParams, "Missing arguments");
|
||||
@@ -455,3 +607,66 @@ test "MCP - evaluate error reporting" {
|
||||
\\}
|
||||
, out_alloc.writer.buffered());
|
||||
}
|
||||
|
||||
test "MCP - Actions: click, fill, scroll" {
|
||||
defer testing.reset();
|
||||
const allocator = testing.allocator;
|
||||
const app = testing.test_app;
|
||||
|
||||
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||
defer out_alloc.deinit();
|
||||
|
||||
var server = try Server.init(allocator, app, &out_alloc.writer);
|
||||
defer server.deinit();
|
||||
|
||||
const aa = testing.arena_allocator;
|
||||
const page = try server.session.createPage();
|
||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
_ = server.session.wait(5000);
|
||||
|
||||
// Test Click
|
||||
const btn = page.document.getElementById("btn", page).?.asNode();
|
||||
const btn_id = (try server.node_registry.register(btn)).id;
|
||||
var btn_id_buf: [12]u8 = undefined;
|
||||
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
|
||||
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
|
||||
try router.handleMessage(server, aa, click_msg);
|
||||
|
||||
// Test Fill Input
|
||||
const inp = page.document.getElementById("inp", page).?.asNode();
|
||||
const inp_id = (try server.node_registry.register(inp)).id;
|
||||
var inp_id_buf: [12]u8 = undefined;
|
||||
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
|
||||
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" });
|
||||
try router.handleMessage(server, aa, fill_msg);
|
||||
|
||||
// Test Fill Select
|
||||
const sel = page.document.getElementById("sel", page).?.asNode();
|
||||
const sel_id = (try server.node_registry.register(sel)).id;
|
||||
var sel_id_buf: [12]u8 = undefined;
|
||||
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
|
||||
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" });
|
||||
try router.handleMessage(server, aa, fill_sel_msg);
|
||||
|
||||
// Test Scroll
|
||||
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
|
||||
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
|
||||
var scroll_id_buf: [12]u8 = undefined;
|
||||
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
|
||||
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
|
||||
try router.handleMessage(server, aa, scroll_msg);
|
||||
|
||||
// Evaluate assertions
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(&ls.local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
|
||||
|
||||
try testing.expect(result.isTrue());
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ const Listener = struct {
|
||||
onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void,
|
||||
};
|
||||
|
||||
// Number of fixed pollfds entries (wakeup pipe + listener).
|
||||
const PSEUDO_POLLFDS = 2;
|
||||
|
||||
const MAX_TICK_CALLBACKS = 16;
|
||||
|
||||
allocator: Allocator,
|
||||
|
||||
config: *const Config,
|
||||
@@ -57,6 +62,22 @@ wakeup_pipe: [2]posix.fd_t = .{ -1, -1 },
|
||||
|
||||
shutdown: std.atomic.Value(bool) = .init(false),
|
||||
|
||||
// Multi is a heavy structure that can consume up to 2MB of RAM.
|
||||
// Currently, Runtime is used sparingly, and we only create it on demand.
|
||||
// When Runtime becomes truly shared, it should become a regular field.
|
||||
multi: ?*libcurl.CurlM = null,
|
||||
submission_mutex: std.Thread.Mutex = .{},
|
||||
submission_queue: std.DoublyLinkedList = .{},
|
||||
|
||||
callbacks: [MAX_TICK_CALLBACKS]TickCallback = undefined,
|
||||
callbacks_len: usize = 0,
|
||||
callbacks_mutex: std.Thread.Mutex = .{},
|
||||
|
||||
const TickCallback = struct {
|
||||
ctx: *anyopaque,
|
||||
fun: *const fn (*anyopaque) void,
|
||||
};
|
||||
|
||||
const ZigToCurlAllocator = struct {
|
||||
// C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64).
|
||||
// We match this guarantee since libcurl expects malloc-compatible alignment.
|
||||
@@ -185,8 +206,8 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
|
||||
|
||||
const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
|
||||
|
||||
// 0 is wakeup, 1 is listener
|
||||
const pollfds = try allocator.alloc(posix.pollfd, 2);
|
||||
// 0 is wakeup, 1 is listener, rest for curl fds
|
||||
const pollfds = try allocator.alloc(posix.pollfd, PSEUDO_POLLFDS + config.httpMaxConcurrent());
|
||||
errdefer allocator.free(pollfds);
|
||||
|
||||
@memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 });
|
||||
@@ -216,16 +237,23 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
|
||||
.allocator = allocator,
|
||||
.config = config,
|
||||
.ca_blob = ca_blob,
|
||||
.robot_store = RobotStore.init(allocator),
|
||||
.connections = connections,
|
||||
.available = available,
|
||||
.web_bot_auth = web_bot_auth,
|
||||
|
||||
.pollfds = pollfds,
|
||||
.wakeup_pipe = pipe,
|
||||
|
||||
.available = available,
|
||||
.connections = connections,
|
||||
|
||||
.robot_store = RobotStore.init(allocator),
|
||||
.web_bot_auth = web_bot_auth,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Runtime) void {
|
||||
if (self.multi) |multi| {
|
||||
libcurl.curl_multi_cleanup(multi) catch {};
|
||||
}
|
||||
|
||||
for (&self.wakeup_pipe) |*fd| {
|
||||
if (fd.* >= 0) {
|
||||
posix.close(fd.*);
|
||||
@@ -285,42 +313,105 @@ pub fn bind(
|
||||
};
|
||||
}
|
||||
|
||||
pub fn run(self: *Runtime) void {
|
||||
while (!self.shutdown.load(.acquire)) {
|
||||
const listener = self.listener orelse return;
|
||||
pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
|
||||
self.callbacks_mutex.lock();
|
||||
defer self.callbacks_mutex.unlock();
|
||||
|
||||
_ = posix.poll(self.pollfds, -1) catch |err| {
|
||||
lp.assert(self.callbacks_len < MAX_TICK_CALLBACKS, "too many ticks", .{});
|
||||
|
||||
self.callbacks[self.callbacks_len] = .{
|
||||
.ctx = ctx,
|
||||
.fun = callback,
|
||||
};
|
||||
self.callbacks_len += 1;
|
||||
|
||||
self.wakeupPoll();
|
||||
}
|
||||
|
||||
pub fn fireTicks(self: *Runtime) void {
|
||||
self.callbacks_mutex.lock();
|
||||
defer self.callbacks_mutex.unlock();
|
||||
|
||||
for (self.callbacks[0..self.callbacks_len]) |*callback| {
|
||||
callback.fun(callback.ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(self: *Runtime) void {
|
||||
var drain_buf: [64]u8 = undefined;
|
||||
var running_handles: c_int = 0;
|
||||
|
||||
const poll_fd = &self.pollfds[0];
|
||||
const listen_fd = &self.pollfds[1];
|
||||
|
||||
// Please note that receiving a shutdown command does not terminate all connections.
|
||||
// When gracefully shutting down a server, we at least want to send the remaining
|
||||
// telemetry, but we stop accepting new connections. It is the responsibility
|
||||
// of external code to terminate its requests upon shutdown.
|
||||
while (true) {
|
||||
self.drainQueue();
|
||||
|
||||
if (self.multi) |multi| {
|
||||
// Kickstart newly added handles (DNS/connect) so that
|
||||
// curl registers its sockets before we poll.
|
||||
libcurl.curl_multi_perform(multi, &running_handles) catch |err| {
|
||||
lp.log.err(.app, "curl perform", .{ .err = err });
|
||||
};
|
||||
|
||||
self.preparePollFds(multi);
|
||||
}
|
||||
|
||||
// for ontick to work, you need to wake up periodically
|
||||
const timeout = blk: {
|
||||
const min_timeout = 250; // 250ms
|
||||
if (self.multi == null) {
|
||||
break :blk min_timeout;
|
||||
}
|
||||
|
||||
const curl_timeout = self.getCurlTimeout();
|
||||
if (curl_timeout == 0) {
|
||||
break :blk min_timeout;
|
||||
}
|
||||
|
||||
break :blk @min(min_timeout, curl_timeout);
|
||||
};
|
||||
|
||||
_ = posix.poll(self.pollfds, timeout) catch |err| {
|
||||
lp.log.err(.app, "poll", .{ .err = err });
|
||||
continue;
|
||||
};
|
||||
|
||||
// check wakeup socket
|
||||
if (self.pollfds[0].revents != 0) {
|
||||
self.pollfds[0].revents = 0;
|
||||
|
||||
// If we were woken up, perhaps everything was cancelled and the iteration can be completed.
|
||||
if (self.shutdown.load(.acquire)) break;
|
||||
// check wakeup pipe
|
||||
if (poll_fd.revents != 0) {
|
||||
poll_fd.revents = 0;
|
||||
while (true)
|
||||
_ = posix.read(self.wakeup_pipe[0], &drain_buf) catch break;
|
||||
}
|
||||
|
||||
// check new connections;
|
||||
if (self.pollfds[1].revents == 0) continue;
|
||||
self.pollfds[1].revents = 0;
|
||||
// accept new connections
|
||||
if (listen_fd.revents != 0) {
|
||||
listen_fd.revents = 0;
|
||||
self.acceptConnections();
|
||||
}
|
||||
|
||||
const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| {
|
||||
switch (err) {
|
||||
error.SocketNotListening, error.ConnectionAborted => {
|
||||
self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 };
|
||||
self.listener = null;
|
||||
},
|
||||
error.WouldBlock => {},
|
||||
else => {
|
||||
lp.log.err(.app, "accept", .{ .err = err });
|
||||
},
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if (self.multi) |multi| {
|
||||
// Drive transfers and process completions.
|
||||
libcurl.curl_multi_perform(multi, &running_handles) catch |err| {
|
||||
lp.log.err(.app, "curl perform", .{ .err = err });
|
||||
};
|
||||
self.processCompletions(multi);
|
||||
}
|
||||
|
||||
listener.onAccept(listener.ctx, socket);
|
||||
self.fireTicks();
|
||||
|
||||
if (self.shutdown.load(.acquire) and running_handles == 0) {
|
||||
// Check if fireTicks submitted new requests (e.g. telemetry flush).
|
||||
// If so, continue the loop to drain and send them before exiting.
|
||||
self.submission_mutex.lock();
|
||||
const has_pending = self.submission_queue.first != null;
|
||||
self.submission_mutex.unlock();
|
||||
if (!has_pending) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (self.listener) |listener| {
|
||||
@@ -337,9 +428,132 @@ pub fn run(self: *Runtime) void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submitRequest(self: *Runtime, conn: *net_http.Connection) void {
|
||||
self.submission_mutex.lock();
|
||||
self.submission_queue.append(&conn.node);
|
||||
self.submission_mutex.unlock();
|
||||
self.wakeupPoll();
|
||||
}
|
||||
|
||||
fn wakeupPoll(self: *Runtime) void {
|
||||
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {};
|
||||
}
|
||||
|
||||
fn drainQueue(self: *Runtime) void {
|
||||
self.submission_mutex.lock();
|
||||
defer self.submission_mutex.unlock();
|
||||
|
||||
if (self.submission_queue.first == null) return;
|
||||
|
||||
const multi = self.multi orelse blk: {
|
||||
const m = libcurl.curl_multi_init() orelse {
|
||||
lp.assert(false, "curl multi init failed", .{});
|
||||
unreachable;
|
||||
};
|
||||
self.multi = m;
|
||||
break :blk m;
|
||||
};
|
||||
|
||||
while (self.submission_queue.popFirst()) |node| {
|
||||
const conn: *net_http.Connection = @fieldParentPtr("node", node);
|
||||
conn.setPrivate(conn) catch |err| {
|
||||
lp.log.err(.app, "curl set private", .{ .err = err });
|
||||
self.releaseConnection(conn);
|
||||
continue;
|
||||
};
|
||||
libcurl.curl_multi_add_handle(multi, conn.easy) catch |err| {
|
||||
lp.log.err(.app, "curl multi add", .{ .err = err });
|
||||
self.releaseConnection(conn);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop(self: *Runtime) void {
|
||||
self.shutdown.store(true, .release);
|
||||
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {};
|
||||
self.wakeupPoll();
|
||||
}
|
||||
|
||||
fn acceptConnections(self: *Runtime) void {
|
||||
if (self.shutdown.load(.acquire)) {
|
||||
return;
|
||||
}
|
||||
const listener = self.listener orelse return;
|
||||
|
||||
while (true) {
|
||||
const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| {
|
||||
switch (err) {
|
||||
error.WouldBlock => break,
|
||||
error.SocketNotListening => {
|
||||
self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 };
|
||||
self.listener = null;
|
||||
return;
|
||||
},
|
||||
error.ConnectionAborted => {
|
||||
lp.log.warn(.app, "accept connection aborted", .{});
|
||||
continue;
|
||||
},
|
||||
else => {
|
||||
lp.log.err(.app, "accept error", .{ .err = err });
|
||||
continue;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
listener.onAccept(listener.ctx, socket);
|
||||
}
|
||||
}
|
||||
|
||||
fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void {
|
||||
const curl_fds = self.pollfds[PSEUDO_POLLFDS..];
|
||||
@memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 });
|
||||
|
||||
var fd_count: c_uint = 0;
|
||||
const wait_fds: []libcurl.CurlWaitFd = @ptrCast(curl_fds);
|
||||
libcurl.curl_multi_waitfds(multi, wait_fds, &fd_count) catch |err| {
|
||||
lp.log.err(.app, "curl waitfds", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn getCurlTimeout(self: *Runtime) i32 {
|
||||
const multi = self.multi orelse return -1;
|
||||
var timeout_ms: c_long = -1;
|
||||
libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1;
|
||||
return @intCast(@min(timeout_ms, std.math.maxInt(i32)));
|
||||
}
|
||||
|
||||
fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void {
|
||||
var msgs_in_queue: c_int = 0;
|
||||
while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| {
|
||||
switch (msg.data) {
|
||||
.done => |maybe_err| {
|
||||
if (maybe_err) |err| {
|
||||
lp.log.warn(.app, "curl transfer error", .{ .err = err });
|
||||
}
|
||||
},
|
||||
else => continue,
|
||||
}
|
||||
|
||||
const easy: *libcurl.Curl = msg.easy_handle;
|
||||
var ptr: *anyopaque = undefined;
|
||||
libcurl.curl_easy_getinfo(easy, .private, &ptr) catch
|
||||
lp.assert(false, "curl getinfo private", .{});
|
||||
const conn: *net_http.Connection = @ptrCast(@alignCast(ptr));
|
||||
|
||||
libcurl.curl_multi_remove_handle(multi, easy) catch {};
|
||||
self.releaseConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
comptime {
|
||||
if (@sizeOf(posix.pollfd) != @sizeOf(libcurl.CurlWaitFd)) {
|
||||
@compileError("pollfd and CurlWaitFd size mismatch");
|
||||
}
|
||||
if (@offsetOf(posix.pollfd, "fd") != @offsetOf(libcurl.CurlWaitFd, "fd") or
|
||||
@offsetOf(posix.pollfd, "events") != @offsetOf(libcurl.CurlWaitFd, "events") or
|
||||
@offsetOf(posix.pollfd, "revents") != @offsetOf(libcurl.CurlWaitFd, "revents"))
|
||||
{
|
||||
@compileError("pollfd and CurlWaitFd layout mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn getConnection(self: *Runtime) ?*net_http.Connection {
|
||||
|
||||
@@ -386,11 +386,18 @@ pub const Connection = struct {
|
||||
}
|
||||
|
||||
pub fn reset(self: *const Connection) !void {
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .http_header, null);
|
||||
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_data, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .header_function, null);
|
||||
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_data, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_function, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .proxy, null);
|
||||
try libcurl.curl_easy_setopt(self.easy, .write_function, discardBody);
|
||||
}
|
||||
|
||||
fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize {
|
||||
return count * len;
|
||||
}
|
||||
|
||||
pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void {
|
||||
|
||||
@@ -308,6 +308,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
|
||||
pub const WsConnection = struct {
|
||||
// CLOSE, 2 length, code
|
||||
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
|
||||
const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001
|
||||
const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009
|
||||
const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002
|
||||
// "private-use" close codes must be from 4000-49999
|
||||
@@ -583,6 +584,10 @@ pub const WsConnection = struct {
|
||||
return address;
|
||||
}
|
||||
|
||||
pub fn sendClose(self: *WsConnection) void {
|
||||
self.send(&CLOSE_GOING_AWAY) catch {};
|
||||
}
|
||||
|
||||
pub fn shutdown(self: *WsConnection) void {
|
||||
posix.shutdown(self.socket, .recv) catch {};
|
||||
}
|
||||
|
||||
@@ -629,10 +629,13 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
|
||||
.write_function => blk: {
|
||||
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
|
||||
.null => null,
|
||||
.@"fn" => struct {
|
||||
.@"fn" => |info| struct {
|
||||
fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize {
|
||||
const u = user orelse unreachable;
|
||||
return value(@ptrCast(buffer), count, len, u);
|
||||
const user_arg = if (@typeInfo(info.params[3].type.?) == .optional)
|
||||
user
|
||||
else
|
||||
user orelse unreachable;
|
||||
return value(@ptrCast(buffer), count, len, user_arg);
|
||||
}
|
||||
}.cb,
|
||||
else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))),
|
||||
@@ -753,6 +756,15 @@ pub fn curl_multi_poll(
|
||||
try errorMCheck(c.curl_multi_poll(multi, raw_fds, @intCast(extra_fds.len), timeout_ms, numfds));
|
||||
}
|
||||
|
||||
pub fn curl_multi_waitfds(multi: *CurlM, ufds: []CurlWaitFd, fd_count: *c_uint) ErrorMulti!void {
|
||||
const raw_fds: [*c]c.curl_waitfd = if (ufds.len == 0) null else @ptrCast(ufds.ptr);
|
||||
try errorMCheck(c.curl_multi_waitfds(multi, raw_fds, @intCast(ufds.len), fd_count));
|
||||
}
|
||||
|
||||
pub fn curl_multi_timeout(multi: *CurlM, timeout_ms: *c_long) ErrorMulti!void {
|
||||
try errorMCheck(c.curl_multi_timeout(multi, timeout_ms));
|
||||
}
|
||||
|
||||
pub fn curl_multi_info_read(multi: *CurlM, msgs_in_queue: *c_int) ?CurlMsg {
|
||||
const ptr = c.curl_multi_info_read(multi, msgs_in_queue);
|
||||
if (ptr == null) return null;
|
||||
|
||||
@@ -2,140 +2,132 @@ const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const build_config = @import("build_config");
|
||||
|
||||
const Thread = std.Thread;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
const Config = @import("../Config.zig");
|
||||
const telemetry = @import("telemetry.zig");
|
||||
const Runtime = @import("../network/Runtime.zig");
|
||||
const Connection = @import("../network/http.zig").Connection;
|
||||
|
||||
const URL = "https://telemetry.lightpanda.io";
|
||||
const MAX_BATCH_SIZE = 20;
|
||||
const BUFFER_SIZE = 1024;
|
||||
const MAX_BODY_SIZE = 500 * 1024; // 500KB server limit
|
||||
|
||||
pub const LightPanda = struct {
|
||||
running: bool,
|
||||
thread: ?std.Thread,
|
||||
allocator: Allocator,
|
||||
mutex: std.Thread.Mutex,
|
||||
cond: Thread.Condition,
|
||||
connection: Connection,
|
||||
config: *const Config,
|
||||
pending: std.DoublyLinkedList,
|
||||
mem_pool: std.heap.MemoryPool(LightPandaEvent),
|
||||
const LightPanda = @This();
|
||||
|
||||
pub fn init(app: *App) !LightPanda {
|
||||
const connection = try app.network.newConnection();
|
||||
errdefer connection.deinit();
|
||||
allocator: Allocator,
|
||||
runtime: *Runtime,
|
||||
writer: std.Io.Writer.Allocating,
|
||||
|
||||
try connection.setURL(URL);
|
||||
try connection.setMethod(.POST);
|
||||
/// Protects concurrent producers in send().
|
||||
mutex: std.Thread.Mutex = .{},
|
||||
|
||||
const allocator = app.allocator;
|
||||
return .{
|
||||
.cond = .{},
|
||||
.mutex = .{},
|
||||
.pending = .{},
|
||||
.thread = null,
|
||||
.running = true,
|
||||
.allocator = allocator,
|
||||
.connection = connection,
|
||||
.config = app.config,
|
||||
.mem_pool = std.heap.MemoryPool(LightPandaEvent).init(allocator),
|
||||
};
|
||||
iid: ?[36]u8 = null,
|
||||
run_mode: Config.RunMode = .serve,
|
||||
|
||||
head: std.atomic.Value(usize) = .init(0),
|
||||
tail: std.atomic.Value(usize) = .init(0),
|
||||
dropped: std.atomic.Value(usize) = .init(0),
|
||||
buffer: [BUFFER_SIZE]telemetry.Event = undefined,
|
||||
|
||||
pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode) !void {
|
||||
self.* = .{
|
||||
.iid = iid,
|
||||
.run_mode = run_mode,
|
||||
.allocator = app.allocator,
|
||||
.runtime = &app.network,
|
||||
.writer = std.Io.Writer.Allocating.init(app.allocator),
|
||||
};
|
||||
|
||||
self.runtime.onTick(@ptrCast(self), flushCallback);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LightPanda) void {
|
||||
self.writer.deinit();
|
||||
}
|
||||
|
||||
pub fn send(self: *LightPanda, raw_event: telemetry.Event) !void {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
const t = self.tail.load(.monotonic);
|
||||
const h = self.head.load(.acquire);
|
||||
if (t - h >= BUFFER_SIZE) {
|
||||
_ = self.dropped.fetchAdd(1, .monotonic);
|
||||
return;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LightPanda) void {
|
||||
if (self.thread) |*thread| {
|
||||
self.mutex.lock();
|
||||
self.running = false;
|
||||
self.mutex.unlock();
|
||||
self.cond.signal();
|
||||
thread.join();
|
||||
}
|
||||
self.mem_pool.deinit();
|
||||
self.connection.deinit();
|
||||
self.buffer[t % BUFFER_SIZE] = raw_event;
|
||||
self.tail.store(t + 1, .release);
|
||||
}
|
||||
|
||||
fn flushCallback(ctx: *anyopaque) void {
|
||||
const self: *LightPanda = @ptrCast(@alignCast(ctx));
|
||||
self.postEvent() catch |err| {
|
||||
log.warn(.telemetry, "flush error", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn postEvent(self: *LightPanda) !void {
|
||||
const conn = self.runtime.getConnection() orelse {
|
||||
return;
|
||||
};
|
||||
errdefer self.runtime.releaseConnection(conn);
|
||||
|
||||
const h = self.head.load(.monotonic);
|
||||
const t = self.tail.load(.acquire);
|
||||
const dropped = self.dropped.swap(0, .monotonic);
|
||||
|
||||
if (h == t and dropped == 0) {
|
||||
self.runtime.releaseConnection(conn);
|
||||
return;
|
||||
}
|
||||
errdefer _ = self.dropped.fetchAdd(dropped, .monotonic);
|
||||
|
||||
self.writer.clearRetainingCapacity();
|
||||
|
||||
if (dropped > 0) {
|
||||
_ = try self.writeEvent(.{ .buffer_overflow = .{ .dropped = dropped } });
|
||||
}
|
||||
|
||||
pub fn send(self: *LightPanda, iid: ?[]const u8, run_mode: Config.RunMode, raw_event: telemetry.Event) !void {
|
||||
const event = try self.mem_pool.create();
|
||||
event.* = .{
|
||||
.iid = iid,
|
||||
.mode = run_mode,
|
||||
.event = raw_event,
|
||||
.node = .{},
|
||||
};
|
||||
var sent: usize = 0;
|
||||
for (h..t) |i| {
|
||||
const fit = try self.writeEvent(self.buffer[i % BUFFER_SIZE]);
|
||||
if (!fit) break;
|
||||
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
if (self.thread == null) {
|
||||
self.thread = try std.Thread.spawn(.{}, run, .{self});
|
||||
}
|
||||
|
||||
self.pending.append(&event.node);
|
||||
self.cond.signal();
|
||||
sent += 1;
|
||||
}
|
||||
|
||||
fn run(self: *LightPanda) void {
|
||||
var aw = std.Io.Writer.Allocating.init(self.allocator);
|
||||
defer aw.deinit();
|
||||
try conn.setURL(URL);
|
||||
try conn.setMethod(.POST);
|
||||
try conn.setBody(self.writer.written());
|
||||
|
||||
var batch: [MAX_BATCH_SIZE]*LightPandaEvent = undefined;
|
||||
self.mutex.lock();
|
||||
while (true) {
|
||||
while (self.pending.first != null) {
|
||||
const b = self.collectBatch(&batch);
|
||||
self.mutex.unlock();
|
||||
self.postEvent(b, &aw) catch |err| {
|
||||
log.warn(.telemetry, "post error", .{ .err = err });
|
||||
};
|
||||
self.mutex.lock();
|
||||
}
|
||||
if (self.running == false) {
|
||||
return;
|
||||
}
|
||||
self.cond.wait(&self.mutex);
|
||||
}
|
||||
self.head.store(h + sent, .release);
|
||||
self.runtime.submitRequest(conn);
|
||||
}
|
||||
|
||||
fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool {
|
||||
const iid: ?[]const u8 = if (self.iid) |*id| id else null;
|
||||
const wrapped = LightPandaEvent{ .iid = iid, .mode = self.run_mode, .event = event };
|
||||
|
||||
const checkpoint = self.writer.written().len;
|
||||
|
||||
try std.json.Stringify.value(&wrapped, .{ .emit_null_optional_fields = false }, &self.writer.writer);
|
||||
try self.writer.writer.writeByte('\n');
|
||||
|
||||
if (self.writer.written().len > MAX_BODY_SIZE) {
|
||||
self.writer.shrinkRetainingCapacity(checkpoint);
|
||||
return false;
|
||||
}
|
||||
|
||||
fn postEvent(self: *LightPanda, events: []*LightPandaEvent, aw: *std.Io.Writer.Allocating) !void {
|
||||
defer for (events) |e| {
|
||||
self.mem_pool.destroy(e);
|
||||
};
|
||||
|
||||
defer aw.clearRetainingCapacity();
|
||||
for (events) |event| {
|
||||
try std.json.Stringify.value(event, .{ .emit_null_optional_fields = false }, &aw.writer);
|
||||
try aw.writer.writeByte('\n');
|
||||
}
|
||||
|
||||
try self.connection.setBody(aw.written());
|
||||
const status = try self.connection.request(&self.config.http_headers);
|
||||
|
||||
if (status != 200) {
|
||||
log.warn(.telemetry, "server error", .{ .status = status });
|
||||
}
|
||||
}
|
||||
|
||||
fn collectBatch(self: *LightPanda, into: []*LightPandaEvent) []*LightPandaEvent {
|
||||
var i: usize = 0;
|
||||
while (self.pending.popFirst()) |node| {
|
||||
into[i] = @fieldParentPtr("node", node);
|
||||
i += 1;
|
||||
if (i == MAX_BATCH_SIZE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return into[0..i];
|
||||
}
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
const LightPandaEvent = struct {
|
||||
iid: ?[]const u8,
|
||||
mode: Config.RunMode,
|
||||
event: telemetry.Event,
|
||||
node: std.DoublyLinkedList.Node,
|
||||
|
||||
pub fn jsonStringify(self: *const LightPandaEvent, writer: anytype) !void {
|
||||
try writer.beginObject();
|
||||
@@ -153,7 +145,7 @@ const LightPandaEvent = struct {
|
||||
try writer.write(builtin.cpu.arch);
|
||||
|
||||
try writer.objectField("version");
|
||||
try writer.write(build_config.git_commit);
|
||||
try writer.write(build_config.git_version orelse build_config.git_commit);
|
||||
|
||||
try writer.objectField("event");
|
||||
try writer.write(@tagName(std.meta.activeTag(self.event)));
|
||||
|
||||
@@ -11,26 +11,21 @@ const uuidv4 = @import("../id.zig").uuidv4;
|
||||
const IID_FILE = "iid";
|
||||
|
||||
pub fn isDisabled() bool {
|
||||
if (builtin.mode == .Debug or builtin.is_test) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY");
|
||||
}
|
||||
|
||||
pub const Telemetry = TelemetryT(blk: {
|
||||
if (builtin.mode == .Debug or builtin.is_test) break :blk NoopProvider;
|
||||
break :blk @import("lightpanda.zig").LightPanda;
|
||||
});
|
||||
pub const Telemetry = TelemetryT(@import("lightpanda.zig"));
|
||||
|
||||
fn TelemetryT(comptime P: type) type {
|
||||
return struct {
|
||||
// an "install" id that we [try to] persist and re-use between runs
|
||||
// null on IO error
|
||||
iid: ?[36]u8,
|
||||
|
||||
provider: P,
|
||||
provider: *P,
|
||||
|
||||
disabled: bool,
|
||||
|
||||
run_mode: Config.RunMode,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(app: *App, run_mode: Config.RunMode) !Self {
|
||||
@@ -39,27 +34,29 @@ fn TelemetryT(comptime P: type) type {
|
||||
log.info(.telemetry, "telemetry status", .{ .disabled = disabled });
|
||||
}
|
||||
|
||||
const provider = try P.init(app);
|
||||
errdefer provider.deinit();
|
||||
const iid: ?[36]u8 = if (disabled) null else getOrCreateId(app.app_dir_path);
|
||||
|
||||
const provider = try app.allocator.create(P);
|
||||
errdefer app.allocator.destroy(provider);
|
||||
|
||||
try P.init(provider, app, iid, run_mode);
|
||||
|
||||
return .{
|
||||
.disabled = disabled,
|
||||
.run_mode = run_mode,
|
||||
.provider = provider,
|
||||
.iid = if (disabled) null else getOrCreateId(app.app_dir_path),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
pub fn deinit(self: *Self, allocator: Allocator) void {
|
||||
self.provider.deinit();
|
||||
allocator.destroy(self.provider);
|
||||
}
|
||||
|
||||
pub fn record(self: *Self, event: Event) void {
|
||||
if (self.disabled) {
|
||||
return;
|
||||
}
|
||||
const iid: ?[]const u8 = if (self.iid) |*iid| iid else null;
|
||||
self.provider.send(iid, self.run_mode, event) catch |err| {
|
||||
self.provider.send(event) catch |err| {
|
||||
log.warn(.telemetry, "record error", .{ .err = err, .type = @tagName(std.meta.activeTag(event)) });
|
||||
};
|
||||
}
|
||||
@@ -105,6 +102,7 @@ fn getOrCreateId(app_dir_path_: ?[]const u8) ?[36]u8 {
|
||||
pub const Event = union(enum) {
|
||||
run: void,
|
||||
navigate: Navigate,
|
||||
buffer_overflow: BufferOverflow,
|
||||
flag: []const u8, // used for testing
|
||||
|
||||
const Navigate = struct {
|
||||
@@ -112,36 +110,35 @@ pub const Event = union(enum) {
|
||||
proxy: bool,
|
||||
driver: []const u8 = "cdp",
|
||||
};
|
||||
};
|
||||
|
||||
const NoopProvider = struct {
|
||||
fn init(_: *App) !NoopProvider {
|
||||
return .{};
|
||||
}
|
||||
fn deinit(_: NoopProvider) void {}
|
||||
pub fn send(_: NoopProvider, _: ?[]const u8, _: Config.RunMode, _: Event) !void {}
|
||||
const BufferOverflow = struct {
|
||||
dropped: usize,
|
||||
};
|
||||
};
|
||||
|
||||
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
|
||||
extern fn unsetenv(name: [*:0]u8) c_int;
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "telemetry: disabled by environment" {
|
||||
test "telemetry: always disabled in debug builds" {
|
||||
// Must be disabled regardless of environment variable.
|
||||
_ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"));
|
||||
try testing.expectEqual(true, isDisabled());
|
||||
|
||||
_ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0);
|
||||
defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"));
|
||||
try testing.expectEqual(true, isDisabled());
|
||||
|
||||
const FailingProvider = struct {
|
||||
fn init(_: *App) !@This() {
|
||||
return .{};
|
||||
}
|
||||
fn deinit(_: @This()) void {}
|
||||
pub fn send(_: @This(), _: ?[]const u8, _: Config.RunMode, _: Event) !void {
|
||||
fn init(_: *@This(), _: *App, _: ?[36]u8, _: Config.RunMode) !void {}
|
||||
fn deinit(_: *@This()) void {}
|
||||
pub fn send(_: *@This(), _: Event) !void {
|
||||
unreachable;
|
||||
}
|
||||
};
|
||||
|
||||
var telemetry = try TelemetryT(FailingProvider).init(undefined, .serve);
|
||||
defer telemetry.deinit();
|
||||
var telemetry = try TelemetryT(FailingProvider).init(testing.test_app, .serve);
|
||||
defer telemetry.deinit(testing.test_app.allocator);
|
||||
telemetry.record(.{ .run = {} });
|
||||
}
|
||||
|
||||
@@ -165,8 +162,9 @@ test "telemetry: getOrCreateId" {
|
||||
|
||||
test "telemetry: sends event to provider" {
|
||||
var telemetry = try TelemetryT(MockProvider).init(testing.test_app, .serve);
|
||||
defer telemetry.deinit();
|
||||
const mock = &telemetry.provider;
|
||||
defer telemetry.deinit(testing.test_app.allocator);
|
||||
telemetry.disabled = false;
|
||||
const mock = telemetry.provider;
|
||||
|
||||
telemetry.record(.{ .flag = "1" });
|
||||
telemetry.record(.{ .flag = "2" });
|
||||
@@ -179,15 +177,11 @@ test "telemetry: sends event to provider" {
|
||||
}
|
||||
|
||||
const MockProvider = struct {
|
||||
iid: ?[]const u8,
|
||||
run_mode: ?Config.RunMode,
|
||||
allocator: Allocator,
|
||||
events: std.ArrayList(Event),
|
||||
|
||||
fn init(app: *App) !@This() {
|
||||
return .{
|
||||
.iid = null,
|
||||
.run_mode = null,
|
||||
fn init(self: *MockProvider, app: *App, _: ?[36]u8, _: Config.RunMode) !void {
|
||||
self.* = .{
|
||||
.events = .{},
|
||||
.allocator = app.allocator,
|
||||
};
|
||||
@@ -195,15 +189,7 @@ const MockProvider = struct {
|
||||
fn deinit(self: *MockProvider) void {
|
||||
self.events.deinit(self.allocator);
|
||||
}
|
||||
pub fn send(self: *MockProvider, iid: ?[]const u8, run_mode: Config.RunMode, events: Event) !void {
|
||||
if (self.iid == null) {
|
||||
try testing.expectEqual(null, self.run_mode);
|
||||
self.iid = iid.?;
|
||||
self.run_mode = run_mode;
|
||||
} else {
|
||||
try testing.expectEqual(self.iid.?, iid.?);
|
||||
try testing.expectEqual(self.run_mode.?, run_mode);
|
||||
}
|
||||
try self.events.append(self.allocator, events);
|
||||
pub fn send(self: *MockProvider, event: Event) !void {
|
||||
try self.events.append(self.allocator, event);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user