diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 4c0a28f9..0c4069fb 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -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: diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml index 1a0217bb..da4056ca 100644 --- a/.github/workflows/e2e-integration-test.yml +++ b/.github/workflows/e2e-integration-test.yml @@ -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 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 675dd36b..0e59cd24 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/nightly.yml similarity index 88% rename from .github/workflows/build.yml rename to .github/workflows/nightly.yml index d2bcde3b..1c1ece06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/nightly.yml @@ -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 }} diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 71d485d0..01f367e8 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -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 diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml deleted file mode 100644 index a6df7e1f..00000000 --- a/.github/workflows/zig-fmt.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index db2f362d..a96ec0bf 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index f106905a..f5cd202d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 && \ diff --git a/Makefile b/Makefile index a85d4e69..56910568 100644 --- a/Makefile +++ b/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 - diff --git a/README.md b/README.md index 456499ce..1ec482ba 100644 --- a/README.md +++ b/README.md @@ -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**: diff --git a/build.zig b/build.zig index 1f9ae5ef..0d917b75 100644 --- a/build.zig +++ b/build.zig @@ -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; diff --git a/build.zig.zon b/build.zig.zon index 9a28408b..cee52057 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{ diff --git a/src/App.zig b/src/App.zig index 9039cec5..d3ba2fec 100644 --- a/src/App.zig +++ b/src/App.zig @@ -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(); diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 95561c9f..f6b2fca5 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -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); +} diff --git a/src/Server.zig b/src/Server.zig index f899d43c..d172f6dd 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -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(); } diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 8f8c4aa2..50a7c037 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -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(); } diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 5588b704..247a298e 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -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. diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 1e74c046..136b578b 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -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 { diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 43ca3632..e23d48a2 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -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 `` and ``. +/// 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 = 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" { + // + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + try testing.expectEqual("iso-8859-1", Mime.prescanCharset("").?); + try testing.expectEqual("shift_jis", Mime.prescanCharset("").?); + + // Case-insensitive tag matching + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + try testing.expectEqual("utf-8", Mime.prescanCharset("").?); + + // + try testing.expectEqual( + "iso-8859-1", + Mime.prescanCharset("").?, + ); + + // No charset found + try testing.expectEqual(null, Mime.prescanCharset("Test")); + 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("")); + + // Charset after 1024 bytes should not be found + var long_html: [1100]u8 = undefined; + @memset(&long_html, ' '); + const suffix = ""; + @memcpy(long_html[1050 .. 1050 + suffix.len], suffix); + try testing.expectEqual(null, Mime.prescanCharset(&long_html)); +} diff --git a/src/browser/Page.zig b/src/browser/Page.zig index e8df5ab0..f9f8bb61 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -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 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", .{}); } diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 2baeef8d..751ba58b 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -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, }; }; diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 404a8bc4..73b6b26e 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -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 diff --git a/src/browser/actions.zig b/src/browser/actions.zig new file mode 100644 index 00000000..951f2b1e --- /dev/null +++ b/src/browser/actions.zig @@ -0,0 +1,104 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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; + }; + } +} diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index b3325e97..944205bd 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -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 { diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 297d4ac1..9ecd65b2 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -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; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 70af9d24..da7362aa 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -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.?, diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index ba2e3e5a..d1aed2cc 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -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| { diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index a45b35df..f913dafd 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -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); diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig index d7e74e4f..9dc5857b 100644 --- a/src/browser/js/Origin.zig +++ b/src/browser/js/Origin.zig @@ -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. diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig index 67f04311..6386569a 100644 --- a/src/browser/js/PromiseResolver.zig +++ b/src/browser/js/PromiseResolver.zig @@ -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 }); diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig index e667a872..322351f3 100644 --- a/src/browser/js/Scheduler.zig +++ b/src/browser/js/Scheduler.zig @@ -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 { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index cf6e999e..2f5d0bb9 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -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"), diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 8a4984a4..f4a0f874 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.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("alt"))) |alt| { + try self.escape(alt); + } + 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("alt"))) |alt| { - try escapeMarkdown(writer, alt); - } - 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(); diff --git a/src/browser/tests/document/query_selector.html b/src/browser/tests/document/query_selector.html index b333069e..0837999e 100644 --- a/src/browser/tests/document/query_selector.html +++ b/src/browser/tests/document/query_selector.html @@ -24,11 +24,10 @@ diff --git a/src/browser/tests/domexception.html b/src/browser/tests/domexception.html index 1ed43e8d..05bdc837 100644 --- a/src/browser/tests/domexception.html +++ b/src/browser/tests/domexception.html @@ -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)); diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html index 9b8c29d3..0929a3d9 100644 --- a/src/browser/tests/element/attributes.html +++ b/src/browser/tests/element/attributes.html @@ -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)); diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html index 0522163f..74bf486c 100644 --- a/src/browser/tests/element/html/anchor.html +++ b/src/browser/tests/element/html/anchor.html @@ -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); diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html index f62cb221..d3249856 100644 --- a/src/browser/tests/element/html/form.html +++ b/src/browser/tests/element/html/form.html @@ -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) } + + +
+ +
+ + + + +
+ +
+ + + + +
+ + + + + + +
+ + + + + + + +
+ +
+
+ +
+ + diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index 92cd947d..baa09918 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -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'; diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 79cd0c6d..2b8576b5 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -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; diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html index bed5e6ab..40d45c2f 100644 --- a/src/browser/tests/element/html/link.html +++ b/src/browser/tests/element/html/link.html @@ -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)); } + + diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html index 5e1721b5..f28d7a71 100644 --- a/src/browser/tests/element/matches.html +++ b/src/browser/tests/element/matches.html @@ -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('')); } diff --git a/src/browser/tests/element/query_selector.html b/src/browser/tests/element/query_selector.html index 9564ca6d..203524b6 100644 --- a/src/browser/tests/element/query_selector.html +++ b/src/browser/tests/element/query_selector.html @@ -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')); diff --git a/src/browser/tests/element/query_selector_all.html b/src/browser/tests/element/query_selector_all.html index eeedc876..3b4013c2 100644 --- a/src/browser/tests/element/query_selector_all.html +++ b/src/browser/tests/element/query_selector_all.html @@ -24,11 +24,10 @@ diff --git a/src/browser/tests/element/selector_invalid.html b/src/browser/tests/element/selector_invalid.html index 35409c19..c0d16d59 100644 --- a/src/browser/tests/element/selector_invalid.html +++ b/src/browser/tests/element/selector_invalid.html @@ -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('')); } diff --git a/src/browser/tests/event/report_error.html b/src/browser/tests/event/report_error.html new file mode 100644 index 00000000..353dc29e --- /dev/null +++ b/src/browser/tests/event/report_error.html @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/src/browser/tests/frames/post_message.html b/src/browser/tests/frames/post_message.html new file mode 100644 index 00000000..6d206b74 --- /dev/null +++ b/src/browser/tests/frames/post_message.html @@ -0,0 +1,25 @@ + + + + + + diff --git a/src/browser/tests/frames/support/message_receiver.html b/src/browser/tests/frames/support/message_receiver.html new file mode 100644 index 00000000..55612a7c --- /dev/null +++ b/src/browser/tests/frames/support/message_receiver.html @@ -0,0 +1,9 @@ + + diff --git a/src/browser/tests/history.html b/src/browser/tests/history.html index 1508e232..e2aa0d35 100644 --- a/src/browser/tests/history.html +++ b/src/browser/tests/history.html @@ -2,37 +2,17 @@ + diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html new file mode 100644 index 00000000..88cb70b1 --- /dev/null +++ b/src/browser/tests/mcp_actions.html @@ -0,0 +1,14 @@ + + + + + + +
+
Long content
+
+ + diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/navigator/navigator.html similarity index 56% rename from src/browser/tests/window/navigator.html rename to src/browser/tests/navigator/navigator.html index df9f436a..b82c6e38 100644 --- a/src/browser/tests/window/navigator.html +++ b/src/browser/tests/navigator/navigator.html @@ -27,3 +27,44 @@ testing.expectEqual(false, navigator.javaEnabled()); testing.expectEqual(false, navigator.webdriver); + + + + + + + + + diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html index a545a452..229d8469 100644 --- a/src/browser/tests/net/fetch.html +++ b/src/browser/tests/net/fetch.html @@ -203,3 +203,39 @@ testing.expectEqual(true, response.body !== null); }); + + + + diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index 64fac5c3..a8683142 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -283,3 +283,26 @@ testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState); }); + + diff --git a/src/browser/tests/node/insert_before.html b/src/browser/tests/node/insert_before.html index 8be48e56..50dff07c 100644 --- a/src/browser/tests/node/insert_before.html +++ b/src/browser/tests/node/insert_before.html @@ -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'); diff --git a/src/browser/tests/node/remove_child.html b/src/browser/tests/node/remove_child.html index fdf0b813..1118e4cf 100644 --- a/src/browser/tests/node/remove_child.html +++ b/src/browser/tests/node/remove_child.html @@ -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'); diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html index 45ed1bc5..51b0a173 100644 --- a/src/browser/tests/node/replace_child.html +++ b/src/browser/tests/node/replace_child.html @@ -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)); diff --git a/src/browser/tests/range.html b/src/browser/tests/range.html index d9a8637b..8440c187 100644 --- a/src/browser/tests/range.html +++ b/src/browser/tests/range.html @@ -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); }); } diff --git a/src/browser/tests/shadowroot/basic.html b/src/browser/tests/shadowroot/basic.html index ed82ab18..3516fa79 100644 --- a/src/browser/tests/shadowroot/basic.html +++ b/src/browser/tests/shadowroot/basic.html @@ -5,7 +5,7 @@
- + + + + diff --git a/src/browser/tests/support/history.html b/src/browser/tests/support/history.html new file mode 100644 index 00000000..d3356de3 --- /dev/null +++ b/src/browser/tests/support/history.html @@ -0,0 +1,33 @@ + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 987ba042..2e33c1d3 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -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); } diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index 01025b86..e4094f9b 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -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'); }); diff --git a/src/browser/tests/window/window_event.html b/src/browser/tests/window/window_event.html new file mode 100644 index 00000000..971651f8 --- /dev/null +++ b/src/browser/tests/window/window_event.html @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index 3e0da288..46294b8d 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -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", }; } diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 33e29952..acaab925 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -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(); diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 74a5d79e..8586a11d 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -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); }; diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index b8608381..df86d1e1 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -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); }; diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig index 5e69db07..218110c9 100644 --- a/src/browser/webapi/Navigator.zig +++ b/src/browser/webapi/Navigator.zig @@ -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", .{}); +} diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig new file mode 100644 index 00000000..ee197d3f --- /dev/null +++ b/src/browser/webapi/Permissions.zig @@ -0,0 +1,94 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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 }); +}; diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index 09bfa2d9..a5e54923 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -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"); diff --git a/src/browser/webapi/StorageManager.zig b/src/browser/webapi/StorageManager.zig new file mode 100644 index 00000000..e7b95cc4 --- /dev/null +++ b/src/browser/webapi/StorageManager.zig @@ -0,0 +1,71 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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, .{}); +}; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 0f288398..bbed1010 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -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", .{}); +} diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index 2b41fffa..6a37ecb5 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -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", .{}); } diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index 24e8433e..b86744da 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -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: +/// - or +/// -