├── .github ├── ISSUE_TEMPLATE │ ├── bug.yaml │ ├── config.yml │ ├── feat_req.yaml │ └── team_issues.yaml ├── PULL_REQUEST_TEMPLATE.md ├── bin │ └── install_chrome_deps_linux.sh ├── dependabot.yml └── workflows │ ├── block-fixup.yml │ ├── e2e.yml │ ├── lint.yml │ ├── sync_e2e.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── TROUBLESHOOTING.md ├── assets ├── github-hr.svg └── logo.svg ├── browser ├── browser_context_mapping.go ├── browser_context_options_test.go ├── browser_mapping.go ├── console_message_mapping.go ├── element_handle_mapping.go ├── file_persister.go ├── file_persister_test.go ├── frame_mapping.go ├── helpers.go ├── helpers_test.go ├── js_handle_mapping.go ├── keyboard_mapping.go ├── locator_mapping.go ├── mapping.go ├── mapping_test.go ├── metric_event_mapping.go ├── module.go ├── module_test.go ├── modulevu.go ├── mouse_mapping.go ├── page_mapping.go ├── registry.go ├── registry_test.go ├── request_mapping.go ├── response_mapping.go ├── sync_browser_context_mapping.go ├── sync_browser_mapping.go ├── sync_console_message_mapping.go ├── sync_element_handle_mapping.go ├── sync_frame_mapping.go ├── sync_js_handle_mapping.go ├── sync_keyboard_mapping.go ├── sync_locator_mapping.go ├── sync_mapping.go ├── sync_page_mapping.go ├── sync_request_mapping.go ├── sync_response_mapping.go ├── sync_touchscreen_mapping.go ├── sync_worker_mapping.go ├── touchscreen_mapping.go └── worker_mapping.go ├── chromium ├── browser.go ├── browser_type.go └── browser_type_test.go ├── common ├── barrier.go ├── barrier_test.go ├── browser.go ├── browser_context.go ├── browser_context_options.go ├── browser_context_test.go ├── browser_options.go ├── browser_options_test.go ├── browser_process.go ├── browser_process_meta.go ├── browser_process_test.go ├── browser_test.go ├── connection.go ├── connection_test.go ├── consts.go ├── context.go ├── context_test.go ├── device.go ├── doc.go ├── element_handle.go ├── element_handle_options.go ├── element_handle_test.go ├── errors.go ├── event_emitter.go ├── event_emitter_test.go ├── execution_context.go ├── frame.go ├── frame_manager.go ├── frame_options.go ├── frame_options_test.go ├── frame_session.go ├── frame_test.go ├── helpers.go ├── helpers_test.go ├── hooks.go ├── http.go ├── http_test.go ├── js │ ├── actions.go │ ├── doc.go │ ├── embedded_scripts.go │ ├── injected_script.js │ ├── query_all.js │ ├── scroll_into_view.js │ ├── selectors.go │ ├── web_vital_iife.js │ └── web_vital_init.js ├── js_handle.go ├── keyboard.go ├── keyboard_test.go ├── kill_linux.go ├── kill_other.go ├── layout.go ├── layout_test.go ├── lifecycle.go ├── lifecycle_test.go ├── locator.go ├── mouse.go ├── mouse_options.go ├── network_manager.go ├── network_manager_test.go ├── network_profile.go ├── page.go ├── page_options.go ├── page_test.go ├── remote_object.go ├── remote_object_test.go ├── screenshotter.go ├── selectors.go ├── session.go ├── session_test.go ├── timeout.go ├── timeout_test.go ├── touchscreen.go ├── trace.go └── worker.go ├── docker-compose.yaml ├── env └── env.go ├── examples ├── colorscheme.js ├── cookies.js ├── device_emulation.js ├── dispatch.js ├── elementstate.js ├── evaluate.js ├── fillform.js ├── getattribute.js ├── grant_permission.js ├── hosts.js ├── keyboard.js ├── locator.js ├── locator_pom.js ├── mouse.js ├── multiple-scenario.js ├── pageon-metric.js ├── pageon.js ├── querying.js ├── screenshot.js ├── shadowdom.js ├── throttle.js ├── touchscreen.js ├── useragent.js ├── waitForEvent.js └── waitforfunction.js ├── go.mod ├── go.sum ├── k6error └── internal.go ├── k6ext ├── context.go ├── doc.go ├── k6test │ ├── doc.go │ ├── executor.go │ └── vu.go ├── metrics.go ├── panic.go └── promise.go ├── keyboardlayout ├── layout.go └── us.go ├── log └── logger.go ├── packaging ├── full-white-stripe.jpg ├── nfpm.yaml ├── thin-white-stripe.jpg ├── xk6-browser.ico └── xk6-browser.wxs ├── register.go ├── release notes ├── v0.1.0.md ├── v0.1.1.md ├── v0.1.2.md ├── v0.1.3.md ├── v0.2.0.md ├── v0.3.0.md ├── v0.4.0.md ├── v0.5.0.md ├── v0.6.0.md ├── v0.7.0.md ├── v0.8.0.md └── v0.8.1.md ├── storage ├── file_persister.go ├── file_persister_test.go ├── storage.go └── storage_test.go ├── sync-examples ├── sync_colorscheme.js ├── sync_cookies.js ├── sync_device_emulation.js ├── sync_dispatch.js ├── sync_elementstate.js ├── sync_evaluate.js ├── sync_fillform.js ├── sync_getattribute.js ├── sync_grant_permission.js ├── sync_hosts.js ├── sync_keyboard.js ├── sync_locator.js ├── sync_locator_pom.js ├── sync_mouse.js ├── sync_multiple-scenario.js ├── sync_pageon.js ├── sync_querying.js ├── sync_screenshot.js ├── sync_shadowdom.js ├── sync_throttle.js ├── sync_touchscreen.js ├── sync_waitForEvent.js └── sync_waitforfunction.js ├── sync_register.go ├── tests ├── browser_context_options_test.go ├── browser_context_test.go ├── browser_test.go ├── browser_type_test.go ├── doc.go ├── element_handle_test.go ├── frame_manager_test.go ├── frame_test.go ├── helpers.go ├── js_handle_get_properties_test.go ├── js_handle_test.go ├── keyboard_test.go ├── launch_options_slowmo_test.go ├── lifecycle_wait_test.go ├── locator_test.go ├── logrus_hook.go ├── mouse_test.go ├── network_manager_test.go ├── page_test.go ├── remote_obj_test.go ├── setinputfiles_test.go ├── static │ ├── concealed_link.html │ ├── dialog.html │ ├── embedded_iframe.html │ ├── iframe_home.html │ ├── iframe_signin.html │ ├── iframe_test_main.html │ ├── iframe_test_nested1.html │ ├── iframe_test_nested2.html │ ├── lifecycle.html │ ├── lifecycle_main_frame.html │ ├── lifecycle_no_ping_js.html │ ├── locators.html │ ├── mouse_helper.js │ ├── nav_in_doc.html │ ├── non_clickable.html │ ├── page1.html │ ├── page2.html │ ├── ping.html │ ├── select_options.html │ ├── shadow_and_doc_frag.html │ ├── shadow_dom_link.html │ ├── usual.html │ ├── visible.html │ ├── wait_for.html │ ├── wait_until.html │ └── web_vitals.html ├── test_browser.go ├── test_browser_proxy.go ├── test_browser_test.go ├── tracing_test.go ├── webvital_test.go └── ws │ └── server.go └── trace └── trace.go /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Use this template for reporting bugs. Please search existing issues first. 3 | labels: bug 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Brief summary 8 | validations: 9 | required: true 10 | - type: markdown 11 | attributes: 12 | value: '## Environment' 13 | - type: input 14 | attributes: 15 | label: xk6-browser version 16 | validations: 17 | required: true 18 | - type: input 19 | attributes: 20 | label: OS 21 | description: e.g. Windows 10, Arch Linux, macOS 11, etc. 22 | validations: 23 | required: true 24 | - type: input 25 | attributes: 26 | label: Chrome version 27 | description: e.g. 105.0.5195.102. 28 | validations: 29 | required: true 30 | - type: input 31 | attributes: 32 | label: Docker version and image (if applicable) 33 | - type: markdown 34 | attributes: 35 | value: '## Detailed issue description' 36 | - type: textarea 37 | attributes: 38 | label: Steps to reproduce the problem 39 | description: Please include a small k6 test script if possible. 40 | validations: 41 | required: true 42 | - type: textarea 43 | attributes: 44 | label: Expected behaviour 45 | validations: 46 | required: true 47 | - type: textarea 48 | attributes: 49 | label: Actual behaviour 50 | validations: 51 | required: true 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Community Forum 4 | url: https://community.grafana.com/c/grafana-k6/k6-browser/79 5 | about: Please ask and answer questions here. 6 | - name: Documentation 7 | url: https://github.com/grafana/k6-docs 8 | about: Please add any documentation related issues here. 9 | - name: Migration Guide 10 | url: https://k6.io/docs/using-k6-browser/migrating-to-k6-v0-46/ 11 | about: Please review the guide if you're migrating to k6 v0.46+ from an older version of xk6-browser. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feat_req.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Use this template for suggesting new features. 3 | labels: feature 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Feature Description 8 | description: A clear and concise description of the problem or missing capability 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Suggested Solution (optional) 14 | description: If you have a solution in mind, please describe it. 15 | - type: textarea 16 | attributes: 17 | label: Already existing or connected issues / PRs (optional) 18 | description: If you have found some issues or pull requests that are related to your new issue, please link them here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/team_issues.yaml: -------------------------------------------------------------------------------- 1 | name: Team issue template 2 | description: This template is reserved for team members. 3 | title: "Keep the title concise. Use the common language we can use to identify the issue." 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What? 8 | description: A concise (or detailed) description of this issue's goal. 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Why? 14 | description: A concise (or detailed) explanation of why we need this issue. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: How? 20 | description: Do you have any suggestions/solutions for solving the issue? 21 | validations: 22 | required: false 23 | - type: textarea 24 | attributes: 25 | label: Tasks 26 | description: Feel free to remove the tasklist items that are not relevant to this issue. 27 | value: | 28 | ```[tasklist] 29 | ### Tasks 30 | - [ ] Subissue 31 | - [ ] PR 32 | - [ ] Docs PR 33 | - [ ] TypeScript Definitions PR 34 | - [ ] Update the k6 release notes and release tasks 35 | ``` 36 | validations: 37 | required: false 38 | - type: textarea 39 | attributes: 40 | label: Related PR(s)/Issue(s) 41 | description: Related issues, PRs, external reference links, etc. 42 | validations: 43 | required: false 44 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What? 2 | 3 | 4 | 5 | ## Why? 6 | 7 | 8 | 9 | ## Checklist 10 | 11 | 15 | 16 | - [ ] I have performed a self-review of my code 17 | - [ ] I have added tests for my changes 18 | - [ ] I have commented on my code, particularly in hard-to-understand areas 19 | 20 | ## Related PR(s)/Issue(s) 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.github/bin/install_chrome_deps_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sudo apt-get update -y 3 | sudo apt-get install -y libpangocairo-1.0-0 libx11-xcb1 libxcomposite1 \ 4 | libxcursor1 libxdamage1 libxi6 libxtst6 libnss3 libcups2 libxss1 libxrandr2 \ 5 | libgconf-2-4 libasound2 libatk1.0-0 libgtk-3-0 6 | sudo apt-get install -y libgbm-dev libxshmfence1 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | allow: 9 | - dependency-name: "go.k6.io/k6" 10 | dependency-type: "all" 11 | - dependency-name: "github.com/chromedp/cdproto" 12 | dependency-type: "all" 13 | - dependency-name: "github.com/mstoykov/k6-taskqueue-lib" 14 | dependency-type: "all" 15 | - dependency-name: "github.com/stretchr/testify" 16 | dependency-type: "all" 17 | - dependency-name: "github.com/gorilla/websocket" 18 | dependency-type: "all" 19 | - dependency-name: "github.com/mccutchen/go-httpbin" 20 | dependency-type: "all" 21 | target-branch: "main" 22 | commit-message: 23 | prefix: "Bump " 24 | include: scope 25 | assignees: 26 | - "octocat" 27 | reviewers: 28 | - "inancgumus" 29 | - "ankur22" -------------------------------------------------------------------------------- /.github/workflows/block-fixup.yml: -------------------------------------------------------------------------------- 1 | name: Block fixup commits 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | block-fixup: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Block Fixup Commit Merge 12 | uses: 13rac1/block-fixup-merge-action@v2.0.0 13 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: E2E 2 | on: 3 | # Enable manually triggering this workflow via the API or web UI 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | go: [stable, tip] 19 | platform: [ubuntu-latest, windows-latest, macos-latest] 20 | runs-on: ${{ matrix.platform }} 21 | steps: 22 | - name: Checkout code 23 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 24 | uses: actions/checkout@v4 25 | - name: Install Go 26 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: 1.x 30 | - name: Install Go tip 31 | if: matrix.go == 'tip' && matrix.platform != 'windows-latest' 32 | run: | 33 | go install golang.org/dl/gotip@latest 34 | gotip download 35 | echo "GOROOT=$HOME/sdk/gotip" >> "$GITHUB_ENV" 36 | echo "GOPATH=$HOME/go" >> "$GITHUB_ENV" 37 | echo "$HOME/go/bin" >> "$GITHUB_PATH" 38 | echo "$HOME/sdk/gotip/bin" >> "$GITHUB_PATH" 39 | - name: Install xk6 40 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 41 | run: go install go.k6.io/xk6/cmd/xk6@master 42 | - name: Build extension 43 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 44 | run: | 45 | which go 46 | go version 47 | 48 | GOPRIVATE="go.k6.io/k6" xk6 build \ 49 | --output ./k6extension \ 50 | --with github.com/grafana/xk6-browser=. 51 | ./k6extension version 52 | - name: Run E2E tests 53 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 54 | run: | 55 | set -x 56 | if [ "$RUNNER_OS" == "Linux" ]; then 57 | export K6_BROWSER_EXECUTABLE_PATH=/usr/bin/google-chrome 58 | fi 59 | export K6_BROWSER_HEADLESS=true 60 | for f in examples/*.js; do 61 | if [ "$f" == "examples/hosts.js" ] && [ "$RUNNER_OS" == "Windows" ]; then 62 | echo "skipping $f on Windows" 63 | continue 64 | fi 65 | ./k6extension run -q "$f" 66 | done 67 | - name: Check screenshot 68 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 69 | # TODO: Do something more sophisticated? 70 | run: test -s screenshot.png 71 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | # Enable manually triggering this workflow via the API or web UI 4 | workflow_dispatch: 5 | pull_request: 6 | 7 | defaults: 8 | run: 9 | shell: bash 10 | 11 | jobs: 12 | deps: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.23.x 23 | check-latest: true 24 | - name: Check dependencies 25 | run: | 26 | go version 27 | test -z "$(go mod tidy && git status --porcelain)" 28 | go mod verify 29 | - name: Check code generation 30 | run: | 31 | go install github.com/alvaroloes/enumer@v1.1.2 32 | go install github.com/mailru/easyjson/easyjson@v0.7.7 33 | test -z "$(go generate ./... && git status --porcelain)" 34 | 35 | lint: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | - name: Install Go 43 | uses: actions/setup-go@v5 44 | with: 45 | go-version: 1.23.x 46 | check-latest: true 47 | - name: Retrieve golangci-lint version 48 | run: | 49 | echo "::set-output name=Version::$(head -n 1 "${GITHUB_WORKSPACE}/.golangci.yml" | tr -d '# ')" 50 | id: version 51 | - name: golangci-lint 52 | uses: golangci/golangci-lint-action@349d20632dbaed38f0a492cc991152e3d351e854 # latest commit at the time that uses node20 53 | with: 54 | # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. 55 | version: ${{ steps.version.outputs.Version }} 56 | skip-cache: true 57 | -------------------------------------------------------------------------------- /.github/workflows/sync_e2e.yml: -------------------------------------------------------------------------------- 1 | name: sync_E2E 2 | on: 3 | # Enable manually triggering this workflow via the API or web UI 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | go: [stable, tip] 19 | platform: [ubuntu-latest, windows-latest, macos-latest] 20 | runs-on: ${{ matrix.platform }} 21 | steps: 22 | - name: Checkout code 23 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 24 | uses: actions/checkout@v4 25 | - name: Install Go 26 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 27 | uses: actions/setup-go@v5 28 | with: 29 | go-version: 1.x 30 | - name: Install Go tip 31 | if: matrix.go == 'tip' && matrix.platform != 'windows-latest' 32 | run: | 33 | go install golang.org/dl/gotip@latest 34 | gotip download 35 | echo "GOROOT=$HOME/sdk/gotip" >> "$GITHUB_ENV" 36 | echo "GOPATH=$HOME/go" >> "$GITHUB_ENV" 37 | echo "$HOME/go/bin" >> "$GITHUB_PATH" 38 | echo "$HOME/sdk/gotip/bin" >> "$GITHUB_PATH" 39 | - name: Install xk6 40 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 41 | run: go install go.k6.io/xk6/cmd/xk6@master 42 | - name: Build extension 43 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 44 | run: | 45 | which go 46 | go version 47 | 48 | GOPRIVATE="go.k6.io/k6" xk6 build \ 49 | --output ./k6extension \ 50 | --with github.com/grafana/xk6-browser=. 51 | ./k6extension version 52 | - name: Run E2E tests 53 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 54 | run: | 55 | set -x 56 | if [ "$RUNNER_OS" == "Linux" ]; then 57 | export K6_BROWSER_EXECUTABLE_PATH=/usr/bin/google-chrome 58 | fi 59 | export K6_BROWSER_HEADLESS=true 60 | for f in sync-examples/*.js; do 61 | if [ "$f" == "sync-examples/sync_hosts.js" ] && [ "$RUNNER_OS" == "Windows" ]; then 62 | echo "skipping $f on Windows" 63 | continue 64 | fi 65 | ./k6extension run -q "$f" 66 | done 67 | - name: Check screenshot 68 | if: matrix.go != 'tip' || matrix.platform != 'windows-latest' 69 | # TODO: Do something more sophisticated? 70 | run: test -s screenshot.png 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | /*.html 14 | 15 | # Text and log files 16 | *.txt 17 | *.log 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | # OSX directory entries 23 | .DS_Store 24 | 25 | # Screenshot example output 26 | screenshots -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/k6-core 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19-bullseye as builder 2 | 3 | RUN go install -trimpath go.k6.io/xk6/cmd/xk6@latest 4 | 5 | RUN xk6 build --output "/tmp/k6" --with github.com/grafana/xk6-browser 6 | 7 | FROM debian:bullseye 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y chromium 11 | 12 | COPY --from=builder /tmp/k6 /usr/bin/k6 13 | 14 | ENV K6_BROWSER_HEADLESS=true 15 | 16 | ENTRYPOINT ["k6"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOLANGCI_LINT_VERSION = $(shell head -n 1 .golangci.yml | tr -d '\# ') 2 | TMPDIR ?= /tmp 3 | BASEREV = $(shell git merge-base HEAD origin/main) 4 | 5 | all: build 6 | 7 | build : 8 | go install go.k6.io/xk6/cmd/xk6@latest && xk6 build --output xk6-browser --with github.com/grafana/xk6-browser=. 9 | 10 | format : 11 | find . -name '*.go' -exec gofmt -s -w {} + 12 | 13 | ci-like-lint : 14 | @docker run --rm -t -v $(shell pwd):/app \ 15 | -v $(TMPDIR)/golangci-cache-$(GOLANGCI_LINT_VERSION):/golangci-cache \ 16 | --env "GOLANGCI_LINT_CACHE=/golangci-cache" \ 17 | -w /app golangci/golangci-lint:$(GOLANGCI_LINT_VERSION) \ 18 | make lint 19 | 20 | lint : 21 | golangci-lint run --timeout=3m --out-format=tab --new-from-rev "$(BASEREV)" ./... 22 | 23 | tests : 24 | go test -race -timeout 210s ./... 25 | 26 | check : ci-like-lint tests 27 | 28 | container: 29 | docker build --rm --pull --no-cache -t grafana/xk6-browser . 30 | 31 | .PHONY: build format ci-like-lint lint tests check container 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting security issues 2 | 3 | If you think you have found a security vulnerability, please send a report to [security@grafana.com](mailto:security@grafana.com). This address can be used for all of Grafana Labs's open source and commercial products (including but not limited to Grafana, Grafana Cloud, Grafana Enterprise, and grafana.com). We can accept only vulnerability reports at this address. 4 | 5 | Please encrypt your message to us; please use our PGP key. The key fingerprint is: 6 | 7 | ``` 8 | 225E 6A9B BB15 A37E 95EB 6312 C66A 51CC B44C 27E0 9 | ``` 10 | 11 | The key is available from [keyserver.ubuntu.com](https://keyserver.ubuntu.com/pks/lookup?search=0x225E6A9BBB15A37E95EB6312C66A51CCB44C27E0&fingerprint=on&op=index). 12 | 13 | Grafana Labs will send you a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. 14 | 15 | **Important:** We ask you to not disclose the vulnerability before it have been fixed and announced, unless you received a response from the Grafana Labs security team that you can do so. 16 | 17 | ## Security announcements 18 | 19 | We maintain a category on the community site called [Security Announcements](https://community.grafana.com/c/support/security-announcements), 20 | where we will post a summary, remediation, and mitigation details for any patch containing security fixes. 21 | 22 | You can also subscribe to email updates to this category if you have a grafana.com account and sign on to the community site or track updates via an [RSS feed](https://community.grafana.com/c/support/security-announcements.rss). 23 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | Types of questions and where to ask: 4 | 5 | - "How do I?" -- the [community forum](https://community.k6.io/c/xk6-browser/14). Please search for existing topics that may help you, and open a new topic only if none exist. 6 | - "I got this error, why?" -- the [community forum](https://community.k6.io/c/xk6-browser/14). 7 | - "I got this error and I'm sure it's a bug" -- search the [GitHub issues](https://github.com/grafana/xk6-browser/issues) to see if it was already reported and, if so, give the issue a :+1:. If it wasn't, [open a new issue](https://github.com/grafana/xk6-browser/issues) and add a `bug` label. Make sure to specify your xk6-browser version, OS and OS version, browser version, and a small JS script with detailed steps of how to reproduce the issue. 8 | - "I have an idea/request" -- search the [GitHub issues](https://github.com/grafana/xk6-browser/issues) to see if it was already requested and, if so, give the issue a :+1:. If it wasn't, search the [community forum](https://community.k6.io/) or post a forum thread to discuss the idea with the developers before creating a GitHub issue. 9 | 10 | For all other questions, use the [community forum](https://community.k6.io/c/xk6-browser/14) or [Slack](https://k6.io/slack). 11 | 12 | If your questions are related to the commercial [k6 Cloud](https://k6.io/cloud/) service, you can contact or write in the `#k6-cloud` channel on [Slack](https://k6.io/slack). 13 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | If you're having issues installing or running xk6-browser, this document is a good place to start. If your issue is not mentioned here, please see [SUPPORT.md](/SUPPORT.md). 4 | 5 | ## Timeout error launching the browser 6 | 7 | If you're using Ubuntu, including under Microsoft's WSL2, and getting the following error consistently (i.e. in every test run): 8 | 9 | > `launching browser: getting DevTools URL: timed out after 30s` 10 | 11 | Confirm that you don't have the `chromium-browser` package installed. This should return no results: 12 | 13 | ```shell 14 | dpkg -l | grep '^ii chromium-browser' 15 | ``` 16 | 17 | On recent versions of Ubuntu (>=19.10), this is a transitional DEB package for the Snap Chromium package. 18 | 19 | Running the browser in a container like Snap or Flatpak is not supported by xk6-browser. 20 | 21 | To resolve this, remove the `chromium-browser` package, and install a native DEB package. Since Ubuntu doesn't carry one in their repositories, you will need to add an external repository that does. We recommend only using trusted repositories, preferably from Google itself. 22 | 23 | If you're OK with using Google Chrome instead of Chromium, run the following commands: 24 | 25 | ```shell 26 | sudo apt remove -y chromium-browser 27 | wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 28 | sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' 29 | sudo apt update && sudo apt install -y google-chrome-stable 30 | ``` 31 | 32 | Then try running the xk6-browser test again. 33 | -------------------------------------------------------------------------------- /assets/github-hr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /browser/console_message_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/common" 5 | ) 6 | 7 | // mapConsoleMessage to the JS module. 8 | func mapConsoleMessage(vu moduleVU, event common.PageOnEvent) mapping { 9 | cm := event.ConsoleMessage 10 | 11 | return mapping{ 12 | "args": func() []mapping { 13 | var ( 14 | margs []mapping 15 | args = cm.Args 16 | ) 17 | for _, arg := range args { 18 | a := mapJSHandle(vu, arg) 19 | margs = append(margs, a) 20 | } 21 | 22 | return margs 23 | }, 24 | // page(), text() and type() are defined as 25 | // functions in order to match Playwright's API 26 | "page": func() mapping { 27 | return mapPage(vu, cm.Page) 28 | }, 29 | "text": func() string { 30 | return cm.Text 31 | }, 32 | "type": func() string { 33 | return cm.Type 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /browser/helpers.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "github.com/grafana/sobek" 9 | 10 | "github.com/grafana/xk6-browser/k6error" 11 | "github.com/grafana/xk6-browser/k6ext" 12 | ) 13 | 14 | func panicIfFatalError(ctx context.Context, err error) { 15 | if errors.Is(err, k6error.ErrFatal) { 16 | k6ext.Abort(ctx, err.Error()) 17 | } 18 | } 19 | 20 | // mergeWith merges the Sobek value with the existing Go value. 21 | func mergeWith[T any](rt *sobek.Runtime, src T, v sobek.Value) error { 22 | if !sobekValueExists(v) { 23 | return nil 24 | } 25 | return rt.ExportTo(v, &src) //nolint:wrapcheck 26 | } 27 | 28 | // exportTo exports the Sobek value to a Go value. 29 | // It returns the zero value of T if obj does not exist in the Sobek runtime. 30 | // It's caller's responsibility to check for nilness. 31 | func exportTo[T any](rt *sobek.Runtime, obj sobek.Value) (T, error) { 32 | var t T 33 | if !sobekValueExists(obj) { 34 | return t, nil 35 | } 36 | err := rt.ExportTo(obj, &t) 37 | return t, err //nolint:wrapcheck 38 | } 39 | 40 | // exportArg exports the value and returns it. 41 | // It returns nil if the value is undefined or null. 42 | func exportArg(gv sobek.Value) any { 43 | if !sobekValueExists(gv) { 44 | return nil 45 | } 46 | return gv.Export() 47 | } 48 | 49 | // exportArgs returns a slice of exported sobek values. 50 | func exportArgs(gargs []sobek.Value) []any { 51 | args := make([]any, 0, len(gargs)) 52 | for _, garg := range gargs { 53 | // leaves a nil garg in the array since users might want to 54 | // pass undefined or null as an argument to a function 55 | args = append(args, exportArg(garg)) 56 | } 57 | return args 58 | } 59 | 60 | // sobekValueExists returns true if a given value is not nil and exists 61 | // (defined and not null) in the sobek runtime. 62 | func sobekValueExists(v sobek.Value) bool { 63 | return v != nil && !sobek.IsUndefined(v) && !sobek.IsNull(v) 64 | } 65 | 66 | // sobekEmptyString returns true if a given value is not nil or an empty string. 67 | func sobekEmptyString(v sobek.Value) bool { 68 | return !sobekValueExists(v) || strings.TrimSpace(v.String()) == "" 69 | } 70 | -------------------------------------------------------------------------------- /browser/helpers_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/sobek" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestSobekEmptyString(t *testing.T) { 11 | t.Parallel() 12 | // SobekEmpty string should return true if the argument 13 | // is an empty string or not defined in the Sobek runtime. 14 | rt := sobek.New() 15 | require.NoError(t, rt.Set("sobekEmptyString", sobekEmptyString)) 16 | for _, s := range []string{"() => true", "'() => false'"} { // not empty 17 | v, err := rt.RunString(`sobekEmptyString(` + s + `)`) 18 | require.NoError(t, err) 19 | require.Falsef(t, v.ToBoolean(), "got: true, want: false for %q", s) 20 | } 21 | for _, s := range []string{"", " ", "null", "undefined"} { // empty 22 | v, err := rt.RunString(`sobekEmptyString(` + s + `)`) 23 | require.NoError(t, err) 24 | require.Truef(t, v.ToBoolean(), "got: false, want: true for %q", s) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /browser/js_handle_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | 8 | "github.com/grafana/xk6-browser/common" 9 | "github.com/grafana/xk6-browser/k6ext" 10 | ) 11 | 12 | // mapJSHandle to the JS module. 13 | func mapJSHandle(vu moduleVU, jsh common.JSHandleAPI) mapping { 14 | return mapping{ 15 | "asElement": func() mapping { 16 | return mapElementHandle(vu, jsh.AsElement()) 17 | }, 18 | "dispose": func() *sobek.Promise { 19 | return k6ext.Promise(vu.Context(), func() (any, error) { 20 | return nil, jsh.Dispose() 21 | }) 22 | }, 23 | "evaluate": func(pageFunc sobek.Value, gargs ...sobek.Value) (*sobek.Promise, error) { 24 | if sobekEmptyString(pageFunc) { 25 | return nil, fmt.Errorf("evaluate requires a page function") 26 | } 27 | return k6ext.Promise(vu.Context(), func() (any, error) { 28 | args := make([]any, 0, len(gargs)) 29 | for _, a := range gargs { 30 | args = append(args, exportArg(a)) 31 | } 32 | return jsh.Evaluate(pageFunc.String(), args...) 33 | }), nil 34 | }, 35 | "evaluateHandle": func(pageFunc sobek.Value, gargs ...sobek.Value) (*sobek.Promise, error) { 36 | if sobekEmptyString(pageFunc) { 37 | return nil, fmt.Errorf("evaluateHandle requires a page function") 38 | } 39 | return k6ext.Promise(vu.Context(), func() (any, error) { 40 | h, err := jsh.EvaluateHandle(pageFunc.String(), exportArgs(gargs)...) 41 | if err != nil { 42 | return nil, err //nolint:wrapcheck 43 | } 44 | return mapJSHandle(vu, h), nil 45 | }), nil 46 | }, 47 | "getProperties": func() *sobek.Promise { 48 | return k6ext.Promise(vu.Context(), func() (any, error) { 49 | props, err := jsh.GetProperties() 50 | if err != nil { 51 | return nil, err //nolint:wrapcheck 52 | } 53 | 54 | dst := make(map[string]any) 55 | for k, v := range props { 56 | dst[k] = mapJSHandle(vu, v) 57 | } 58 | return dst, nil 59 | }) 60 | }, 61 | "jsonValue": func() *sobek.Promise { 62 | return k6ext.Promise(vu.Context(), func() (any, error) { 63 | return jsh.JSONValue() //nolint:wrapcheck 64 | }) 65 | }, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /browser/keyboard_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | 8 | "github.com/grafana/xk6-browser/common" 9 | "github.com/grafana/xk6-browser/k6ext" 10 | ) 11 | 12 | func mapKeyboard(vu moduleVU, kb *common.Keyboard) mapping { 13 | return mapping{ 14 | "down": func(key string) *sobek.Promise { 15 | return k6ext.Promise(vu.Context(), func() (any, error) { 16 | return nil, kb.Down(key) //nolint:wrapcheck 17 | }) 18 | }, 19 | "up": func(key string) *sobek.Promise { 20 | return k6ext.Promise(vu.Context(), func() (any, error) { 21 | return nil, kb.Up(key) //nolint:wrapcheck 22 | }) 23 | }, 24 | "press": func(key string, opts sobek.Value) *sobek.Promise { 25 | return k6ext.Promise(vu.Context(), func() (any, error) { 26 | kbopts, err := exportTo[common.KeyboardOptions](vu.Runtime(), opts) 27 | if err != nil { 28 | return nil, fmt.Errorf("parsing keyboard options: %w", err) 29 | } 30 | return nil, kb.Press(key, kbopts) 31 | }) 32 | }, 33 | "type": func(text string, opts sobek.Value) *sobek.Promise { 34 | return k6ext.Promise(vu.Context(), func() (any, error) { 35 | kbopts, err := exportTo[common.KeyboardOptions](vu.Runtime(), opts) 36 | if err != nil { 37 | return nil, fmt.Errorf("parsing keyboard options: %w", err) 38 | } 39 | return nil, kb.Type(text, kbopts) 40 | }) 41 | }, 42 | "insertText": func(text string) *sobek.Promise { 43 | return k6ext.Promise(vu.Context(), func() (any, error) { 44 | return nil, kb.InsertText(text) //nolint:wrapcheck 45 | }) 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /browser/mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/grafana/sobek" 9 | 10 | "github.com/grafana/xk6-browser/common" 11 | 12 | k6common "go.k6.io/k6/js/common" 13 | ) 14 | 15 | // mapping is a type for mapping our module API to sobek. 16 | // It acts like a bridge and allows adding wildcard methods 17 | // and customization over our API. 18 | type mapping = map[string]any 19 | 20 | // mapBrowserToSobek maps the browser API to the JS module. 21 | // The motivation of this mapping was to support $ and $$ wildcard 22 | // methods. 23 | // See issue #661 for more details. 24 | func mapBrowserToSobek(vu moduleVU) *sobek.Object { 25 | var ( 26 | rt = vu.Runtime() 27 | obj = rt.NewObject() 28 | ) 29 | for k, v := range mapBrowser(vu) { 30 | err := obj.Set(k, rt.ToValue(v)) 31 | if err != nil { 32 | k6common.Throw(rt, fmt.Errorf("mapping: %w", err)) 33 | } 34 | } 35 | 36 | return obj 37 | } 38 | 39 | func parseFrameClickOptions( 40 | ctx context.Context, opts sobek.Value, defaultTimeout time.Duration, 41 | ) (*common.FrameClickOptions, error) { 42 | copts := common.NewFrameClickOptions(defaultTimeout) 43 | if err := copts.Parse(ctx, opts); err != nil { 44 | return nil, fmt.Errorf("parsing click options: %w", err) 45 | } 46 | return copts, nil 47 | } 48 | -------------------------------------------------------------------------------- /browser/metric_event_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | ) 8 | 9 | // mapMetricEvent to the JS module. 10 | func mapMetricEvent(vu moduleVU, event common.PageOnEvent) mapping { 11 | rt := vu.Runtime() 12 | em := event.Metric 13 | 14 | return mapping{ 15 | "tag": func(urls common.TagMatches) error { 16 | callback := func(pattern, url string) (bool, error) { 17 | js := fmt.Sprintf(`_k6BrowserCheckRegEx(%s, '%s')`, pattern, url) 18 | 19 | matched, err := rt.RunString(js) 20 | if err != nil { 21 | return false, fmt.Errorf("matching url with regex: %w", err) 22 | } 23 | 24 | return matched.ToBoolean(), nil 25 | } 26 | 27 | return em.Tag(callback, urls) //nolint:wrapcheck 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /browser/module_test.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/grafana/xk6-browser/k6ext/k6test" 9 | ) 10 | 11 | // TestModuleNew tests registering the module. 12 | // It doesn't test the module's remaining functionality as it is 13 | // already tested in the tests/ integration tests. 14 | func TestModuleNew(t *testing.T) { 15 | t.Parallel() 16 | 17 | vu := k6test.NewVU(t) 18 | m, ok := New().NewModuleInstance(vu).(*ModuleInstance) 19 | require.True(t, ok, "NewModuleInstance should return a ModuleInstance") 20 | require.NotNil(t, m.mod, "Module should be set") 21 | require.NotNil(t, m.mod.Browser, "Browser should be set") 22 | require.NotNil(t, m.mod.Devices, "Devices should be set") 23 | require.NotNil(t, m.mod.NetworkProfiles, "Profiles should be set") 24 | } 25 | -------------------------------------------------------------------------------- /browser/modulevu.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | 9 | k6modules "go.k6.io/k6/js/modules" 10 | ) 11 | 12 | // moduleVU carries module specific VU information. 13 | // 14 | // Currently, it is used to carry the VU object to the inner objects and 15 | // promises. 16 | type moduleVU struct { 17 | k6modules.VU 18 | 19 | *pidRegistry 20 | *browserRegistry 21 | 22 | *taskQueueRegistry 23 | 24 | filePersister 25 | 26 | testRunID string 27 | } 28 | 29 | // browser returns the VU browser instance for the current iteration. 30 | func (vu moduleVU) browser() (*common.Browser, error) { 31 | return vu.browserRegistry.getBrowser(vu.State().Iteration) 32 | } 33 | 34 | func (vu moduleVU) Context() context.Context { 35 | // promises and inner objects need the VU object to be 36 | // able to use k6-core specific functionality. 37 | // 38 | // We should not cache the context (especially the init 39 | // context from the vu that is received from k6 in 40 | // NewModuleInstance). 41 | return k6ext.WithVU(vu.VU.Context(), vu) 42 | } 43 | -------------------------------------------------------------------------------- /browser/mouse_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | ) 9 | 10 | func mapMouse(vu moduleVU, m *common.Mouse) mapping { 11 | return mapping{ 12 | "click": func(x float64, y float64, opts sobek.Value) *sobek.Promise { 13 | return k6ext.Promise(vu.Context(), func() (any, error) { 14 | return nil, m.Click(x, y, opts) //nolint:wrapcheck 15 | }) 16 | }, 17 | "dblClick": func(x float64, y float64, opts sobek.Value) *sobek.Promise { 18 | return k6ext.Promise(vu.Context(), func() (any, error) { 19 | return nil, m.DblClick(x, y, opts) //nolint:wrapcheck 20 | }) 21 | }, 22 | "down": func(opts sobek.Value) *sobek.Promise { 23 | return k6ext.Promise(vu.Context(), func() (any, error) { 24 | return nil, m.Down(opts) //nolint:wrapcheck 25 | }) 26 | }, 27 | "up": func(opts sobek.Value) *sobek.Promise { 28 | return k6ext.Promise(vu.Context(), func() (any, error) { 29 | return nil, m.Up(opts) //nolint:wrapcheck 30 | }) 31 | }, 32 | "move": func(x float64, y float64, opts sobek.Value) *sobek.Promise { 33 | return k6ext.Promise(vu.Context(), func() (any, error) { 34 | return nil, m.Move(x, y, opts) //nolint:wrapcheck 35 | }) 36 | }, 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /browser/request_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | ) 9 | 10 | // mapRequest to the JS module. 11 | func mapRequest(vu moduleVU, r *common.Request) mapping { 12 | rt := vu.Runtime() 13 | maps := mapping{ 14 | "allHeaders": func() *sobek.Promise { 15 | return k6ext.Promise(vu.Context(), func() (any, error) { 16 | return r.AllHeaders(), nil 17 | }) 18 | }, 19 | "frame": func() *sobek.Object { 20 | mf := mapFrame(vu, r.Frame()) 21 | return rt.ToValue(mf).ToObject(rt) 22 | }, 23 | "headerValue": func(name string) *sobek.Promise { 24 | return k6ext.Promise(vu.Context(), func() (any, error) { 25 | v, ok := r.HeaderValue(name) 26 | if !ok { 27 | return nil, nil 28 | } 29 | 30 | return v, nil 31 | }) 32 | }, 33 | "headers": r.Headers, 34 | "headersArray": func() *sobek.Promise { 35 | return k6ext.Promise(vu.Context(), func() (any, error) { 36 | return r.HeadersArray(), nil 37 | }) 38 | }, 39 | "isNavigationRequest": r.IsNavigationRequest, 40 | "method": r.Method, 41 | "postData": func() any { 42 | p := r.PostData() 43 | if p == "" { 44 | return nil 45 | } 46 | return p 47 | }, 48 | "postDataBuffer": func() any { 49 | p := r.PostDataBuffer() 50 | if len(p) == 0 { 51 | return nil 52 | } 53 | return rt.NewArrayBuffer(p) 54 | }, 55 | "resourceType": r.ResourceType, 56 | "response": func() *sobek.Promise { 57 | return k6ext.Promise(vu.Context(), func() (any, error) { 58 | resp := r.Response() 59 | if resp == nil { 60 | return nil, nil 61 | } 62 | return mapResponse(vu, resp), nil 63 | }) 64 | }, 65 | "size": r.Size, 66 | "timing": r.Timing, 67 | "url": r.URL, 68 | } 69 | 70 | return maps 71 | } 72 | -------------------------------------------------------------------------------- /browser/response_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | ) 9 | 10 | // mapResponse to the JS module. 11 | func mapResponse(vu moduleVU, r *common.Response) mapping { 12 | if r == nil { 13 | return nil 14 | } 15 | maps := mapping{ 16 | "allHeaders": func() *sobek.Promise { 17 | return k6ext.Promise(vu.Context(), func() (any, error) { 18 | return r.AllHeaders(), nil 19 | }) 20 | }, 21 | "body": func() *sobek.Promise { 22 | return k6ext.Promise(vu.Context(), func() (any, error) { 23 | body, err := r.Body() 24 | if err != nil { 25 | return nil, err //nolint: wrapcheck 26 | } 27 | buf := vu.Runtime().NewArrayBuffer(body) 28 | return &buf, nil 29 | }) 30 | }, 31 | "frame": func() mapping { 32 | return mapFrame(vu, r.Frame()) 33 | }, 34 | "headerValue": func(name string) *sobek.Promise { 35 | return k6ext.Promise(vu.Context(), func() (any, error) { 36 | v, ok := r.HeaderValue(name) 37 | if !ok { 38 | return nil, nil 39 | } 40 | return v, nil 41 | }) 42 | }, 43 | "headerValues": func(name string) *sobek.Promise { 44 | return k6ext.Promise(vu.Context(), func() (any, error) { 45 | return r.HeaderValues(name), nil 46 | }) 47 | }, 48 | "headers": r.Headers, 49 | "headersArray": func() *sobek.Promise { 50 | return k6ext.Promise(vu.Context(), func() (any, error) { 51 | return r.HeadersArray(), nil 52 | }) 53 | }, 54 | "json": func() *sobek.Promise { 55 | return k6ext.Promise(vu.Context(), func() (any, error) { 56 | return r.JSON() //nolint: wrapcheck 57 | }) 58 | }, 59 | "ok": r.Ok, 60 | "request": func() mapping { 61 | return mapRequest(vu, r.Request()) 62 | }, 63 | "securityDetails": func() *sobek.Promise { 64 | return k6ext.Promise(vu.Context(), func() (any, error) { 65 | return r.SecurityDetails(), nil 66 | }) 67 | }, 68 | "serverAddr": func() *sobek.Promise { 69 | return k6ext.Promise(vu.Context(), func() (any, error) { 70 | return r.ServerAddr(), nil 71 | }) 72 | }, 73 | "size": func() *sobek.Promise { 74 | return k6ext.Promise(vu.Context(), func() (any, error) { 75 | return r.Size(), nil 76 | }) 77 | }, 78 | "status": r.Status, 79 | "statusText": r.StatusText, 80 | "url": r.URL, 81 | "text": func() *sobek.Promise { 82 | return k6ext.Promise(vu.Context(), func() (any, error) { 83 | return r.Text() //nolint:wrapcheck 84 | }) 85 | }, 86 | } 87 | 88 | return maps 89 | } 90 | -------------------------------------------------------------------------------- /browser/sync_browser_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | ) 8 | 9 | // syncMapBrowser is like mapBrowser but returns synchronous functions. 10 | func syncMapBrowser(vu moduleVU) mapping { //nolint:funlen 11 | rt := vu.Runtime() 12 | return mapping{ 13 | "context": func() (mapping, error) { 14 | b, err := vu.browser() 15 | if err != nil { 16 | return nil, err 17 | } 18 | return syncMapBrowserContext(vu, b.Context()), nil 19 | }, 20 | "closeContext": func() error { 21 | b, err := vu.browser() 22 | if err != nil { 23 | return err 24 | } 25 | return b.CloseContext() //nolint:wrapcheck 26 | }, 27 | "isConnected": func() (bool, error) { 28 | b, err := vu.browser() 29 | if err != nil { 30 | return false, err 31 | } 32 | return b.IsConnected(), nil 33 | }, 34 | "newContext": func(opts sobek.Value) (*sobek.Object, error) { 35 | popts, err := parseBrowserContextOptions(vu.Runtime(), opts) 36 | if err != nil { 37 | return nil, fmt.Errorf("parsing browser.newContext options: %w", err) 38 | } 39 | 40 | b, err := vu.browser() 41 | if err != nil { 42 | return nil, err 43 | } 44 | bctx, err := b.NewContext(popts) 45 | if err != nil { 46 | return nil, err //nolint:wrapcheck 47 | } 48 | 49 | if err := initBrowserContext(bctx, vu.testRunID); err != nil { 50 | return nil, err 51 | } 52 | 53 | m := syncMapBrowserContext(vu, bctx) 54 | 55 | return rt.ToValue(m).ToObject(rt), nil 56 | }, 57 | "userAgent": func() (string, error) { 58 | b, err := vu.browser() 59 | if err != nil { 60 | return "", err 61 | } 62 | return b.UserAgent(), nil 63 | }, 64 | "version": func() (string, error) { 65 | b, err := vu.browser() 66 | if err != nil { 67 | return "", err 68 | } 69 | return b.Version(), nil 70 | }, 71 | "newPage": func(opts sobek.Value) (mapping, error) { 72 | popts, err := parseBrowserContextOptions(vu.Runtime(), opts) 73 | if err != nil { 74 | return nil, fmt.Errorf("parsing browser.newPage options: %w", err) 75 | } 76 | 77 | b, err := vu.browser() 78 | if err != nil { 79 | return nil, err 80 | } 81 | page, err := b.NewPage(popts) 82 | if err != nil { 83 | return nil, err //nolint:wrapcheck 84 | } 85 | 86 | if err := initBrowserContext(b.Context(), vu.testRunID); err != nil { 87 | return nil, err 88 | } 89 | 90 | return syncMapPage(vu, page), nil 91 | }, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /browser/sync_console_message_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | ) 8 | 9 | // syncMapConsoleMessage is like mapConsoleMessage but returns synchronous functions. 10 | func syncMapConsoleMessage(vu moduleVU, cm *common.ConsoleMessage) mapping { 11 | rt := vu.Runtime() 12 | return mapping{ 13 | "args": func() *sobek.Object { 14 | var ( 15 | margs []mapping 16 | args = cm.Args 17 | ) 18 | for _, arg := range args { 19 | a := syncMapJSHandle(vu, arg) 20 | margs = append(margs, a) 21 | } 22 | 23 | return rt.ToValue(margs).ToObject(rt) 24 | }, 25 | // page(), text() and type() are defined as 26 | // functions in order to match Playwright's API 27 | "page": func() mapping { return syncMapPage(vu, cm.Page) }, 28 | "text": func() string { return cm.Text }, 29 | "type": func() string { return cm.Type }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /browser/sync_js_handle_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | ) 8 | 9 | // syncMapJSHandle is like mapJSHandle but returns synchronous functions. 10 | func syncMapJSHandle(vu moduleVU, jsh common.JSHandleAPI) mapping { 11 | rt := vu.Runtime() 12 | return mapping{ 13 | "asElement": func() *sobek.Object { 14 | m := syncMapElementHandle(vu, jsh.AsElement()) 15 | return rt.ToValue(m).ToObject(rt) 16 | }, 17 | "dispose": jsh.Dispose, 18 | "evaluate": func(pageFunc sobek.Value, gargs ...sobek.Value) (any, error) { 19 | args := make([]any, 0, len(gargs)) 20 | for _, a := range gargs { 21 | args = append(args, exportArg(a)) 22 | } 23 | return jsh.Evaluate(pageFunc.String(), args...) //nolint:wrapcheck 24 | }, 25 | "evaluateHandle": func(pageFunc sobek.Value, gargs ...sobek.Value) (mapping, error) { 26 | h, err := jsh.EvaluateHandle(pageFunc.String(), exportArgs(gargs)...) 27 | if err != nil { 28 | return nil, err //nolint:wrapcheck 29 | } 30 | return syncMapJSHandle(vu, h), nil 31 | }, 32 | "getProperties": func() (mapping, error) { 33 | props, err := jsh.GetProperties() 34 | if err != nil { 35 | return nil, err //nolint:wrapcheck 36 | } 37 | 38 | dst := make(map[string]any) 39 | for k, v := range props { 40 | dst[k] = syncMapJSHandle(vu, v) 41 | } 42 | return dst, nil 43 | }, 44 | "jsonValue": jsh.JSONValue, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /browser/sync_keyboard_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | 8 | "github.com/grafana/xk6-browser/common" 9 | ) 10 | 11 | func syncMapKeyboard(vu moduleVU, kb *common.Keyboard) mapping { 12 | return mapping{ 13 | "down": kb.Down, 14 | "up": kb.Up, 15 | "press": func(key string, opts sobek.Value) error { 16 | kbopts, err := exportTo[common.KeyboardOptions](vu.Runtime(), opts) 17 | if err != nil { 18 | return fmt.Errorf("parsing keyboard options: %w", err) 19 | } 20 | return kb.Press(key, kbopts) 21 | }, 22 | "type": func(text string, opts sobek.Value) error { 23 | kbopts, err := exportTo[common.KeyboardOptions](vu.Runtime(), opts) 24 | if err != nil { 25 | return fmt.Errorf("parsing keyboard options: %w", err) 26 | } 27 | return kb.Type(text, kbopts) 28 | }, 29 | "insertText": kb.InsertText, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /browser/sync_locator_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | 8 | "github.com/grafana/xk6-browser/common" 9 | "github.com/grafana/xk6-browser/k6ext" 10 | ) 11 | 12 | // syncMapLocator is like mapLocator but returns synchronous functions. 13 | func syncMapLocator(vu moduleVU, lo *common.Locator) mapping { 14 | return mapping{ 15 | "clear": func(opts sobek.Value) error { 16 | ctx := vu.Context() 17 | 18 | copts := common.NewFrameFillOptions(lo.Timeout()) 19 | if err := copts.Parse(ctx, opts); err != nil { 20 | return fmt.Errorf("parsing clear options: %w", err) 21 | } 22 | 23 | return lo.Clear(copts) //nolint:wrapcheck 24 | }, 25 | "click": func(opts sobek.Value) (*sobek.Promise, error) { 26 | popts, err := parseFrameClickOptions(vu.Context(), opts, lo.Timeout()) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | return k6ext.Promise(vu.Context(), func() (any, error) { 32 | return nil, lo.Click(popts) //nolint:wrapcheck 33 | }), nil 34 | }, 35 | "dblclick": lo.Dblclick, 36 | "check": lo.Check, 37 | "uncheck": lo.Uncheck, 38 | "isChecked": lo.IsChecked, 39 | "isEditable": lo.IsEditable, 40 | "isEnabled": lo.IsEnabled, 41 | "isDisabled": lo.IsDisabled, 42 | "isVisible": lo.IsVisible, 43 | "isHidden": lo.IsHidden, 44 | "fill": lo.Fill, 45 | "focus": lo.Focus, 46 | "getAttribute": func(name string, opts sobek.Value) (any, error) { 47 | v, ok, err := lo.GetAttribute(name, opts) 48 | if err != nil { 49 | return nil, err //nolint:wrapcheck 50 | } 51 | if !ok { 52 | return nil, nil 53 | } 54 | return v, nil 55 | }, 56 | "innerHTML": lo.InnerHTML, 57 | "innerText": lo.InnerText, 58 | "textContent": func(opts sobek.Value) (any, error) { 59 | v, ok, err := lo.TextContent(opts) 60 | if err != nil { 61 | return nil, err //nolint:wrapcheck 62 | } 63 | if !ok { 64 | return nil, nil 65 | } 66 | return v, nil 67 | }, 68 | "inputValue": lo.InputValue, 69 | "selectOption": lo.SelectOption, 70 | "press": lo.Press, 71 | "type": lo.Type, 72 | "hover": lo.Hover, 73 | "tap": func(opts sobek.Value) (*sobek.Promise, error) { 74 | copts := common.NewFrameTapOptions(lo.DefaultTimeout()) 75 | if err := copts.Parse(vu.Context(), opts); err != nil { 76 | return nil, fmt.Errorf("parsing locator tap options: %w", err) 77 | } 78 | return k6ext.Promise(vu.Context(), func() (any, error) { 79 | return nil, lo.Tap(copts) //nolint:wrapcheck 80 | }), nil 81 | }, 82 | "dispatchEvent": func(typ string, eventInit, opts sobek.Value) error { 83 | popts := common.NewFrameDispatchEventOptions(lo.DefaultTimeout()) 84 | if err := popts.Parse(vu.Context(), opts); err != nil { 85 | return fmt.Errorf("parsing locator dispatch event options: %w", err) 86 | } 87 | return lo.DispatchEvent(typ, exportArg(eventInit), popts) //nolint:wrapcheck 88 | }, 89 | "waitFor": lo.WaitFor, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /browser/sync_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/sobek" 7 | 8 | k6common "go.k6.io/k6/js/common" 9 | ) 10 | 11 | // syncMapBrowserToSobek maps the browser API to the JS module as a 12 | // synchronous version. 13 | func syncMapBrowserToSobek(vu moduleVU) *sobek.Object { 14 | var ( 15 | rt = vu.Runtime() 16 | obj = rt.NewObject() 17 | ) 18 | for k, v := range syncMapBrowser(vu) { 19 | err := obj.Set(k, rt.ToValue(v)) 20 | if err != nil { 21 | k6common.Throw(rt, fmt.Errorf("mapping: %w", err)) 22 | } 23 | } 24 | 25 | return obj 26 | } 27 | -------------------------------------------------------------------------------- /browser/sync_request_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/common" 5 | ) 6 | 7 | // syncMapRequest is like mapRequest but returns synchronous functions. 8 | func syncMapRequest(vu moduleVU, r *common.Request) mapping { 9 | maps := mapping{ 10 | "allHeaders": r.AllHeaders, 11 | "frame": func() mapping { return syncMapFrame(vu, r.Frame()) }, 12 | "headerValue": func(name string) any { 13 | v, ok := r.HeaderValue(name) 14 | if !ok { 15 | return nil 16 | } 17 | 18 | return v 19 | }, 20 | "headers": r.Headers, 21 | "headersArray": r.HeadersArray, 22 | "isNavigationRequest": r.IsNavigationRequest, 23 | "method": r.Method, 24 | "postData": r.PostData, 25 | "postDataBuffer": r.PostDataBuffer, 26 | "resourceType": r.ResourceType, 27 | "response": func() mapping { return syncMapResponse(vu, r.Response()) }, 28 | "size": r.Size, 29 | "timing": r.Timing, 30 | "url": r.URL, 31 | } 32 | 33 | return maps 34 | } 35 | -------------------------------------------------------------------------------- /browser/sync_response_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/common" 5 | ) 6 | 7 | // syncMapResponse is like mapResponse but returns synchronous functions. 8 | func syncMapResponse(vu moduleVU, r *common.Response) mapping { 9 | if r == nil { 10 | return nil 11 | } 12 | maps := mapping{ 13 | "allHeaders": r.AllHeaders, 14 | "body": r.Body, 15 | "frame": func() mapping { return syncMapFrame(vu, r.Frame()) }, 16 | "headerValue": r.HeaderValue, 17 | "headerValues": r.HeaderValues, 18 | "headers": r.Headers, 19 | "headersArray": r.HeadersArray, 20 | "json": r.JSON, 21 | "ok": r.Ok, 22 | "request": func() mapping { return syncMapRequest(vu, r.Request()) }, 23 | "securityDetails": r.SecurityDetails, 24 | "serverAddr": r.ServerAddr, 25 | "size": r.Size, 26 | "status": r.Status, 27 | "statusText": r.StatusText, 28 | "url": r.URL, 29 | } 30 | 31 | return maps 32 | } 33 | -------------------------------------------------------------------------------- /browser/sync_touchscreen_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | ) 9 | 10 | // syncMapTouchscreen is like mapTouchscreen but returns synchronous functions. 11 | func syncMapTouchscreen(vu moduleVU, ts *common.Touchscreen) mapping { 12 | return mapping{ 13 | "tap": func(x float64, y float64) *sobek.Promise { 14 | return k6ext.Promise(vu.Context(), func() (result any, reason error) { 15 | return nil, ts.Tap(x, y) //nolint:wrapcheck 16 | }) 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /browser/sync_worker_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/common" 5 | ) 6 | 7 | // syncMapWorker is like mapWorker but returns synchronous functions. 8 | func syncMapWorker(_ moduleVU, w *common.Worker) mapping { 9 | return mapping{ 10 | "url": w.URL(), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /browser/touchscreen_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/sobek" 5 | 6 | "github.com/grafana/xk6-browser/common" 7 | "github.com/grafana/xk6-browser/k6ext" 8 | ) 9 | 10 | // mapTouchscreen to the JS module. 11 | func mapTouchscreen(vu moduleVU, ts *common.Touchscreen) mapping { 12 | return mapping{ 13 | "tap": func(x float64, y float64) *sobek.Promise { 14 | return k6ext.Promise(vu.Context(), func() (result any, reason error) { 15 | return nil, ts.Tap(x, y) //nolint:wrapcheck 16 | }) 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /browser/worker_mapping.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/common" 5 | ) 6 | 7 | // mapWorker to the JS module. 8 | func mapWorker(_ moduleVU, w *common.Worker) mapping { 9 | return mapping{ 10 | "url": w.URL(), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /chromium/browser.go: -------------------------------------------------------------------------------- 1 | // Package chromium is responsible for launching a Chrome browser process and managing its lifetime. 2 | package chromium 3 | 4 | import ( 5 | "github.com/grafana/xk6-browser/common" 6 | ) 7 | 8 | // Browser is the public interface of a CDP browser. 9 | type Browser struct { 10 | common.Browser 11 | 12 | // TODO: 13 | // - add support for service workers 14 | // - add support for background pages 15 | } 16 | -------------------------------------------------------------------------------- /common/barrier.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | ) 8 | 9 | type Barrier struct { 10 | count int64 11 | ch chan bool 12 | errCh chan error 13 | } 14 | 15 | func NewBarrier() *Barrier { 16 | return &Barrier{ 17 | count: 1, 18 | ch: make(chan bool, 1), 19 | errCh: make(chan error, 1), 20 | } 21 | } 22 | 23 | func (b *Barrier) AddFrameNavigation(frame *Frame) { 24 | if frame.parentFrame != nil { 25 | return // We only care about top-frame navigation 26 | } 27 | ch, evCancelFn := createWaitForEventHandler(frame.ctx, frame, []string{EventFrameNavigation}, func(data any) bool { 28 | return true 29 | }) 30 | go func() { 31 | defer evCancelFn() // Remove event handler 32 | atomic.AddInt64(&b.count, 1) 33 | select { 34 | case <-frame.ctx.Done(): 35 | case <-time.After(frame.manager.timeoutSettings.navigationTimeout()): 36 | b.errCh <- ErrTimedOut 37 | case <-ch: 38 | b.ch <- true 39 | } 40 | atomic.AddInt64(&b.count, -1) 41 | }() 42 | } 43 | 44 | func (b *Barrier) Wait(ctx context.Context) error { 45 | if atomic.AddInt64(&b.count, -1) == 0 { 46 | return nil 47 | } 48 | 49 | select { 50 | case <-ctx.Done(): 51 | case <-b.ch: 52 | return nil 53 | case err := <-b.errCh: 54 | return err 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /common/barrier_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/chromedp/cdproto/cdp" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/grafana/xk6-browser/log" 11 | ) 12 | 13 | func TestBarrier(t *testing.T) { 14 | t.Parallel() 15 | 16 | ctx := context.Background() 17 | 18 | log := log.NewNullLogger() 19 | 20 | timeoutSettings := NewTimeoutSettings(nil) 21 | frameManager := NewFrameManager(ctx, nil, nil, timeoutSettings, log) 22 | frame := NewFrame(ctx, frameManager, nil, cdp.FrameID("frame_id_0123456789"), log) 23 | 24 | barrier := NewBarrier() 25 | barrier.AddFrameNavigation(frame) 26 | frame.emit(EventFrameNavigation, "some data") 27 | 28 | err := barrier.Wait(ctx) 29 | require.Nil(t, err) 30 | } 31 | -------------------------------------------------------------------------------- /common/browser_context_options.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "fmt" 4 | 5 | // BrowserContextOptions stores browser context options. 6 | type BrowserContextOptions struct { 7 | AcceptDownloads bool `js:"acceptDownloads"` 8 | DownloadsPath string `js:"downloadsPath"` 9 | BypassCSP bool `js:"bypassCSP"` 10 | ColorScheme ColorScheme `js:"colorScheme"` 11 | DeviceScaleFactor float64 `js:"deviceScaleFactor"` 12 | ExtraHTTPHeaders map[string]string `js:"extraHTTPHeaders"` 13 | Geolocation *Geolocation `js:"geolocation"` 14 | HasTouch bool `js:"hasTouch"` 15 | HTTPCredentials Credentials `js:"httpCredentials"` 16 | IgnoreHTTPSErrors bool `js:"ignoreHTTPSErrors"` 17 | IsMobile bool `js:"isMobile"` 18 | JavaScriptEnabled bool `js:"javaScriptEnabled"` 19 | Locale string `js:"locale"` 20 | Offline bool `js:"offline"` 21 | Permissions []string `js:"permissions"` 22 | ReducedMotion ReducedMotion `js:"reducedMotion"` 23 | Screen Screen `js:"screen"` 24 | TimezoneID string `js:"timezoneID"` 25 | UserAgent string `js:"userAgent"` 26 | VideosPath string `js:"videosPath"` 27 | Viewport Viewport `js:"viewport"` 28 | } 29 | 30 | // DefaultBrowserContextOptions returns the default browser context options. 31 | func DefaultBrowserContextOptions() *BrowserContextOptions { 32 | return &BrowserContextOptions{ 33 | ColorScheme: ColorSchemeLight, 34 | DeviceScaleFactor: 1.0, 35 | ExtraHTTPHeaders: make(map[string]string), 36 | JavaScriptEnabled: true, 37 | Locale: DefaultLocale, 38 | Permissions: []string{}, 39 | ReducedMotion: ReducedMotionNoPreference, 40 | Screen: Screen{Width: DefaultScreenWidth, Height: DefaultScreenHeight}, 41 | Viewport: Viewport{Width: DefaultScreenWidth, Height: DefaultScreenHeight}, 42 | } 43 | } 44 | 45 | // Geolocation represents a geolocation. 46 | type Geolocation struct { 47 | Latitude float64 `js:"latitude"` 48 | Longitude float64 `js:"longitude"` 49 | Accuracy float64 `js:"accuracy"` 50 | } 51 | 52 | // Validate validates the [Geolocation]. 53 | func (g *Geolocation) Validate() error { 54 | if g.Longitude < -180 || g.Longitude > 180 { 55 | return fmt.Errorf(`invalid longitude "%.2f": precondition -180 <= LONGITUDE <= 180 failed`, g.Longitude) 56 | } 57 | if g.Latitude < -90 || g.Latitude > 90 { 58 | return fmt.Errorf(`invalid latitude "%.2f": precondition -90 <= LATITUDE <= 90 failed`, g.Latitude) 59 | } 60 | if g.Accuracy < 0 { 61 | return fmt.Errorf(`invalid accuracy "%.2f": precondition 0 <= ACCURACY failed`, g.Accuracy) 62 | } 63 | return nil 64 | } 65 | 66 | // GrantPermissionsOptions is used by BrowserContext.GrantPermissions. 67 | type GrantPermissionsOptions struct { 68 | Origin string 69 | } 70 | -------------------------------------------------------------------------------- /common/browser_process_meta.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/grafana/xk6-browser/storage" 7 | ) 8 | 9 | const ( 10 | unknownProcessPid = -1 11 | ) 12 | 13 | // browserProcessMeta handles the metadata associated with 14 | // a browser process, especifically, the OS process handle 15 | // and the associated browser data directory. 16 | type browserProcessMeta interface { 17 | Pid() int 18 | Cleanup() error 19 | } 20 | 21 | // localBrowserProcessMeta holds the metadata for local 22 | // browser process. 23 | type localBrowserProcessMeta struct { 24 | process *os.Process //nolint:forbidigo 25 | userDataDir *storage.Dir 26 | } 27 | 28 | // newLocalBrowserProcessMeta returns a new BrowserProcessMeta 29 | // for the given OS process and storage directory. 30 | func newLocalBrowserProcessMeta( 31 | process *os.Process, userDataDir *storage.Dir, //nolint:forbidigo 32 | ) *localBrowserProcessMeta { 33 | return &localBrowserProcessMeta{ 34 | process, 35 | userDataDir, 36 | } 37 | } 38 | 39 | // Pid returns the Pid for the local browser process. 40 | func (l *localBrowserProcessMeta) Pid() int { 41 | return l.process.Pid 42 | } 43 | 44 | // Cleanup cleans the local user data directory associated 45 | // with the local browser process. 46 | func (l *localBrowserProcessMeta) Cleanup() error { 47 | return l.userDataDir.Cleanup() //nolint:wrapcheck 48 | } 49 | 50 | // remoteBrowserProcessMeta is a placeholder for a 51 | // remote browser process metadata. 52 | type remoteBrowserProcessMeta struct{} 53 | 54 | // newRemoteBrowserProcessMeta returns a new BrowserProcessMeta 55 | // which acts as a placeholder for a remote browser process data. 56 | func newRemoteBrowserProcessMeta() *remoteBrowserProcessMeta { 57 | return &remoteBrowserProcessMeta{} 58 | } 59 | 60 | // Pid returns -1 as the remote browser process is unknown. 61 | func (r *remoteBrowserProcessMeta) Pid() int { 62 | return unknownProcessPid 63 | } 64 | 65 | // Cleanup does nothing and returns nil, as there is no 66 | // access to the remote browser's user data directory. 67 | func (r *remoteBrowserProcessMeta) Cleanup() error { 68 | // Nothing to do. 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /common/consts.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | const ( 6 | // Defaults 7 | 8 | DefaultLocale string = "en-US" 9 | DefaultScreenWidth int64 = 1280 10 | DefaultScreenHeight int64 = 720 11 | DefaultTimeout time.Duration = 30 * time.Second 12 | 13 | // Life-cycle consts 14 | 15 | LifeCycleNetworkIdleTimeout time.Duration = 500 * time.Millisecond 16 | 17 | // API default consts. 18 | 19 | StrictModeOff = false 20 | ) 21 | -------------------------------------------------------------------------------- /common/context.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ctxKey int 8 | 9 | const ( 10 | ctxKeyBrowserOptions ctxKey = iota 11 | ctxKeyHooks 12 | ctxKeyIterationID 13 | ctxKeyTracer 14 | ) 15 | 16 | func WithHooks(ctx context.Context, hooks *Hooks) context.Context { 17 | return context.WithValue(ctx, ctxKeyHooks, hooks) 18 | } 19 | 20 | func GetHooks(ctx context.Context) *Hooks { 21 | v := ctx.Value(ctxKeyHooks) 22 | if v == nil { 23 | return nil 24 | } 25 | return v.(*Hooks) //nolint:forcetypeassert 26 | } 27 | 28 | // WithIterationID adds an identifier for the current iteration to the context. 29 | func WithIterationID(ctx context.Context, iterID string) context.Context { 30 | return context.WithValue(ctx, ctxKeyIterationID, iterID) 31 | } 32 | 33 | // GetIterationID returns the iteration identifier attached to the context. 34 | func GetIterationID(ctx context.Context) string { 35 | s, _ := ctx.Value(ctxKeyIterationID).(string) 36 | return s 37 | } 38 | 39 | // WithBrowserOptions adds the browser options to the context. 40 | func WithBrowserOptions(ctx context.Context, opts *BrowserOptions) context.Context { 41 | return context.WithValue(ctx, ctxKeyBrowserOptions, opts) 42 | } 43 | 44 | // GetBrowserOptions returns the browser options attached to the context. 45 | func GetBrowserOptions(ctx context.Context) *BrowserOptions { 46 | v := ctx.Value(ctxKeyBrowserOptions) 47 | if v == nil { 48 | return nil 49 | } 50 | if bo, ok := v.(*BrowserOptions); ok { 51 | return bo 52 | } 53 | return nil 54 | } 55 | 56 | // WithTracer adds the given tracer to the context. 57 | func WithTracer(ctx context.Context, tracer Tracer) context.Context { 58 | return context.WithValue(ctx, ctxKeyTracer, tracer) 59 | } 60 | 61 | // GetTracer returns the tracer attached to the context, or nil if not found. 62 | func GetTracer(ctx context.Context) Tracer { 63 | v := ctx.Value(ctxKeyTracer) 64 | if v == nil { 65 | return nil 66 | } 67 | if tracer, ok := v.(Tracer); ok { 68 | return tracer 69 | } 70 | return nil 71 | } 72 | 73 | // contextWithDoneChan returns a new context that is canceled either 74 | // when the done channel is closed or ctx is canceled. 75 | func contextWithDoneChan(ctx context.Context, done chan struct{}) context.Context { 76 | ctx, cancel := context.WithCancel(ctx) 77 | go func() { 78 | defer cancel() 79 | select { 80 | case <-done: 81 | case <-ctx.Done(): 82 | } 83 | }() 84 | return ctx 85 | } 86 | -------------------------------------------------------------------------------- /common/context_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestContextWithDoneChan(t *testing.T) { 12 | t.Parallel() 13 | 14 | done := make(chan struct{}) 15 | ctx := contextWithDoneChan(context.Background(), done) 16 | close(done) 17 | select { 18 | case <-ctx.Done(): 19 | case <-time.After(time.Millisecond * 100): 20 | require.FailNow(t, "should cancel the context after closing the done chan") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /common/doc.go: -------------------------------------------------------------------------------- 1 | // Package common provides the main logic of the browser module. 2 | // This package will be split into multiple packages in the future. 3 | package common 4 | -------------------------------------------------------------------------------- /common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chromedp/cdproto/runtime" 7 | ) 8 | 9 | // Error is a common package error. 10 | type Error string 11 | 12 | // Error satisfies the builtin error interface. 13 | func (e Error) Error() string { 14 | return string(e) 15 | } 16 | 17 | // Error types. 18 | const ( 19 | ErrUnexpectedRemoteObjectWithID Error = "cannot extract value when remote object ID is given" 20 | ErrChannelClosed Error = "channel closed" 21 | ErrFrameDetached Error = "frame detached" 22 | ErrJSHandleDisposed Error = "JS handle is disposed" 23 | ErrJSHandleInvalid Error = "JS handle is invalid" 24 | ErrTargetCrashed Error = "Target has crashed" 25 | ErrTimedOut Error = "timed out" 26 | ErrWrongExecutionContext Error = "JS handles can be evaluated only in the context they were created" 27 | ) 28 | 29 | type BigIntParseError struct { 30 | err error 31 | } 32 | 33 | // Error satisfies the builtin error interface. 34 | func (e BigIntParseError) Error() string { 35 | return fmt.Sprintf("parsing bigint: %v", e.err) 36 | } 37 | 38 | // Is satisfies the builtin error Is interface. 39 | func (e BigIntParseError) Is(target error) bool { 40 | _, ok := target.(BigIntParseError) 41 | return ok 42 | } 43 | 44 | // Unwrap satisfies the builtin error Unwrap interface. 45 | func (e BigIntParseError) Unwrap() error { 46 | return e.err 47 | } 48 | 49 | type UnserializableValueError struct { 50 | UnserializableValue runtime.UnserializableValue 51 | } 52 | 53 | // Error satisfies the builtin error interface. 54 | func (e UnserializableValueError) Error() string { 55 | return fmt.Sprintf("unsupported unserializable value: %s", e.UnserializableValue) 56 | } 57 | -------------------------------------------------------------------------------- /common/hooks.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | type HookID int 10 | 11 | const ( 12 | HookApplySlowMo HookID = iota 13 | ) 14 | 15 | type Hook func(context.Context) 16 | 17 | type Hooks struct { 18 | mu sync.RWMutex 19 | hooks map[HookID]Hook 20 | } 21 | 22 | func applySlowMo(ctx context.Context) { 23 | hooks := GetHooks(ctx) 24 | if hooks == nil { 25 | return 26 | } 27 | if hook := hooks.Get(HookApplySlowMo); hook != nil { 28 | hook(ctx) 29 | } 30 | } 31 | 32 | func defaultSlowMo(ctx context.Context) { 33 | sm := GetBrowserOptions(ctx).SlowMo 34 | if sm <= 0 { 35 | return 36 | } 37 | select { 38 | case <-ctx.Done(): 39 | case <-time.After(sm): 40 | } 41 | } 42 | 43 | func NewHooks() *Hooks { 44 | h := Hooks{ 45 | hooks: make(map[HookID]Hook), 46 | } 47 | h.registerDefaultHooks() 48 | return &h 49 | } 50 | 51 | func (h *Hooks) registerDefaultHooks() { 52 | h.Register(HookApplySlowMo, defaultSlowMo) 53 | } 54 | 55 | func (h *Hooks) Get(id HookID) Hook { 56 | h.mu.RLock() 57 | defer h.mu.RUnlock() 58 | return h.hooks[id] 59 | } 60 | 61 | func (h *Hooks) Register(id HookID, hook Hook) { 62 | h.mu.Lock() 63 | defer h.mu.Unlock() 64 | h.hooks[id] = hook 65 | } 66 | -------------------------------------------------------------------------------- /common/js/actions.go: -------------------------------------------------------------------------------- 1 | package js 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | // ScrollIntoView scrolls an element into view. 8 | // 9 | //go:embed scroll_into_view.js 10 | var ScrollIntoView string 11 | -------------------------------------------------------------------------------- /common/js/doc.go: -------------------------------------------------------------------------------- 1 | // Package js provides JavaScript code that the browser module evaluates on the browser. 2 | // The common package uses this package. 3 | // `injected_script.js` is injected into each execution context. 4 | package js 5 | -------------------------------------------------------------------------------- /common/js/embedded_scripts.go: -------------------------------------------------------------------------------- 1 | package js 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | // WebVitalIIFEScript was downloaded from 8 | // https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js. 9 | // Repo: https://github.com/GoogleChrome/web-vitals 10 | // 11 | //go:embed web_vital_iife.js 12 | var WebVitalIIFEScript string 13 | 14 | // WebVitalInitScript uses WebVitalIIFEScript 15 | // and applies it to the current website that 16 | // this init script is used against. 17 | // 18 | //go:embed web_vital_init.js 19 | var WebVitalInitScript string 20 | -------------------------------------------------------------------------------- /common/js/query_all.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Finds all elements in a given scope. 3 | * @param {Node} scope - The scope of searching. It can be a node. 4 | * By default, it is document. 5 | * @param {InjectedScript} injected - Injected script. 6 | * @param {string} selector - XPath or CSS selector string. 7 | * @returns {Set|string} - A set of nodes found or an error string. 8 | */ 9 | function QueryAll(scope = document, injected, selector) { 10 | return injected.querySelectorAll(selector, scope); 11 | } 12 | -------------------------------------------------------------------------------- /common/js/scroll_into_view.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scrolls an element into view. 3 | * @param {Node} node - The element to scroll into. 4 | * @param {ScrollIntoViewOptions} options. 5 | * @returns {void} 6 | */ 7 | function scrollIntoView(node, options) { 8 | // we can only scroll to element nodes 9 | if (node.nodeType !== Node.ELEMENT_NODE) { 10 | return; 11 | } 12 | node.scrollIntoView(options); 13 | } -------------------------------------------------------------------------------- /common/js/selectors.go: -------------------------------------------------------------------------------- 1 | package js 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | // QueryAll queries all the elements in a given scope (document by default). 8 | // 9 | //go:embed query_all.js 10 | var QueryAll string 11 | -------------------------------------------------------------------------------- /common/js/web_vital_init.js: -------------------------------------------------------------------------------- 1 | function print(metric) { 2 | const m = { 3 | id: metric.id, 4 | name: metric.name, 5 | value: metric.value, 6 | rating: metric.rating, 7 | delta: metric.delta, 8 | numEntries: metric.entries.length, 9 | navigationType: metric.navigationType, 10 | url: window.location.href, 11 | // To be able to associate a Web Vital measurement to the PageNavigation 12 | // span, we need to collect the span ID that was previously set in the 13 | // page after the navigation, and pass it back to k6 browser included in 14 | // the WV event so the measurement can be correctly linked to the page 15 | // navigation span 16 | spanID: window.k6SpanId, 17 | } 18 | window.k6browserSendWebVitalMetric(JSON.stringify(m)) 19 | } 20 | 21 | function load() { 22 | webVitals.onCLS(print); 23 | webVitals.onFID(print); 24 | webVitals.onLCP(print); 25 | 26 | webVitals.onFCP(print); 27 | webVitals.onINP(print); 28 | webVitals.onTTFB(print); 29 | } 30 | 31 | load(); 32 | -------------------------------------------------------------------------------- /common/kill_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package common 5 | 6 | import ( 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | // killAfterParent kills the child process when the parent process dies. 12 | func killAfterParent(cmd *exec.Cmd) { 13 | if cmd.SysProcAttr == nil { 14 | cmd.SysProcAttr = new(syscall.SysProcAttr) 15 | } 16 | cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL 17 | } 18 | -------------------------------------------------------------------------------- /common/kill_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package common 5 | 6 | import "os/exec" 7 | 8 | // killAfterParent kills the child process when the parent process dies. 9 | func killAfterParent(cmd *exec.Cmd) { 10 | } 11 | -------------------------------------------------------------------------------- /common/layout_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // See: Issue #183 for details. 10 | func TestViewportCalculateInset(t *testing.T) { 11 | t.Parallel() 12 | 13 | t.Run("headless", func(t *testing.T) { 14 | t.Parallel() 15 | 16 | headless, vp := true, Viewport{} 17 | vp = vp.recalculateInset(headless, "any os") 18 | assert.Equal(t, vp, Viewport{}, 19 | "should not change the viewport if headless is true") 20 | }) 21 | 22 | t.Run("headful", func(t *testing.T) { 23 | t.Parallel() 24 | 25 | var ( 26 | headless bool 27 | vp Viewport 28 | ) 29 | vp = vp.recalculateInset(headless, "any os") 30 | assert.NotEqual(t, vp, Viewport{}, 31 | "should add the default inset to the viewport if the"+ 32 | " operating system is unrecognized by the addInset.") 33 | }) 34 | 35 | // should add a different inset to viewport than the default one 36 | // if a recognized os is given. 37 | for _, os := range []string{"windows", "linux", "darwin"} { 38 | os := os 39 | t.Run(os, func(t *testing.T) { 40 | t.Parallel() 41 | 42 | var ( 43 | headless bool 44 | vp Viewport 45 | ) 46 | // add the default inset to the viewport 47 | vp = vp.recalculateInset(headless, "any os") 48 | defaultVp := vp 49 | // add an os specific inset to the viewport 50 | vp = vp.recalculateInset(headless, os) 51 | 52 | assert.NotEqual(t, vp, defaultVp, "inset for %q should exist", os) 53 | // we multiply the default viewport by two to detect 54 | // whether an os specific inset is adding the default 55 | // viewport, instead of its own. 56 | assert.NotEqual(t, vp, Viewport{ 57 | Width: defaultVp.Width * 2, 58 | Height: defaultVp.Height * 2, 59 | }, "inset for %q should not be the same as the default one", os) 60 | }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /common/lifecycle_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestLifecycleEventMarshalText(t *testing.T) { 11 | t.Parallel() 12 | 13 | t.Run("ok/nil", func(t *testing.T) { 14 | t.Parallel() 15 | 16 | var evt *LifecycleEvent 17 | m, err := evt.MarshalText() 18 | require.NoError(t, err) 19 | assert.Equal(t, []byte(""), m) 20 | }) 21 | 22 | t.Run("err/invalid", func(t *testing.T) { 23 | t.Parallel() 24 | 25 | evt := LifecycleEvent(-1) 26 | _, err := evt.MarshalText() 27 | require.EqualError(t, err, "invalid lifecycle event: -1") 28 | }) 29 | } 30 | 31 | func TestLifecycleEventMarshalTextRound(t *testing.T) { 32 | t.Parallel() 33 | 34 | evt := LifecycleEventLoad 35 | m, err := evt.MarshalText() 36 | require.NoError(t, err) 37 | assert.Equal(t, []byte("load"), m) 38 | 39 | var evt2 LifecycleEvent 40 | err = evt2.UnmarshalText(m) 41 | require.NoError(t, err) 42 | assert.Equal(t, evt, evt2) 43 | } 44 | 45 | func TestLifecycleEventUnmarshalText(t *testing.T) { 46 | t.Parallel() 47 | 48 | t.Run("ok", func(t *testing.T) { 49 | t.Parallel() 50 | 51 | var evt LifecycleEvent 52 | err := evt.UnmarshalText([]byte("load")) 53 | require.NoError(t, err) 54 | assert.Equal(t, LifecycleEventLoad, evt) 55 | }) 56 | 57 | t.Run("err/invalid", func(t *testing.T) { 58 | t.Parallel() 59 | 60 | var evt LifecycleEvent 61 | err := evt.UnmarshalText([]byte("none")) 62 | require.EqualError(t, err, 63 | `invalid lifecycle event: "none"; `+ 64 | `must be one of: load, domcontentloaded, networkidle`) 65 | }) 66 | 67 | t.Run("err/invalid_empty", func(t *testing.T) { 68 | t.Parallel() 69 | 70 | var evt LifecycleEvent 71 | err := evt.UnmarshalText([]byte("")) 72 | require.EqualError(t, err, 73 | `invalid lifecycle event: ""; `+ 74 | `must be one of: load, domcontentloaded, networkidle`) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /common/network_profile.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // NetworkProfile is used in ThrottleNetwork. 4 | type NetworkProfile struct { 5 | // Minimum latency from request sent to response headers received (ms). 6 | Latency float64 7 | 8 | // Maximal aggregated download throughput (bytes/sec). -1 disables download throttling. 9 | Download float64 10 | 11 | // Maximal aggregated upload throughput (bytes/sec). -1 disables upload throttling. 12 | Upload float64 13 | } 14 | 15 | // NewNetworkProfile creates a non-throttled network profile. 16 | func NewNetworkProfile() NetworkProfile { 17 | return NetworkProfile{ 18 | Latency: 0, 19 | Download: -1, 20 | Upload: -1, 21 | } 22 | } 23 | 24 | // GetNetworkProfiles returns NetworkProfiles which are ready to be used to 25 | // throttle the network with page.throttleNetwork. 26 | func GetNetworkProfiles() map[string]NetworkProfile { 27 | return map[string]NetworkProfile{ 28 | "No Throttling": { 29 | Download: -1, 30 | Upload: -1, 31 | Latency: 0, 32 | }, 33 | "Slow 3G": { 34 | // (500 (Kb/s) * 1000 (to bits/s)) / 8 (to bytes/s)) * 0.8 (20% bandwidth loss) 35 | Download: ((500 * 1000) / 8) * 0.8, 36 | // (500 (Kb/s) * 1000 (to bits/s)) / 8 (to bytes/s)) * 0.8 (20% bandwidth loss) 37 | Upload: ((500 * 1000) / 8) * 0.8, 38 | Latency: 400 * 5, 39 | }, 40 | "Fast 3G": { 41 | // ((1.6 (Mb/s) * 1000 (to Kb/s) * 1000 (to bits/s)) / 8 (to bytes/s)) * 0.9 (10% bandwidth loss) 42 | Download: ((1.6 * 1000 * 1000) / 8) * 0.9, 43 | // (750 (Kb/s) * 1000 (to bits/s)) / 8 (to bytes/s)) * 0.9 (10% bandwidth loss) 44 | Upload: ((750 * 1000) / 8) * 0.9, 45 | Latency: 150 * 3.75, 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /common/page_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestPageLocator can be removed later on when we add integration 11 | // tests. Since we don't yet have any of them, it makes sense to keep 12 | // this test. 13 | func TestPageLocator(t *testing.T) { 14 | t.Parallel() 15 | 16 | const ( 17 | wantMainFrameID = "1" 18 | wantSelector = "span" 19 | ) 20 | ctx := context.TODO() 21 | p := &Page{ 22 | ctx: ctx, 23 | frameManager: &FrameManager{ 24 | ctx: ctx, 25 | mainFrame: &Frame{id: wantMainFrameID, ctx: ctx}, 26 | }, 27 | } 28 | l := p.Locator(wantSelector, nil) 29 | assert.Equal(t, wantSelector, l.selector) 30 | assert.Equal(t, wantMainFrameID, string(l.frame.id)) 31 | 32 | // other behavior will be tested via integration tests 33 | } 34 | -------------------------------------------------------------------------------- /common/timeout.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | // TimeoutSettings holds information on timeout settings. 6 | type TimeoutSettings struct { 7 | parent *TimeoutSettings 8 | defaultTimeout *time.Duration 9 | defaultNavigationTimeout *time.Duration 10 | } 11 | 12 | // NewTimeoutSettings creates a new timeout settings object. 13 | func NewTimeoutSettings(parent *TimeoutSettings) *TimeoutSettings { 14 | t := &TimeoutSettings{ 15 | parent: parent, 16 | defaultTimeout: nil, 17 | defaultNavigationTimeout: nil, 18 | } 19 | return t 20 | } 21 | 22 | func (t *TimeoutSettings) setDefaultTimeout(timeout time.Duration) { 23 | t.defaultTimeout = &timeout 24 | } 25 | 26 | func (t *TimeoutSettings) setDefaultNavigationTimeout(timeout time.Duration) { 27 | t.defaultNavigationTimeout = &timeout 28 | } 29 | 30 | func (t *TimeoutSettings) navigationTimeout() time.Duration { 31 | if t.defaultNavigationTimeout != nil { 32 | return *t.defaultNavigationTimeout 33 | } 34 | if t.defaultTimeout != nil { 35 | return *t.defaultTimeout 36 | } 37 | if t.parent != nil { 38 | return t.parent.navigationTimeout() 39 | } 40 | return DefaultTimeout 41 | } 42 | 43 | func (t *TimeoutSettings) timeout() time.Duration { 44 | if t.defaultTimeout != nil { 45 | return *t.defaultTimeout 46 | } 47 | if t.parent != nil { 48 | return t.parent.timeout() 49 | } 50 | return DefaultTimeout 51 | } 52 | -------------------------------------------------------------------------------- /common/touchscreen.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chromedp/cdproto/cdp" 8 | "github.com/chromedp/cdproto/input" 9 | ) 10 | 11 | // Touchscreen represents a touchscreen. 12 | type Touchscreen struct { 13 | BaseEventEmitter 14 | 15 | ctx context.Context 16 | session session 17 | keyboard *Keyboard 18 | } 19 | 20 | // NewTouchscreen returns a new TouchScreen. 21 | func NewTouchscreen(ctx context.Context, s session, k *Keyboard) *Touchscreen { 22 | return &Touchscreen{ 23 | ctx: ctx, 24 | session: s, 25 | keyboard: k, 26 | } 27 | } 28 | 29 | // Tap dispatches a tap start and tap end event. 30 | func (t *Touchscreen) Tap(x float64, y float64) error { 31 | if err := t.tap(x, y); err != nil { 32 | return fmt.Errorf("tapping: %w", err) 33 | } 34 | return nil 35 | } 36 | 37 | func (t *Touchscreen) tap(x float64, y float64) error { 38 | touchStart := input.DispatchTouchEvent( 39 | input.TouchStart, 40 | []*input.TouchPoint{{X: x, Y: y}}, 41 | ).WithModifiers( 42 | input.Modifier(t.keyboard.modifiers), 43 | ) 44 | if err := touchStart.Do(cdp.WithExecutor(t.ctx, t.session)); err != nil { 45 | return fmt.Errorf("touch start: %w", err) 46 | } 47 | 48 | touchEnd := input.DispatchTouchEvent( 49 | input.TouchEnd, 50 | []*input.TouchPoint{}, 51 | ).WithModifiers( 52 | input.Modifier(t.keyboard.modifiers), 53 | ) 54 | if err := touchEnd.Do(cdp.WithExecutor(t.ctx, t.session)); err != nil { 55 | return fmt.Errorf("touch end: %w", err) 56 | } 57 | 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /common/trace.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | 6 | "go.opentelemetry.io/otel/codes" 7 | "go.opentelemetry.io/otel/trace" 8 | 9 | browsertrace "github.com/grafana/xk6-browser/trace" 10 | ) 11 | 12 | // Tracer defines the interface with the tracing methods used in the common package. 13 | type Tracer interface { 14 | TraceAPICall( 15 | ctx context.Context, targetID string, spanName string, opts ...trace.SpanStartOption, 16 | ) (context.Context, trace.Span) 17 | TraceNavigation( 18 | ctx context.Context, targetID string, opts ...trace.SpanStartOption, 19 | ) (context.Context, trace.Span) 20 | TraceEvent( 21 | ctx context.Context, targetID string, eventName string, spanID string, opts ...trace.SpanStartOption, 22 | ) (context.Context, trace.Span) 23 | } 24 | 25 | // TraceAPICall is a helper method that retrieves the Tracer from the given ctx and 26 | // calls its TraceAPICall implementation. If the Tracer is not present in the given 27 | // ctx, it returns a noopSpan and the given context. 28 | func TraceAPICall( 29 | ctx context.Context, targetID string, spanName string, opts ...trace.SpanStartOption, 30 | ) (context.Context, trace.Span) { 31 | if tracer := GetTracer(ctx); tracer != nil { 32 | return tracer.TraceAPICall(ctx, targetID, spanName, opts...) 33 | } 34 | return ctx, browsertrace.NoopSpan{} 35 | } 36 | 37 | // TraceNavigation is a helper method that retrieves the Tracer from the given ctx and 38 | // calls its TraceNavigation implementation. If the Tracer is not present in the given 39 | // ctx, it returns a noopSpan and the given context. 40 | func TraceNavigation( 41 | ctx context.Context, targetID string, opts ...trace.SpanStartOption, 42 | ) (context.Context, trace.Span) { 43 | if tracer := GetTracer(ctx); tracer != nil { 44 | return tracer.TraceNavigation(ctx, targetID, opts...) 45 | } 46 | return ctx, browsertrace.NoopSpan{} 47 | } 48 | 49 | // TraceEvent is a helper method that retrieves the Tracer from the given ctx and 50 | // calls its TraceEvent implementation. If the Tracer is not present in the given 51 | // ctx, it returns a noopSpan and the given context. 52 | func TraceEvent( 53 | ctx context.Context, targetID string, eventName string, spanID string, options ...trace.SpanStartOption, 54 | ) (context.Context, trace.Span) { 55 | if tracer := GetTracer(ctx); tracer != nil { 56 | return tracer.TraceEvent(ctx, targetID, eventName, spanID, options...) 57 | } 58 | return ctx, browsertrace.NoopSpan{} 59 | } 60 | 61 | // spanRecordError will set the status of the span to error and record the 62 | // error on the span. Check the documentation for trace.SetStatus and 63 | // trace.RecordError for more details. 64 | func spanRecordError(span trace.Span, err error) { 65 | span.SetStatus(codes.Error, err.Error()) 66 | span.RecordError(err) 67 | } 68 | -------------------------------------------------------------------------------- /common/worker.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chromedp/cdproto/cdp" 8 | "github.com/chromedp/cdproto/log" 9 | "github.com/chromedp/cdproto/network" 10 | "github.com/chromedp/cdproto/runtime" 11 | "github.com/chromedp/cdproto/target" 12 | ) 13 | 14 | type Worker struct { 15 | BaseEventEmitter 16 | 17 | ctx context.Context 18 | session session 19 | 20 | targetID target.ID 21 | url string 22 | } 23 | 24 | // NewWorker creates a new page viewport. 25 | func NewWorker(ctx context.Context, s session, id target.ID, url string) (*Worker, error) { 26 | w := Worker{ 27 | BaseEventEmitter: NewBaseEventEmitter(ctx), 28 | ctx: ctx, 29 | session: s, 30 | targetID: id, 31 | url: url, 32 | } 33 | if err := w.initEvents(); err != nil { 34 | return nil, err 35 | } 36 | 37 | return &w, nil 38 | } 39 | 40 | func (w *Worker) didClose() { 41 | w.emit(EventWorkerClose, w) 42 | } 43 | 44 | func (w *Worker) initEvents() error { 45 | actions := []Action{ 46 | log.Enable(), 47 | network.Enable(), 48 | runtime.RunIfWaitingForDebugger(), 49 | } 50 | for _, action := range actions { 51 | if err := action.Do(cdp.WithExecutor(w.ctx, w.session)); err != nil { 52 | return fmt.Errorf("protocol error while initializing worker %T: %w", action, err) 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | // URL returns the URL of the web worker. 59 | func (w *Worker) URL() string { 60 | return w.url 61 | } 62 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | xk6-browser: 5 | build: . 6 | -------------------------------------------------------------------------------- /examples/colorscheme.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext({ 22 | // valid values are "light", "dark" or "no-preference" 23 | colorScheme: 'dark', 24 | }); 25 | const page = await context.newPage(); 26 | 27 | try { 28 | await page.goto( 29 | 'https://test.k6.io', 30 | { waitUntil: 'load' }, 31 | ) 32 | await check(page, { 33 | 'isDarkColorScheme': 34 | p => p.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches) 35 | }); 36 | } finally { 37 | await page.close(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/device_emulation.js: -------------------------------------------------------------------------------- 1 | import { browser, devices } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const device = devices['iPhone X']; 22 | // The spread operator is currently unsupported by k6's Babel, so use 23 | // Object.assign instead to merge browser context and device options. 24 | // See https://github.com/grafana/k6/issues/2296 25 | const options = Object.assign({ locale: 'es-ES' }, device); 26 | const context = await browser.newContext(options); 27 | const page = await context.newPage(); 28 | 29 | try { 30 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 31 | const dimensions = await page.evaluate(() => { 32 | return { 33 | width: document.documentElement.clientWidth, 34 | height: document.documentElement.clientHeight, 35 | deviceScaleFactor: window.devicePixelRatio 36 | }; 37 | }); 38 | 39 | await check(dimensions, { 40 | 'width': d => d.width === device.viewport.width, 41 | 'height': d => d.height === device.viewport.height, 42 | 'scale': d => d.deviceScaleFactor === device.deviceScaleFactor, 43 | }); 44 | 45 | if (!__ENV.K6_BROWSER_HEADLESS) { 46 | await page.waitForTimeout(10000); 47 | } 48 | } finally { 49 | await page.close(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/dispatch.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 26 | 27 | const contacts = page.locator('a[href="/contacts.php"]'); 28 | await contacts.dispatchEvent("click"); 29 | 30 | await check(page.locator('h3'), { 31 | 'header': async lo => { 32 | const text = await lo.textContent(); 33 | return text == 'Contact us'; 34 | } 35 | }); 36 | } finally { 37 | await page.close(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/elementstate.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | // Inject page content 25 | await page.setContent(` 26 |
Hello world
27 | 28 |
Edit me
29 | 30 | 31 | 32 | 33 | `); 34 | 35 | // Check state 36 | await check(page, { 37 | 'is visible': async p => { 38 | const e = await p.$('.visible'); 39 | return await e.isVisible(); 40 | }, 41 | 'is hidden': async p => { 42 | const e = await p.$('.hidden'); 43 | return await e.isHidden() 44 | }, 45 | 'is editable': async p => { 46 | const e = await p.$('.editable'); 47 | return await e.isEditable(); 48 | }, 49 | 'is enabled': async p => { 50 | const e = await p.$('.enabled'); 51 | return await e.isEnabled(); 52 | }, 53 | 'is disabled': async p => { 54 | const e = await p.$('.disabled'); 55 | return await e.isDisabled(); 56 | }, 57 | 'is checked': async p => { 58 | const e = await p.$('.checked'); 59 | return await e.isChecked(); 60 | }, 61 | 'is unchecked': async p => { 62 | const e = await p.$('.unchecked'); 63 | return !await e.isChecked(); 64 | } 65 | }); 66 | 67 | // Change state and check again 68 | await check(page, { 69 | 'is unchecked checked': async p => { 70 | const e = await p.$(".unchecked"); 71 | await e.setChecked(true); 72 | return e.isChecked(); 73 | }, 74 | 'is checked unchecked': async p => { 75 | const e = await p.$(".checked"); 76 | await e.setChecked(false); 77 | return !await e.isChecked(); 78 | } 79 | }); 80 | 81 | await page.close(); 82 | } 83 | -------------------------------------------------------------------------------- /examples/evaluate.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | await page.goto("https://test.k6.io/", { waitUntil: "load" }); 26 | 27 | // calling evaluate without arguments 28 | await check(page, { 29 | 'result should be 210': async p => { 30 | const n = await p.evaluate(() => 5 * 42); 31 | return n == 210; 32 | } 33 | }); 34 | 35 | // calling evaluate with arguments 36 | await check(page, { 37 | 'result should be 25': async p => { 38 | const n = await p.evaluate((x, y) => x * y, 5, 5); 39 | return n == 25; 40 | } 41 | }); 42 | } finally { 43 | await page.close(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/fillform.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | // Goto front page, find login link and click it 26 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 27 | await Promise.all([ 28 | page.waitForNavigation(), 29 | page.locator('a[href="/my_messages.php"]').click(), 30 | ]); 31 | 32 | // Enter login credentials and login 33 | await page.locator('input[name="login"]').type('admin'); 34 | await page.locator('input[name="password"]').type("123"); 35 | 36 | // We expect the form submission to trigger a navigation, so to prevent a 37 | // race condition, setup a waiter concurrently while waiting for the click 38 | // to resolve. 39 | await Promise.all([ 40 | page.waitForNavigation(), 41 | page.locator('input[type="submit"]').click(), 42 | ]); 43 | 44 | await check(page.locator('h2'), { 45 | 'header': async lo => { 46 | return await lo.textContent() == 'Welcome, admin!' 47 | } 48 | }); 49 | 50 | // Check whether we receive cookies from the logged site. 51 | await check(context, { 52 | 'session cookie is set': async ctx => { 53 | const cookies = await ctx.cookies(); 54 | return cookies.find(c => c.name == 'sid') !== undefined; 55 | } 56 | }); 57 | } finally { 58 | await page.close(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/getattribute.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | await page.goto('https://googlechromelabs.github.io/dark-mode-toggle/demo/', { 26 | waitUntil: 'load', 27 | }); 28 | await check(page, { 29 | "GetAttribute('mode')": async p => { 30 | const e = await p.$('#dark-mode-toggle-3'); 31 | return await e.getAttribute('mode') === 'light'; 32 | } 33 | }); 34 | } finally { 35 | await page.close(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/grant_permission.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | thresholds: { 15 | checks: ["rate==1.0"] 16 | } 17 | } 18 | 19 | export default async function() { 20 | // grant camera and microphone permissions to the 21 | // new browser context. 22 | const context = await browser.newContext({ 23 | permissions: ["camera", "microphone"], 24 | }); 25 | 26 | const page = await context.newPage(); 27 | 28 | try { 29 | await page.goto('https://test.k6.io/'); 30 | await page.screenshot({ path: `example-chromium.png` }); 31 | await context.clearPermissions(); 32 | } finally { 33 | await page.close(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/hosts.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | hosts: { 'test.k6.io': '127.0.0.254' }, 16 | thresholds: { 17 | checks: ["rate==1.0"] 18 | } 19 | }; 20 | 21 | export default async function() { 22 | const context = await browser.newContext(); 23 | const page = await context.newPage(); 24 | 25 | try { 26 | const res = await page.goto('http://test.k6.io/', { 27 | waitUntil: 'load' 28 | }); 29 | await check(res, { 30 | 'null response': r => r === null, 31 | }); 32 | } finally { 33 | await page.close(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/keyboard.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | } 14 | } 15 | 16 | export default async function () { 17 | const page = await browser.newPage(); 18 | 19 | await page.goto('https://test.k6.io/my_messages.php', { waitUntil: 'networkidle' }); 20 | 21 | const userInput = page.locator('input[name="login"]'); 22 | await userInput.click(); 23 | await page.keyboard.type("admin"); 24 | 25 | const pwdInput = page.locator('input[name="password"]'); 26 | await pwdInput.click(); 27 | await page.keyboard.type("123"); 28 | 29 | await page.keyboard.press('Enter'); // submit 30 | await page.waitForNavigation(); 31 | 32 | await page.close(); 33 | } 34 | -------------------------------------------------------------------------------- /examples/locator.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | thresholds: { 15 | checks: ["rate==1.0"] 16 | } 17 | } 18 | 19 | export default async function() { 20 | const context = await browser.newContext(); 21 | const page = await context.newPage(); 22 | 23 | try { 24 | await page.goto("https://test.k6.io/flip_coin.php", { 25 | waitUntil: "networkidle", 26 | }) 27 | 28 | /* 29 | In this example, we will use two locators, matching a 30 | different betting button on the page. If you were to query 31 | the buttons once and save them as below, you would see an 32 | error after the initial navigation. Try it! 33 | 34 | const heads = page.$("input[value='Bet on heads!']"); 35 | const tails = page.$("input[value='Bet on tails!']"); 36 | 37 | The Locator API allows you to get a fresh element handle each 38 | time you use one of the locator methods. And, you can carry a 39 | locator across frame navigations. Let's create two locators; 40 | each locates a button on the page. 41 | */ 42 | const heads = page.locator("input[value='Bet on heads!']"); 43 | const tails = page.locator("input[value='Bet on tails!']"); 44 | 45 | const currentBet = page.locator("//p[starts-with(text(),'Your bet: ')]"); 46 | 47 | // In the following Promise.all the tails locator clicks 48 | // on the tails button by using the locator's selector. 49 | // Since clicking on each button causes page navigation, 50 | // waitForNavigation is needed -- this is because the page 51 | // won't be ready until the navigation completes. 52 | // Setting up the waitForNavigation first before the click 53 | // is important to avoid race conditions. 54 | await Promise.all([ 55 | page.waitForNavigation(), 56 | tails.click(), 57 | ]); 58 | console.log(await currentBet.innerText()); 59 | // the heads locator clicks on the heads button 60 | // by using the locator's selector. 61 | await Promise.all([ 62 | page.waitForNavigation(), 63 | heads.click(), 64 | ]); 65 | console.log(await currentBet.innerText()); 66 | await Promise.all([ 67 | page.waitForNavigation(), 68 | tails.click(), 69 | ]); 70 | console.log(await currentBet.innerText()); 71 | } finally { 72 | await page.close(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /examples/locator_pom.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | thresholds: { 15 | checks: ["rate==1.0"] 16 | } 17 | } 18 | 19 | /* 20 | Page Object Model is a well-known pattern to abstract a web page. 21 | 22 | The Locator API enables using the Page Object Model pattern to organize 23 | and simplify test code. 24 | 25 | Note: For comparison, you can see another example that does not use 26 | the Page Object Model pattern in locator.js. 27 | */ 28 | export class Bet { 29 | constructor(page) { 30 | this.page = page; 31 | this.headsButton = page.locator("input[value='Bet on heads!']"); 32 | this.tailsButton = page.locator("input[value='Bet on tails!']"); 33 | this.currentBet = page.locator("//p[starts-with(text(),'Your bet: ')]"); 34 | } 35 | 36 | goto() { 37 | return this.page.goto("https://test.k6.io/flip_coin.php", { waitUntil: "networkidle" }); 38 | } 39 | 40 | heads() { 41 | return Promise.all([ 42 | this.page.waitForNavigation(), 43 | this.headsButton.click(), 44 | ]); 45 | } 46 | 47 | tails() { 48 | return Promise.all([ 49 | this.page.waitForNavigation(), 50 | this.tailsButton.click(), 51 | ]); 52 | } 53 | 54 | current() { 55 | return this.currentBet.innerText(); 56 | } 57 | } 58 | 59 | export default async function() { 60 | const context = await browser.newContext(); 61 | const page = await context.newPage(); 62 | 63 | const bet = new Bet(page); 64 | try { 65 | await bet.goto() 66 | await bet.tails(); 67 | console.log("Current bet:", await bet.current()); 68 | await bet.heads(); 69 | console.log("Current bet:", await bet.current()); 70 | await bet.tails(); 71 | console.log("Current bet:", await bet.current()); 72 | await bet.heads(); 73 | console.log("Current bet:", await bet.current()); 74 | } finally { 75 | await page.close(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/mouse.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | } 14 | } 15 | 16 | export default async function () { 17 | const page = await browser.newPage(); 18 | 19 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 20 | 21 | // Obtain ElementHandle for news link and navigate to it 22 | // by clicking in the 'a' element's bounding box 23 | const newsLinkBox = await page.$('a[href="/news.php"]').then(e => e.boundingBox()); 24 | 25 | await Promise.all([ 26 | page.waitForNavigation(), 27 | page.mouse.click(newsLinkBox.x + newsLinkBox.width / 2, newsLinkBox.y) 28 | ]); 29 | 30 | await page.close(); 31 | } 32 | -------------------------------------------------------------------------------- /examples/multiple-scenario.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | messages: { 6 | executor: 'constant-vus', 7 | exec: 'messages', 8 | vus: 2, 9 | duration: '2s', 10 | options: { 11 | browser: { 12 | type: 'chromium', 13 | }, 14 | }, 15 | }, 16 | news: { 17 | executor: 'per-vu-iterations', 18 | exec: 'news', 19 | vus: 2, 20 | iterations: 4, 21 | maxDuration: '5s', 22 | options: { 23 | browser: { 24 | type: 'chromium', 25 | }, 26 | }, 27 | }, 28 | }, 29 | thresholds: { 30 | browser_web_vital_fcp: ['max < 5000'], 31 | checks: ["rate==1.0"] 32 | } 33 | } 34 | 35 | export async function messages() { 36 | const page = await browser.newPage(); 37 | 38 | try { 39 | await page.goto('https://test.k6.io/my_messages.php', { waitUntil: 'networkidle' }); 40 | } finally { 41 | await page.close(); 42 | } 43 | } 44 | 45 | export async function news() { 46 | const page = await browser.newPage(); 47 | 48 | try { 49 | await page.goto('https://test.k6.io/news.php', { waitUntil: 'networkidle' }); 50 | } finally { 51 | await page.close(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/pageon-metric.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | } 15 | 16 | export default async function() { 17 | const page = await browser.newPage(); 18 | 19 | page.on('metric', (metric) => { 20 | metric.tag({ 21 | name:'test', 22 | matches: [ 23 | {url: /^https:\/\/test\.k6\.io\/\?q=[0-9a-z]+$/, method: 'GET'}, 24 | ] 25 | }); 26 | }); 27 | 28 | try { 29 | await page.goto('https://test.k6.io/?q=abc123'); 30 | await page.goto('https://test.k6.io/?q=def456'); 31 | } finally { 32 | await page.close(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/pageon.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const page = await browser.newPage(); 22 | 23 | try { 24 | await page.goto('https://test.k6.io/'); 25 | 26 | page.on('console', async msg => check(msg, { 27 | 'assert console message type': msg => 28 | msg.type() == 'log', 29 | 'assert console message text': msg => 30 | msg.text() == 'this is a console.log message 42', 31 | 'assert console message first argument': async msg => { 32 | const arg1 = await msg.args()[0].jsonValue(); 33 | return arg1 == 'this is a console.log message'; 34 | }, 35 | 'assert console message second argument': async msg => { 36 | const arg2 = await msg.args()[1].jsonValue(); 37 | return arg2 == 42; 38 | } 39 | })); 40 | 41 | await page.evaluate(() => console.log('this is a console.log message', 42)); 42 | } finally { 43 | await page.close(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /examples/querying.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | await page.goto('https://test.k6.io/'); 26 | 27 | await check(page, { 28 | 'Title with CSS selector': async p => { 29 | const e = await p.$('header h1.title'); 30 | return await e.textContent() === 'test.k6.io'; 31 | }, 32 | 'Title with XPath selector': async p => { 33 | const e = await p.$('//header//h1[@class="title"]'); 34 | return await e.textContent() === 'test.k6.io'; 35 | } 36 | }); 37 | } finally { 38 | await page.close(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/screenshot.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | thresholds: { 15 | checks: ["rate==1.0"] 16 | } 17 | } 18 | 19 | export default async function() { 20 | const context = await browser.newContext(); 21 | const page = await context.newPage(); 22 | 23 | try { 24 | await page.goto('https://test.k6.io/'); 25 | await page.screenshot({ path: 'screenshot.png' }); 26 | // TODO: Assert this somehow. Upload as CI artifact or just an external `ls`? 27 | // Maybe even do a fuzzy image comparison against a preset known good screenshot? 28 | } finally { 29 | await page.close(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/shadowdom.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const page = await browser.newPage(); 22 | 23 | await page.setContent("hello!") 24 | 25 | await page.evaluate(() => { 26 | const shadowRoot = document.createElement('div'); 27 | shadowRoot.id = 'shadow-root'; 28 | shadowRoot.attachShadow({mode: 'open'}); 29 | shadowRoot.shadowRoot.innerHTML = '

Shadow DOM

'; 30 | document.body.appendChild(shadowRoot); 31 | }); 32 | 33 | await check(page.locator('#shadow-dom'), { 34 | 'shadow element exists': e => e !== null, 35 | 'shadow element text is correct': async e => { 36 | return await e.innerText() === 'Shadow DOM'; 37 | } 38 | }); 39 | 40 | await page.close(); 41 | } 42 | -------------------------------------------------------------------------------- /examples/throttle.js: -------------------------------------------------------------------------------- 1 | import { browser, networkProfiles } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | normal: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | exec: 'normal', 13 | }, 14 | networkThrottled: { 15 | executor: 'shared-iterations', 16 | options: { 17 | browser: { 18 | type: 'chromium', 19 | }, 20 | }, 21 | exec: 'networkThrottled', 22 | }, 23 | cpuThrottled: { 24 | executor: 'shared-iterations', 25 | options: { 26 | browser: { 27 | type: 'chromium', 28 | }, 29 | }, 30 | exec: 'cpuThrottled', 31 | }, 32 | }, 33 | thresholds: { 34 | 'browser_http_req_duration{scenario:normal}': ['p(99)<3000'], 35 | 'browser_http_req_duration{scenario:networkThrottled}': ['p(99)<6000'], 36 | 'iteration_duration{scenario:normal}': ['p(99)<5000'], 37 | 'iteration_duration{scenario:cpuThrottled}': ['p(99)<10000'], 38 | }, 39 | } 40 | 41 | export async function normal() { 42 | const context = await browser.newContext(); 43 | const page = await context.newPage(); 44 | 45 | try { 46 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 47 | } finally { 48 | await page.close(); 49 | } 50 | } 51 | 52 | export async function networkThrottled() { 53 | const context = await browser.newContext(); 54 | const page = await context.newPage(); 55 | 56 | try { 57 | await page.throttleNetwork(networkProfiles["Slow 3G"]); 58 | 59 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 60 | } finally { 61 | await page.close(); 62 | } 63 | } 64 | 65 | export async function cpuThrottled() { 66 | const context = await browser.newContext(); 67 | const page = await context.newPage(); 68 | 69 | try { 70 | await page.throttleCPU({ rate: 4 }); 71 | 72 | await page.goto('https://test.k6.io/', { waitUntil: 'networkidle' }); 73 | } finally { 74 | await page.close(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/touchscreen.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | ui: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | } 14 | } 15 | 16 | export default async function () { 17 | const page = await browser.newPage(); 18 | 19 | await page.goto("https://test.k6.io/", { waitUntil: "networkidle" }); 20 | 21 | // Obtain ElementHandle for news link and navigate to it 22 | // by tapping in the 'a' element's bounding box 23 | const newsLinkBox = await page.$('a[href="/news.php"]').then((e) => e.boundingBox()); 24 | 25 | // Wait until the navigation is done before closing the page. 26 | // Otherwise, there will be a race condition between the page closing 27 | // and the navigation. 28 | await Promise.all([ 29 | page.waitForNavigation(), 30 | page.touchscreen.tap(newsLinkBox.x + newsLinkBox.width / 2, newsLinkBox.y), 31 | ]); 32 | 33 | await page.close(); 34 | } 35 | -------------------------------------------------------------------------------- /examples/useragent.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | let context = await browser.newContext({ 22 | userAgent: 'k6 test user agent', 23 | }) 24 | let page = await context.newPage(); 25 | await check(page, { 26 | 'user agent is set': async p => { 27 | const userAgent = await p.evaluate(() => navigator.userAgent); 28 | return userAgent.includes('k6 test user agent'); 29 | } 30 | }); 31 | await page.close(); 32 | await context.close(); 33 | 34 | context = await browser.newContext(); 35 | check(context.browser(), { 36 | 'user agent does not contain headless': b => { 37 | return b.userAgent().includes('Headless') === false; 38 | } 39 | }); 40 | 41 | page = await context.newPage(); 42 | await check(page, { 43 | 'chromium user agent does not contain headless': async p => { 44 | const userAgent = await p.evaluate(() => navigator.userAgent); 45 | return userAgent.includes('Headless') === false; 46 | } 47 | }); 48 | await page.close(); 49 | } 50 | -------------------------------------------------------------------------------- /examples/waitForEvent.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | 3 | export const options = { 4 | scenarios: { 5 | browser: { 6 | executor: 'shared-iterations', 7 | options: { 8 | browser: { 9 | type: 'chromium', 10 | }, 11 | }, 12 | }, 13 | }, 14 | } 15 | 16 | export default async function() { 17 | const context = await browser.newContext() 18 | 19 | // We want to wait for two page creations before carrying on. 20 | var counter = 0 21 | const promise = context.waitForEvent("page", { predicate: page => { 22 | if (++counter == 2) { 23 | return true 24 | } 25 | return false 26 | } }) 27 | 28 | // Now we create two pages. 29 | const page = await context.newPage(); 30 | const page2 = await context.newPage(); 31 | 32 | // We await for the page creation events to be processed and the predicate 33 | // to pass. 34 | await promise 35 | console.log('predicate passed') 36 | 37 | await page.close() 38 | await page2.close(); 39 | }; 40 | -------------------------------------------------------------------------------- /examples/waitforfunction.js: -------------------------------------------------------------------------------- 1 | import { browser } from 'k6/x/browser/async'; 2 | import { check } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; 3 | 4 | export const options = { 5 | scenarios: { 6 | ui: { 7 | executor: 'shared-iterations', 8 | options: { 9 | browser: { 10 | type: 'chromium', 11 | }, 12 | }, 13 | }, 14 | }, 15 | thresholds: { 16 | checks: ["rate==1.0"] 17 | } 18 | } 19 | 20 | export default async function() { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | 24 | try { 25 | await page.evaluate(() => { 26 | setTimeout(() => { 27 | const el = document.createElement('h1'); 28 | el.innerHTML = 'Hello'; 29 | document.body.appendChild(el); 30 | }, 1000); 31 | }); 32 | 33 | const e = await page.waitForFunction( 34 | "document.querySelector('h1')", { 35 | polling: 'mutation', 36 | timeout: 2000 37 | } 38 | ); 39 | await check(e, { 40 | 'waitForFunction successfully resolved': async e => { 41 | return await e.innerHTML() === 'Hello'; 42 | } 43 | }); 44 | } finally { 45 | await page.close(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /k6error/internal.go: -------------------------------------------------------------------------------- 1 | // Package k6error contains ErrFatal. 2 | package k6error 3 | 4 | import ( 5 | "errors" 6 | ) 7 | 8 | // ErrFatal should be wrapped into an error 9 | // to signal to the mapping layer that the error 10 | // is a fatal error and we should abort the whole 11 | // test run, not just the current iteration. It 12 | // should be used in cases where if the iteration 13 | // ran again then there's a 100% chance that it 14 | // will end up running into the same error. 15 | var ErrFatal = errors.New("fatal error") 16 | -------------------------------------------------------------------------------- /k6ext/context.go: -------------------------------------------------------------------------------- 1 | package k6ext 2 | 3 | import ( 4 | "context" 5 | 6 | k6modules "go.k6.io/k6/js/modules" 7 | k6lib "go.k6.io/k6/lib" 8 | 9 | "github.com/grafana/sobek" 10 | ) 11 | 12 | type ctxKey int 13 | 14 | const ( 15 | ctxKeyVU ctxKey = iota 16 | ctxKeyPid 17 | ctxKeyCustomK6Metrics 18 | ) 19 | 20 | // WithVU returns a new context based on ctx with the k6 VU instance attached. 21 | func WithVU(ctx context.Context, vu k6modules.VU) context.Context { 22 | return context.WithValue(ctx, ctxKeyVU, vu) 23 | } 24 | 25 | // GetVU returns the attached k6 VU instance from ctx, which can be used to 26 | // retrieve the sobek runtime and other k6 objects relevant to the currently 27 | // executing VU. 28 | // See https://github.com/grafana/k6/blob/v0.37.0/js/initcontext.go#L168-L186 29 | func GetVU(ctx context.Context) k6modules.VU { 30 | v := ctx.Value(ctxKeyVU) 31 | if vu, ok := v.(k6modules.VU); ok { 32 | return vu 33 | } 34 | return nil 35 | } 36 | 37 | // WithCustomMetrics attaches the CustomK6Metrics object to the context. 38 | func WithCustomMetrics(ctx context.Context, k6m *CustomMetrics) context.Context { 39 | return context.WithValue(ctx, ctxKeyCustomK6Metrics, k6m) 40 | } 41 | 42 | // GetCustomMetrics returns the CustomK6Metrics object attached to the context. 43 | func GetCustomMetrics(ctx context.Context) *CustomMetrics { 44 | v := ctx.Value(ctxKeyCustomK6Metrics) 45 | if k6m, ok := v.(*CustomMetrics); ok { 46 | return k6m 47 | } 48 | return nil 49 | } 50 | 51 | // Runtime is a convenience function for getting a k6 VU runtime. 52 | func Runtime(ctx context.Context) *sobek.Runtime { 53 | return GetVU(ctx).Runtime() 54 | } 55 | 56 | // GetScenarioName returns the scenario name associated with the given context. 57 | func GetScenarioName(ctx context.Context) string { 58 | ss := k6lib.GetScenarioState(ctx) 59 | if ss == nil { 60 | return "" 61 | } 62 | return ss.Name 63 | } 64 | 65 | // GetScenarioOpts returns the browser options and environment variables associated 66 | // with the given context. 67 | func GetScenarioOpts(ctx context.Context, vu k6modules.VU) map[string]any { 68 | scenario := GetScenarioName(ctx) 69 | if scenario == "" { 70 | return nil 71 | } 72 | if so := vu.State().Options.Scenarios[scenario].GetScenarioOptions(); so != nil { 73 | return so.Browser 74 | } 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /k6ext/doc.go: -------------------------------------------------------------------------------- 1 | // Package k6ext acts as an encapsulation layer between the k6 core and xk6-browser. 2 | package k6ext 3 | -------------------------------------------------------------------------------- /k6ext/k6test/doc.go: -------------------------------------------------------------------------------- 1 | // Package k6test provides mock implementations of k6 elements for testing purposes. 2 | package k6test 3 | -------------------------------------------------------------------------------- /k6ext/k6test/executor.go: -------------------------------------------------------------------------------- 1 | package k6test 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | 6 | k6lib "go.k6.io/k6/lib" 7 | k6executor "go.k6.io/k6/lib/executor" 8 | ) 9 | 10 | // TestExecutor is a k6lib.ExecutorConfig implementation 11 | // for testing purposes. 12 | type TestExecutor struct { 13 | k6executor.BaseConfig 14 | } 15 | 16 | // GetDescription returns a mock Executor description. 17 | func (te *TestExecutor) GetDescription(*k6lib.ExecutionTuple) string { 18 | return "TestExecutor" 19 | } 20 | 21 | // GetExecutionRequirements is a dummy implementation that just returns nil. 22 | func (te *TestExecutor) GetExecutionRequirements(*k6lib.ExecutionTuple) []k6lib.ExecutionStep { 23 | return nil 24 | } 25 | 26 | // NewExecutor is a dummy implementation that just returns nil. 27 | func (te *TestExecutor) NewExecutor(*k6lib.ExecutionState, *logrus.Entry) (k6lib.Executor, error) { 28 | return nil, nil //nolint:nilnil 29 | } 30 | 31 | // HasWork is a dummy implementation that returns true. 32 | func (te *TestExecutor) HasWork(*k6lib.ExecutionTuple) bool { 33 | return true 34 | } 35 | -------------------------------------------------------------------------------- /k6ext/metrics.go: -------------------------------------------------------------------------------- 1 | package k6ext 2 | 3 | import ( 4 | k6metrics "go.k6.io/k6/metrics" 5 | ) 6 | 7 | const ( 8 | webVitalFID = "FID" 9 | webVitalTTFB = "TTFB" 10 | webVitalLCP = "LCP" 11 | webVitalCLS = "CLS" 12 | webVitalINP = "INP" 13 | webVitalFCP = "FCP" 14 | 15 | fidName = "browser_web_vital_fid" 16 | ttfbName = "browser_web_vital_ttfb" 17 | lcpName = "browser_web_vital_lcp" 18 | clsName = "browser_web_vital_cls" 19 | inpName = "browser_web_vital_inp" 20 | fcpName = "browser_web_vital_fcp" 21 | 22 | browserDataSentName = "browser_data_sent" 23 | browserDataReceivedName = "browser_data_received" 24 | browserHTTPReqDurationName = "browser_http_req_duration" 25 | browserHTTPReqFailedName = "browser_http_req_failed" 26 | ) 27 | 28 | // CustomMetrics are the custom k6 metrics used by xk6-browser. 29 | type CustomMetrics struct { 30 | WebVitals map[string]*k6metrics.Metric 31 | 32 | BrowserDataSent *k6metrics.Metric 33 | BrowserDataReceived *k6metrics.Metric 34 | BrowserHTTPReqDuration *k6metrics.Metric 35 | BrowserHTTPReqFailed *k6metrics.Metric 36 | } 37 | 38 | // RegisterCustomMetrics creates and registers our custom metrics with the k6 39 | // VU Registry and returns our internal struct pointer. 40 | func RegisterCustomMetrics(registry *k6metrics.Registry) *CustomMetrics { 41 | wvs := map[string]string{ 42 | webVitalFID: fidName, // first input delay 43 | webVitalTTFB: ttfbName, // time to first byte 44 | webVitalLCP: lcpName, // largest content paint 45 | webVitalCLS: clsName, // cumulative layout shift 46 | webVitalINP: inpName, // interaction to next paint 47 | webVitalFCP: fcpName, // first contentful paint 48 | } 49 | webVitals := make(map[string]*k6metrics.Metric) 50 | 51 | for k, v := range wvs { 52 | t := k6metrics.Time 53 | // CLS is not a time based measurement, it is a score, 54 | // so use the default metric type for CLS. 55 | if k == webVitalCLS { 56 | t = k6metrics.Default 57 | } 58 | 59 | webVitals[k] = registry.MustNewMetric(v, k6metrics.Trend, t) 60 | } 61 | 62 | return &CustomMetrics{ 63 | WebVitals: webVitals, 64 | BrowserDataSent: registry.MustNewMetric(browserDataSentName, k6metrics.Counter, k6metrics.Data), 65 | BrowserDataReceived: registry.MustNewMetric(browserDataReceivedName, k6metrics.Counter, k6metrics.Data), 66 | BrowserHTTPReqDuration: registry.MustNewMetric(browserHTTPReqDurationName, k6metrics.Trend, k6metrics.Time), 67 | BrowserHTTPReqFailed: registry.MustNewMetric(browserHTTPReqFailedName, k6metrics.Rate), 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /k6ext/panic.go: -------------------------------------------------------------------------------- 1 | package k6ext 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/grafana/sobek" 12 | 13 | "go.k6.io/k6/errext" 14 | k6common "go.k6.io/k6/js/common" 15 | ) 16 | 17 | // Abort will shutdown the whole test run. This should 18 | // only be used from the sobek mapping layer. It is only 19 | // to be used when an error will occur in all iterations, 20 | // so it's permanent. 21 | func Abort(ctx context.Context, format string, a ...any) { 22 | failFunc := func(rt *sobek.Runtime, a ...any) { 23 | reason := fmt.Errorf(format, a...).Error() 24 | rt.Interrupt(&errext.InterruptError{Reason: reason}) 25 | } 26 | sharedPanic(ctx, failFunc, a...) 27 | } 28 | 29 | // Panic will cause a panic with the given error which will stop 30 | // the current iteration. Before panicking, it will find the 31 | // browser process from the context and kill it if it still exists. 32 | // TODO: test. 33 | func Panic(ctx context.Context, format string, a ...any) { 34 | failFunc := func(rt *sobek.Runtime, a ...any) { 35 | k6common.Throw(rt, fmt.Errorf(format, a...)) 36 | } 37 | sharedPanic(ctx, failFunc, a...) 38 | } 39 | 40 | func sharedPanic(ctx context.Context, failFunc func(rt *sobek.Runtime, a ...any), a ...any) { 41 | rt := Runtime(ctx) 42 | if rt == nil { 43 | // this should never happen unless a programmer error 44 | panic("no k6 JS runtime in context") 45 | } 46 | // get a user-friendly error if the err is not already so. 47 | if len(a) > 0 { 48 | var ( 49 | uerr *UserFriendlyError 50 | err, ok = a[len(a)-1].(error) 51 | ) 52 | if ok && !errors.As(err, &uerr) { 53 | a[len(a)-1] = &UserFriendlyError{Err: err} 54 | } 55 | } 56 | defer failFunc(rt, a...) 57 | 58 | // TODO: Remove this after moving k6ext.Panic into the mapping layer. 59 | pidder, ok := GetVU(ctx).(interface { 60 | Pids() []int 61 | }) 62 | if !ok { 63 | // we're running in a test, let's skip killing the process. 64 | return 65 | } 66 | for _, pid := range pidder.Pids() { 67 | p, err := os.FindProcess(pid) //nolint:forbidigo 68 | if err != nil { 69 | // optimistically skip and don't kill the process 70 | continue 71 | } 72 | // no need to check the error for whether we could kill it as 73 | // we're already dying. 74 | _ = p.Kill() 75 | } 76 | } 77 | 78 | // UserFriendlyError maps an internal error to an error that users 79 | // can easily understand. 80 | type UserFriendlyError struct { 81 | Err error 82 | Timeout time.Duration // prints "timed out after Ns" error 83 | } 84 | 85 | func (e *UserFriendlyError) Unwrap() error { return e.Err } 86 | 87 | func (e *UserFriendlyError) Error() string { 88 | switch { 89 | default: 90 | return e.Err.Error() 91 | case e.Err == nil: 92 | return "" 93 | case errors.Is(e.Err, context.DeadlineExceeded): 94 | s := "timed out" 95 | if t := e.Timeout; t != 0 { 96 | s += fmt.Sprintf(" after %s", t) 97 | } 98 | return strings.ReplaceAll(e.Err.Error(), context.DeadlineExceeded.Error(), s) 99 | case errors.Is(e.Err, context.Canceled): 100 | return "canceled" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /k6ext/promise.go: -------------------------------------------------------------------------------- 1 | package k6ext 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafana/sobek" 7 | "go.k6.io/k6/js/promises" 8 | ) 9 | 10 | // PromisifiedFunc is a type of the function to run as a promise. 11 | type PromisifiedFunc func() (result any, reason error) 12 | 13 | // Promise runs fn in a goroutine and returns a new sobek.Promise. 14 | // - If fn returns a nil error, resolves the promise with the 15 | // first result value fn returns. 16 | // - Otherwise, rejects the promise with the error fn returns. 17 | func Promise(ctx context.Context, fn PromisifiedFunc) *sobek.Promise { 18 | return promise(ctx, fn) 19 | } 20 | 21 | func promise(ctx context.Context, fn PromisifiedFunc) *sobek.Promise { 22 | p, resolve, reject := promises.New(GetVU(ctx)) 23 | go func() { 24 | v, err := fn() 25 | if err != nil { 26 | reject(err) 27 | return 28 | } 29 | resolve(v) 30 | }() 31 | 32 | return p 33 | } 34 | -------------------------------------------------------------------------------- /keyboardlayout/layout.go: -------------------------------------------------------------------------------- 1 | // Package keyboardlayout provides keyboard key interpretation and layout validation. 2 | package keyboardlayout 3 | 4 | import ( 5 | "fmt" 6 | "sync" 7 | ) 8 | 9 | type KeyInput string 10 | 11 | type KeyDefinition struct { 12 | Code string 13 | Key string 14 | KeyCode int64 15 | KeyCodeWithoutLocation int64 16 | ShiftKey string 17 | ShiftKeyCode int64 18 | Text string 19 | Location int64 20 | } 21 | 22 | type KeyboardLayout struct { 23 | ValidKeys map[KeyInput]bool 24 | Keys map[KeyInput]KeyDefinition 25 | } 26 | 27 | // KeyDefinition returns true with the key definition of a given key input. 28 | // It returns false and an empty key definition if it cannot find the key. 29 | func (kl KeyboardLayout) KeyDefinition(key KeyInput) (KeyDefinition, bool) { 30 | for _, d := range kl.Keys { 31 | if d.Key == string(key) { 32 | return d, true 33 | } 34 | } 35 | return KeyDefinition{}, false 36 | } 37 | 38 | // ShiftKeyDefinition returns shift key definition of a given key input. 39 | // It returns an empty key definition if it cannot find the key. 40 | func (kl KeyboardLayout) ShiftKeyDefinition(key KeyInput) KeyDefinition { 41 | for _, d := range kl.Keys { 42 | if d.ShiftKey == string(key) { 43 | return d 44 | } 45 | } 46 | return KeyDefinition{} 47 | } 48 | 49 | //nolint:gochecknoglobals 50 | var ( 51 | kbdLayouts = make(map[string]KeyboardLayout) 52 | mx sync.RWMutex 53 | ) 54 | 55 | // GetKeyboardLayout returns the keyboard layout registered with name. 56 | func GetKeyboardLayout(name string) KeyboardLayout { 57 | mx.RLock() 58 | defer mx.RUnlock() 59 | return kbdLayouts[name] 60 | } 61 | 62 | func init() { 63 | initUS() 64 | } 65 | 66 | // Register the given keyboard layout. 67 | // This function panics if a keyboard layout with the same name is already registered. 68 | func register(lang string, validKeys map[KeyInput]bool, keys map[KeyInput]KeyDefinition) { 69 | mx.Lock() 70 | defer mx.Unlock() 71 | 72 | if _, ok := kbdLayouts[lang]; ok { 73 | panic(fmt.Sprintf("keyboard layout already registered: %s", lang)) 74 | } 75 | kbdLayouts[lang] = KeyboardLayout{ValidKeys: validKeys, Keys: keys} 76 | } 77 | -------------------------------------------------------------------------------- /packaging/full-white-stripe.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/xk6-browser/d099ea4eca220e193ce28cdcd5010585eaf2d95f/packaging/full-white-stripe.jpg -------------------------------------------------------------------------------- /packaging/nfpm.yaml: -------------------------------------------------------------------------------- 1 | name: "xk6-browser" 2 | arch: "${GOARCH}" 3 | platform: "linux" 4 | version: "${VERSION}" 5 | version_schema: semver 6 | section: "default" 7 | maintainer: "Raintank Inc. d.b.a. Grafana Labs" 8 | description: | 9 | Load testing for the 21st century. 10 | depends: 11 | - ca-certificates 12 | homepage: "https://k6.io" 13 | license: "AGPL-3.0" 14 | contents: 15 | - src: ./xk6-browser 16 | dst: /usr/bin/xk6-browser 17 | 18 | deb: 19 | compression: xz 20 | fields: 21 | Bugs: https://github.com/grafana/xk6-browser/issues 22 | -------------------------------------------------------------------------------- /packaging/thin-white-stripe.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/xk6-browser/d099ea4eca220e193ce28cdcd5010585eaf2d95f/packaging/thin-white-stripe.jpg -------------------------------------------------------------------------------- /packaging/xk6-browser.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/xk6-browser/d099ea4eca220e193ce28cdcd5010585eaf2d95f/packaging/xk6-browser.ico -------------------------------------------------------------------------------- /packaging/xk6-browser.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /register.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "github.com/grafana/xk6-browser/browser" 5 | 6 | k6modules "go.k6.io/k6/js/modules" 7 | ) 8 | 9 | func init() { 10 | k6modules.Register("k6/x/browser/async", browser.New()) 11 | } 12 | -------------------------------------------------------------------------------- /release notes/v0.1.0.md: -------------------------------------------------------------------------------- 1 | This is the first public release of xk6-browser! :tada: 2 | 3 | It's an early **beta** release we've been working on internally for several months now and we're happy to finally share it with the k6 community. See the [release announcement blog post](https://k6.io/blog/announcing-xk6-browser-testing/). 4 | 5 | Take a look at the [README](/README.md) for [JavaScript API examples](/README.md#examples), and the [current status](/README.md#status) of implemented functionality. 6 | 7 | You can also find runnable examples in the [`examples` directory](/examples). It's as simple as downloading a pre-built binary package for your operating system from below, extracting it, cloning this repository and running `./xk6-browser run examples/ 49 | 50 | -------------------------------------------------------------------------------- /tests/static/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
Hello World
5 |
6 | 7 | 8 | 35 | 36 | -------------------------------------------------------------------------------- /tests/static/embedded_iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/static/iframe_home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign In 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/static/iframe_signin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
Sign In Page
6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/static/iframe_test_main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/static/iframe_test_nested1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/static/iframe_test_nested2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/static/lifecycle.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Home 7 |
Waiting...
8 |
Waiting...
9 | 10 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/static/lifecycle_main_frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
main
7 |
Waiting...
8 |
Waiting...
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/static/lifecycle_no_ping_js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
Waiting...
7 | 8 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/static/locators.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Clickable link test 6 | 7 | 8 | 9 | Click 10 | Dblclick 11 | Click 12 | 13 | 14 | 15 | 16 |
hello
17 |
bye
18 | 19 |

original text

20 |

original text

21 | 22 | 23 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /tests/static/mouse_helper.js: -------------------------------------------------------------------------------- 1 | // This injects a circle into the page that moves with the mouse; 2 | // Useful for debugging 3 | (function(){ 4 | const box = document.createElement('div'); 5 | box.classList.add('mouse-helper'); 6 | const styleElement = document.createElement('style'); 7 | styleElement.innerHTML = ` 8 | .mouse-helper { 9 | pointer-events: none; 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | width: 20px; 14 | height: 20px; 15 | background: rgba(0,0,0,.4); 16 | border: 1px solid white; 17 | border-radius: 10px; 18 | margin-left: -10px; 19 | margin-top: -10px; 20 | transition: background .2s, border-radius .2s, border-color .2s; 21 | } 22 | .mouse-helper.button-1 { 23 | transition: none; 24 | background: rgba(0,0,0,0.9); 25 | } 26 | .mouse-helper.button-2 { 27 | transition: none; 28 | border-color: rgba(0,0,255,0.9); 29 | } 30 | .mouse-helper.button-3 { 31 | transition: none; 32 | border-radius: 4px; 33 | } 34 | .mouse-helper.button-4 { 35 | transition: none; 36 | border-color: rgba(255,0,0,0.9); 37 | } 38 | .mouse-helper.button-5 { 39 | transition: none; 40 | border-color: rgba(0,255,0,0.9); 41 | } 42 | `; 43 | document.head.appendChild(styleElement); 44 | document.body.appendChild(box); 45 | document.addEventListener('mousemove', event => { 46 | box.style.left = event.pageX + 'px'; 47 | box.style.top = event.pageY + 'px'; 48 | updateButtons(event.buttons); 49 | }, true); 50 | document.addEventListener('mousedown', event => { 51 | updateButtons(event.buttons); 52 | box.classList.add('button-' + event.which); 53 | }, true); 54 | document.addEventListener('mouseup', event => { 55 | updateButtons(event.buttons); 56 | box.classList.remove('button-' + event.which); 57 | }, true); 58 | function updateButtons(buttons) { 59 | for (let i = 0; i < 5; i++) { 60 | box.classList.toggle('button-' + i, buttons & (1 << i)); 61 | } 62 | } 63 | })(); -------------------------------------------------------------------------------- /tests/static/nav_in_doc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Navigation test within the same document 4 | 5 | 6 | Navigate with History API 7 | Navigate with anchor link 8 |
Some div...
9 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/static/non_clickable.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Non-clickable test 5 | 18 | 19 | 20 |

I'm preventing clicking on elements

21 | I am non clickable 22 | 23 | -------------------------------------------------------------------------------- /tests/static/page1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Page 1

5 | Click Me 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/static/page2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Page 2

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/static/ping.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Ping duration test 4 | 5 | 6 |
NA
7 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/static/select_options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | -------------------------------------------------------------------------------- /tests/static/shadow_and_doc_frag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DocumentFragment and ShadowRoot Test page 7 | 8 | 9 |

DocumentFragment and ShadowRoot Test page

10 |
11 | 12 | 13 |
14 | 15 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /tests/static/shadow_dom_link.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 20 | 21 | 22 | 25 | Sign up 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/static/usual.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/xk6-browser/d099ea4eca220e193ce28cdcd5010585eaf2d95f/tests/static/usual.html -------------------------------------------------------------------------------- /tests/static/visible.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
My DIV
5 |
My DIV 2
6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/static/wait_for.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/static/wait_until.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WaitUntil options test 7 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /tests/static/web_vitals.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | for web vital 13 | 14 | 36 |
37 |
38 |
39 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore 40 | et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut 41 | aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum 42 | dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui 43 | officia deserunt mollit anim id est laborum. 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
TitleReview
Book AGood
Book BOk
Book CNot good
Book DVery good
68 | 69 | 70 | -------------------------------------------------------------------------------- /tests/test_browser_proxy.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "sync" 11 | "testing" 12 | 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | // testBrowserProxy wraps a testBrowser and 17 | // proxies WS messages to/from it. 18 | type testBrowserProxy struct { 19 | t testing.TB 20 | 21 | mu sync.Mutex // avoid concurrent connect requests 22 | 23 | tb *testBrowser 24 | ts *httptest.Server 25 | 26 | connected bool 27 | } 28 | 29 | func newTestBrowserProxy(tb testing.TB, b *testBrowser) *testBrowserProxy { 30 | tb.Helper() 31 | 32 | p := &testBrowserProxy{ 33 | t: tb, 34 | tb: b, 35 | } 36 | p.ts = httptest.NewServer(p.connHandler()) 37 | 38 | return p 39 | } 40 | 41 | func (p *testBrowserProxy) wsURL() string { 42 | p.t.Helper() 43 | 44 | tsURL, err := url.Parse(p.ts.URL) 45 | if err != nil { 46 | p.t.Fatalf("error parsing test server URL: %v", err) 47 | } 48 | return fmt.Sprintf("ws://%s", tsURL.Host) 49 | } 50 | 51 | func (p *testBrowserProxy) connHandler() http.Handler { 52 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | p.mu.Lock() 54 | defer p.mu.Unlock() 55 | 56 | upgrader := websocket.Upgrader{} // default options 57 | 58 | // Upgrade in connection from client 59 | in, err := upgrader.Upgrade(w, r, nil) 60 | if err != nil { 61 | p.t.Fatalf("error upgrading proxy connection: %v", err) 62 | } 63 | defer in.Close() //nolint:errcheck 64 | 65 | // Connect to testBrowser CDP WS 66 | out, _, err := websocket.DefaultDialer.Dial(p.tb.wsURL, nil) //nolint:bodyclose 67 | if err != nil { 68 | p.t.Fatalf("error connecting to test browser: %v", err) 69 | } 70 | defer out.Close() //nolint:errcheck 71 | 72 | p.connected = true 73 | 74 | // Stop proxy when test exits 75 | ctx, cancel := context.WithCancel(context.Background()) 76 | p.t.Cleanup(func() { 77 | cancel() // stop forwarding mssgs 78 | p.ts.Close() // close test server 79 | }) 80 | 81 | var wg sync.WaitGroup 82 | wg.Add(2) 83 | 84 | go p.fwdMssgs(ctx, in, out, &wg) 85 | go p.fwdMssgs(ctx, out, in, &wg) 86 | 87 | wg.Wait() 88 | }) 89 | } 90 | 91 | func (p *testBrowserProxy) fwdMssgs(ctx context.Context, 92 | in, out *websocket.Conn, wg *sync.WaitGroup, 93 | ) { 94 | p.t.Helper() 95 | defer wg.Done() 96 | 97 | LOOP: 98 | for { 99 | select { 100 | case <-ctx.Done(): 101 | break LOOP 102 | default: 103 | mt, message, err := in.ReadMessage() 104 | if err != nil { 105 | var cerr *websocket.CloseError 106 | if errors.As(err, &cerr) { 107 | // If WS conn is closed, just return 108 | return 109 | } 110 | p.t.Fatalf("error reading message: %v", err) 111 | } 112 | 113 | err = out.WriteMessage(mt, message) 114 | if err != nil { 115 | var cerr *websocket.CloseError 116 | if errors.As(err, &cerr) { 117 | // If WS conn is closed, just return 118 | return 119 | } 120 | p.t.Fatalf("error writing message: %v", err) 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/test_browser_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/grafana/xk6-browser/env" 11 | ) 12 | 13 | func TestTestBrowserAwaitWithTimeoutShortCircuit(t *testing.T) { 14 | t.Parallel() 15 | tb := newTestBrowser(t) 16 | start := time.Now() 17 | require.NoError(t, tb.awaitWithTimeout(time.Second*10, func() error { 18 | runtime.Goexit() // this is what happens when a `require` fails 19 | return nil 20 | })) 21 | require.Less(t, time.Since(start), time.Second) 22 | } 23 | 24 | // testingT is a wrapper around testing.TB. 25 | type testingT struct { 26 | testing.TB 27 | fatalfCalled bool 28 | } 29 | 30 | // Fatalf skips the test immediately after a test is calling it. 31 | // This is useful when a test is expected to fail, but we don't 32 | // want to mark it as a failure since it's expected. 33 | func (t *testingT) Fatalf(format string, args ...any) { 34 | t.fatalfCalled = true 35 | t.SkipNow() 36 | } 37 | 38 | func TestTestBrowserWithLookupFunc(t *testing.T) { 39 | // Skip until we get answer from Chromium team in an open issue 40 | // https://issues.chromium.org/issues/364089353. 41 | t.Skip("Skipping until we get response from Chromium team") 42 | t.Parallel() 43 | 44 | tt := &testingT{TB: t} 45 | // this operation is expected to fail because the remote debugging port is 46 | // invalid, practically testing that the InitEnv.LookupEnv is used. 47 | _ = newTestBrowser( 48 | tt, 49 | withEnvLookup(env.ConstLookup(env.BrowserArguments, "remote-debugging-port=99999")), 50 | ) 51 | require.True(t, tt.fatalfCalled) 52 | } 53 | --------------------------------------------------------------------------------