diff --git a/.github/workflows/kitesurf.yml b/.github/workflows/kitesurf.yml new file mode 100644 index 000000000..547dc75f1 --- /dev/null +++ b/.github/workflows/kitesurf.yml @@ -0,0 +1,92 @@ +name: Kitesurf Helper Tests + +on: + push: + branches: + - 4.x + pull_request: + branches: + - '**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + FORCE_COLOR: 1 + KITESURF_VERSION: 2026.11.9 + KITESURF_ENDPOINT: http://127.0.0.1:8787 + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 90 + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Checkout Kitesurf + uses: actions/checkout@v6 + with: + repository: cloudflare/kitesurf + ref: ${{ env.KITESURF_VERSION }} + path: kitesurf + persist-credentials: false + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + - uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + - name: npm install + run: npm i --force + - name: Install Kitesurf dependencies + working-directory: kitesurf + run: pnpm install --frozen-lockfile + - name: Cache Kitesurf build + id: kitesurf-build + uses: actions/cache@v6 + with: + path: | + kitesurf/packages/core/pkg + kitesurf/packages/renderer-core/pkg + kitesurf/packages/engine/public/rbi + kitesurf/packages/dom-shim/dist + kitesurf/packages/page-script/dist + kitesurf/packages/page-renderer/dist + kitesurf/packages/worker-script/dist + key: kitesurf-build-${{ env.KITESURF_VERSION }}-x86_64-linux + - name: Build Kitesurf + if: steps.kitesurf-build.outputs.cache-hit != 'true' + working-directory: kitesurf + run: | + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + bazel run //packages/engine:dev_build + - name: start Kitesurf + working-directory: kitesurf/packages/engine + run: | + npx wrangler dev --env local -c wrangler.jsonc -c test/fixtures-worker/wrangler.jsonc --port 8787 > ~/kitesurf.log 2>&1 & + for i in $(seq 1 90); do curl -sf http://127.0.0.1:8787/health && break; sleep 2; done + curl -sf http://127.0.0.1:8787/json/version + - name: start test server + run: | + php -S 127.0.0.1:8000 -t test/data/app & + sleep 1 + curl -sf http://127.0.0.1:8000/info > /dev/null + - name: run kitesurf helper tests + run: npm run test:unit:webbapi:kitesurf + - name: run kitesurf acceptance tests + run: ./bin/codecept.js run -c test/acceptance/codecept.Kitesurf.js --grep @Kitesurf --debug + - name: Kitesurf log + if: failure() + run: tail -200 ~/kitesurf.log diff --git a/Bunoshfile.js b/Bunoshfile.js index 58fead570..dd169a9f0 100644 --- a/Bunoshfile.js +++ b/Bunoshfile.js @@ -342,7 +342,7 @@ const inheritedHelperDocs = { Obscura: { parent: 'CDPBrowser' }, Lightpanda: { parent: 'CDPBrowser' }, Moli: { parent: 'CDPBrowser' }, - Kitesurf: { parent: 'CDPBrowser', excludeConfig: ['endpoint', 'headers'] }, + Kitesurf: { parent: 'CDPBrowser' }, } const abstractHelpers = ['CDPBrowser'] diff --git a/docs/alternative-browsers.md b/docs/alternative-browsers.md index b285955f9..e68b39c1d 100644 --- a/docs/alternative-browsers.md +++ b/docs/alternative-browsers.md @@ -9,7 +9,7 @@ title: Alternative Browser Engines The `Obscura`, `Lightpanda`, `Moli` and `Kitesurf` helpers are experimental. Keep a Playwright or WebDriver job for compatibility-critical tests. ::: -Playwright drives full Chromium, Firefox and WebKit: the most accurate way to test what users see, at the cost of a heavy browser per worker. Alternative engines trade part of that accuracy for speed, a small footprint, or cloud scale. They are a good fit for functional tests (text appears, form submits, redirect happens, cookie is set) and a poor one for anything visual. +Playwright drives full Chromium, Firefox and WebKit: the most accurate way to test what users see, at the cost of a heavy browser per worker. Alternative engines trade part of that accuracy for speed, a small footprint, or cloud scale. Obscura, Lightpanda, Moli and Kitesurf are open source and run on your machine; Kitesurf also runs in Cloudflare's cloud. They are a good fit for functional tests (text appears, form submits, redirect happens, cookie is set) and a poor one for anything visual. Your tests do not change. `I.amOnPage`, `I.click`, `I.fillField`, `I.see`, `I.seeElement` and the rest of the `I.*` web API work the same; you only swap the helper in the config. All engines below speak Chrome DevTools Protocol, so there is no `npx playwright install` step. @@ -200,14 +200,53 @@ All options: [Moli helper reference](/helpers/moli). ## Kitesurf -[Kitesurf](https://blog.cloudflare.com/kitesurf/) is Cloudflare's browser running on Cloudflare Workers, with a real layout and rendering pipeline. Sessions start in about a second and nothing runs on your machine, so the number of parallel browsers is limited only by how far your suite splits. +[Kitesurf](https://github.com/cloudflare/kitesurf) is Cloudflare's open-source browser engine built on Cloudflare Workers, with real layout and rendering: screenshots and the `screencast` plugin work. Run it locally for development and CI, or use it in the cloud through Cloudflare Browser Run, where sessions start in about a second and the number of parallel browsers is limited only by how far your suite splits. + +Page JavaScript runs in Boa, a JavaScript engine written in Rust, not V8, so some libraries fail to start. + +Kitesurf starts a fresh isolated worker for every page, so each navigation costs about 200 ms on a local engine. A suite runs about 4 times slower than on Lightpanda, Moli or Obscura, close to Playwright. In exchange, it is the only engine here with real layout and screenshots. Limitations: -- Cloud only, currently a free beta. -- The browser cannot reach `localhost`: test a deployed environment or expose your app with a tunnel such as `cloudflared tunnel --url http://localhost:3000`. +- `attachFile` does not upload files. +- Clipboard actions do not work: pages are never a secure context, even on `localhost`. +- A click can hit the wrong element when a child overflows a container with `overflow: hidden`. +- Rich text editors Quill, Trix and ACE do not work; TinyMCE does not start on a pre-filled element. +- A Base UI radio group does not uncheck the previously selected radio. +- Links compute `cursor: auto` instead of `pointer`. -### Setup +### Local engine + +Build and start the engine following the [Kitesurf README](https://github.com/cloudflare/kitesurf#run-locally). It listens on `http://127.0.0.1:8787`. Point the helper at it with `endpoint`; the helper connects but never starts or stops the engine: + +```js +// codecept.conf.js +export const config = { + helpers: { + Kitesurf: { + url: 'http://localhost:3000', + endpoint: 'http://127.0.0.1:8787', + }, + }, +} +``` + +The same `endpoint` works for an engine you deploy to your own Cloudflare account. If it sits behind Cloudflare Access, pass the service token in `headers`: + +```js +Kitesurf: { + url: 'https://staging.myapp.com', + endpoint: 'https://kitesurf.example.com', + headers: { + 'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID, + 'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET, + }, +} +``` + +### Cloudflare Browser Run + +Without `endpoint`, the helper opens a Kitesurf session in Cloudflare Browser Run, currently a free beta. A cloud browser cannot reach `localhost`: test a deployed environment or expose your app with a tunnel such as `cloudflared tunnel --url http://localhost:3000`. Enable [Cloudflare Browser Run](https://developers.cloudflare.com/browser-run/) and create an API token with the **Browser Rendering → Edit** permission. Export the credentials: @@ -229,8 +268,6 @@ export const config = { } ``` -### Usage - Every worker opens its own cloud session: ```sh diff --git a/docs/helpers/Kitesurf.md b/docs/helpers/Kitesurf.md index fdc6d5205..8a381493d 100644 --- a/docs/helpers/Kitesurf.md +++ b/docs/helpers/Kitesurf.md @@ -11,17 +11,40 @@ title: Kitesurf **Extends CDPBrowser** -Kitesurf is a cloud browser helper that extends CDPBrowser to run tests against -Cloudflare's Browser Run service (Kitesurf browser). It automates real browser -sessions in Cloudflare's cloud infrastructure, eliminating the need to manage -local browser instances. +Kitesurf drives [Kitesurf][1], Cloudflare's open-source +browser engine that runs on Cloudflare Workers, over the Chrome DevTools Protocol. -**Status:** Beta +**Status:** Experimental -## Requirements +The helper connects in one of two ways: -* Cloudflare account with Browser Run enabled -* API token with **Browser Rendering Edit** permission +* **Engine you run** — set `endpoint` to a Kitesurf engine started locally with `pnpm run dev` + (`http://127.0.0.1:8787`) or deployed to your own account. The helper only connects; it never + starts or stops the engine. +* **Cloudflare Browser Run** — leave `endpoint` unset and provide `accountId` and `apiToken`. + Every test run opens a cloud session and closes it after the last test. + +## Local engine + +Build and start the engine following the [Kitesurf README][2], +then point the helper at it: + +```js +// codecept.conf.js +{ + helpers: { + Kitesurf: { + url: 'http://localhost:3000', + endpoint: 'http://127.0.0.1:8787', + } + } +} +``` + +## Cloudflare Browser Run + +Requires a Cloudflare account with Browser Run enabled and an API token with +**Browser Rendering Edit** permission. ## Setup @@ -37,10 +60,8 @@ To create an API token with Browser Rendering Edit permission: For more details, see: -* [Cloudflare Browser Run Developers Docs][1] -* [Cloudflare Blog - Kitesurf Announcement][2] - -## Example +* [Cloudflare Browser Run Developers Docs][3] +* [Cloudflare Blog - Kitesurf Announcement][4] ```js // codecept.conf.js @@ -68,23 +89,25 @@ export CF_API_TOKEN="your-api-token" This helper should be configured in codecept.conf.js -Type: [object][4] +Type: [object][6] ### Properties -* `url` **[string][3]?** base URL for tests -* `accountId` **[string][3]?** Cloudflare account ID; defaults to CF_ACCOUNT_ID env var -* `apiToken` **[string][3]?** Cloudflare API token; defaults to CF_API_TOKEN env var -* `keepAlive` **[number][8]?** session keep-alive time in milliseconds -* `apiBase` **[string][3]?** Cloudflare API base URL -* `input` **[string][3]?** how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events). -* `capabilities` **[object][4]?** browser capabilities; Kitesurf sets `{ layout: 'real', screenshot: true }`. -* `xpathPolyfill` **([string][3] | [boolean][18])?** inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed. -* `waitForTimeout` **[number][8]?** default timeout for wait* actions, in seconds. -* `waitForAction` **[number][8]?** fixed delay after each action, in milliseconds. When unset, actions wait only for the navigation they trigger. -* `pollInterval` **[number][8]?** interval between checks while waiting, in milliseconds. -* `getPageTimeout` **[number][8]?** maximum time to wait for a page to load, in seconds. -* `waitForNavigation` **[string][3]?** when a navigation is considered finished: `load`, `domcontentloaded`, or `networkidle`. +* `url` **[string][5]?** base URL for tests +* `endpoint` **[string][5]?** CDP endpoint of a Kitesurf engine you run yourself, e.g. `http://127.0.0.1:8787` for a local `pnpm run dev`. When set, the helper connects to it directly and does not use Cloudflare Browser Run. +* `headers` **[object][6]?** headers sent when connecting to `endpoint`, e.g. `CF-Access-Client-Id` and `CF-Access-Client-Secret` for an engine behind Cloudflare Access. +* `accountId` **[string][5]?** Cloudflare account ID; defaults to CF_ACCOUNT_ID env var +* `apiToken` **[string][5]?** Cloudflare API token; defaults to CF_API_TOKEN env var +* `keepAlive` **[number][10]?** session keep-alive time in milliseconds +* `apiBase` **[string][5]?** Cloudflare API base URL +* `input` **[string][5]?** how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events). +* `capabilities` **[object][6]?** browser capabilities; Kitesurf sets `{ layout: 'real', screenshot: true }`. +* `xpathPolyfill` **([string][5] | [boolean][20])?** inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed. +* `waitForTimeout` **[number][10]?** default timeout for wait* actions, in seconds. +* `waitForAction` **[number][10]?** fixed delay after each action, in milliseconds. When unset, actions wait only for the navigation they trigger. +* `pollInterval` **[number][10]?** interval between checks while waiting, in milliseconds. +* `getPageTimeout` **[number][10]?** maximum time to wait for a page to load, in seconds. +* `waitForNavigation` **[string][5]?** when a navigation is considered finished: `load`, `domcontentloaded`, or `networkidle`. @@ -107,7 +130,7 @@ I.amOnPage('/login'); // opens a login page #### Parameters -* `url` **[string][3]** url path or global url. +* `url` **[string][5]** url path or global url. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -128,9 +151,9 @@ I.appendField('name', 'John', '.form-container'); #### Parameters -* `field` **([string][3] | [object][4])** located by label|name|CSS|XPath|strict locator -* `value` **[string][3]** text value to append. -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `field` **([string][5] | [object][6])** located by label|name|CSS|XPath|strict locator +* `value` **[string][5]** text value to append. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder @@ -159,16 +182,16 @@ I.attachFile('#dropzone', 'data/avatar.jpg'); #### Parameters * `field` -* `pathToFile` **[string][3]** local file path relative to codecept.conf.ts or codecept.conf.js config file. -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. -* `locator` **([string][3] | [object][4])** field located by label|name|CSS|XPath|strict locator. +* `pathToFile` **[string][5]** local file path relative to codecept.conf.ts or codecept.conf.js config file. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `locator` **([string][5] | [object][6])** field located by label|name|CSS|XPath|strict locator. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder ### blur Remove focus from a text input, button, etc. -Calls [blur][5] on the element. +Calls [blur][7] on the element. Examples: @@ -185,8 +208,8 @@ I.dontSee('#add-to-cart-btn'); #### Parameters -* `locator` **([string][3] | [object][4])** field located by label|name|CSS|XPath|strict locator. -* `options` **any?** Playwright only: [Additional options][6] for available options object as 2nd argument. +* `locator` **([string][5] | [object][6])** field located by label|name|CSS|XPath|strict locator. +* `options` **any?** Playwright only: [Additional options][8] for available options object as 2nd argument. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder @@ -209,8 +232,8 @@ I.checkOption('agree', '//form'); #### Parameters -* `field` **([string][3] | [object][4])** checkbox located by label | name | CSS | XPath | strict locator. -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `field` **([string][5] | [object][6])** checkbox located by label | name | CSS | XPath | strict locator. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder @@ -238,7 +261,7 @@ I.clearCookie('test'); #### Parameters * `name` -* `cookie` **[string][3]?** (optional, `null` by default) cookie name +* `cookie` **[string][5]?** (optional, `null` by default) cookie name ### clearField @@ -257,8 +280,8 @@ I.clearField('Email', '.form-container'); #### Parameters * `field` -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. -* `editable` **([string][3] | [object][4])** field located by label|name|CSS|XPath|strict locator. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `editable` **([string][5] | [object][6])** field located by label|name|CSS|XPath|strict locator. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder. @@ -292,12 +315,12 @@ I.click({css: 'nav a.login'}); I.click({role: 'button', name: 'Submit'}); ``` -> ℹ️ ARIA role locators (`{role, name}`) match elements the way assistive technology does and survive markup refactors. See [Locators][7]. +> ℹ️ ARIA role locators (`{role, name}`) match elements the way assistive technology does and survive markup refactors. See [Locators][9]. #### Parameters -* `locator` **([string][3] | [object][4])** (optional, `'//body'` by default) clickable link or button located by text, or any element located by CSS|XPath|strict locator. -* `context` **([string][3]? | [object][4] | null)** (optional, `null` by default) element to search in CSS|XPath|Strict locator. +* `locator` **([string][5] | [object][6])** (optional, `'//body'` by default) clickable link or button located by text, or any element located by CSS|XPath|strict locator. +* `context` **([string][5]? | [object][6] | null)** (optional, `null` by default) element to search in CSS|XPath|Strict locator. Returns **CodeceptJS.ElementStepPromise** automatically synchronized promise through #recorder @@ -313,9 +336,9 @@ I.clickXY('#area', 50, 30); // relative to #area #### Parameters -* `locator` **([string][3] | [object][4] | [number][8])** element to click relative to, or the X page coordinate. -* `x` **[number][8]?** X coordinate relative to the element, or the Y page coordinate if `locator` is a number. -* `y` **[number][8]?** Y coordinate relative to the element. +* `locator` **([string][5] | [object][6] | [number][10])** element to click relative to, or the X page coordinate. +* `x` **[number][10]?** X coordinate relative to the element, or the Y page coordinate if `locator` is a number. +* `y` **[number][10]?** Y coordinate relative to the element. Returns **void** automatically synchronized promise through #recorder @@ -331,8 +354,8 @@ I.dontSee('Login', '.nav'); // no login inside .nav element #### Parameters -* `text` **[string][3]** which is not present. -* `context` **([string][3] | [object][4])?** (optional) element located by CSS|XPath|strict locator in which to perfrom search. +* `text` **[string][5]** which is not present. +* `context` **([string][5] | [object][6])?** (optional) element located by CSS|XPath|strict locator in which to perfrom search. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -349,7 +372,7 @@ I.dontSeeCheckboxIsChecked('agree'); // located by name #### Parameters * `locator` -* `field` **([string][3] | [object][4])** located by label|name|CSS|XPath|strict locator. +* `field` **([string][5] | [object][6])** located by label|name|CSS|XPath|strict locator. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -363,7 +386,7 @@ I.dontSeeCookie('auth'); // no auth cookie #### Parameters -* `name` **[string][3]** cookie name. +* `name` **[string][5]** cookie name. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -379,7 +402,7 @@ I.dontSeeCurrentPathEquals('/'); // fails for '/', '/?user=ok', '/#top' #### Parameters -* `path` **[string][3]** value to check. +* `path` **[string][5]** value to check. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -395,7 +418,7 @@ I.dontSeeCurrentUrlEquals('http://mysite.com/login'); // absolute urls are also #### Parameters -* `url` **[string][3]** value to check. +* `url` **[string][5]** value to check. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -412,8 +435,8 @@ I.dontSeeElement('.modal', '#container'); #### Parameters -* `locator` **([string][3] | [object][4])** located by CSS|XPath|Strict locator. -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `locator` **([string][5] | [object][6])** located by CSS|XPath|Strict locator. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -427,7 +450,7 @@ I.dontSeeElementInDOM('.nav'); // checks that element is not on page visible or #### Parameters -* `locator` **([string][3] | [object][4])** located by CSS|XPath|Strict locator. +* `locator` **([string][5] | [object][6])** located by CSS|XPath|Strict locator. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -437,7 +460,7 @@ Checks that current url does not contain a provided fragment. #### Parameters -* `url` **[string][3]** value to check. +* `url` **[string][5]** value to check. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -457,9 +480,9 @@ I.dontSeeInField('Name', 'old_value', '.form-container'); #### Parameters -* `field` **([string][3] | [object][4])** located by label|name|CSS|XPath|strict locator. -* `value` **([string][3] | [object][4])** value to check. -* `context` **([string][3]? | [object][4])** (optional, `null` by default) element located by CSS | XPath | strict locator. +* `field` **([string][5] | [object][6])** located by label|name|CSS|XPath|strict locator. +* `value` **([string][5] | [object][6])** value to check. +* `context` **([string][5]? | [object][6])** (optional, `null` by default) element located by CSS | XPath | strict locator. Returns **CodeceptJS.StepPromise** automatically synchronized promise through #recorder @@ -474,7 +497,7 @@ I.dontSeeInSource('