From f32bcb0dd72f7ca2b22bfa300b74be2b01aacd09 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 14 Oct 2026 02:10:39 +0300 Subject: [PATCH 1/2] feat(helpers): add experimental Moli CDP helper Moli (https://github.com/lexmount/moli) is a Rust headless browser with V8 and Stylo, served over CDP. The helper is a thin CDPBrowser subclass like Lightpanda: it self-launches `moli serve` on a free port, or attaches to an explicit endpoint. Moli runs without --layout on purpose: with --layout element geometry is frozen on first read and only a screenshot refreshes it, which breaks waitForVisible/waitForText. Presets pin synthetic input, layout 'real' and no screenshots, since the runtime probe would otherwise pick coordinate input that Moli rejects in this mode. - shared webapi suite: 289 passing, 50 pending; 6 tests skipped with verified reasons (clickXY, upload MIME type, link color) - 19 acceptance scenarios tagged @Moli, including gherkin features - CI workflow pinned to v1.1.17 with SHA256 check - docs: alternative browsers, installation, README, helper reference Co-Authored-By: Claude Opus 5.5 --- .github/workflows/moli.yml | 72 + Bunoshfile.js | 1 + README.md | 2 + docs/alternative-browsers.md | 57 +- docs/helpers/Moli.md | 1956 +++++++++++++++++++ docs/installation.md | 16 + lib/container.js | 2 +- lib/helper/Decision.js | 2 +- lib/helper/Moli.js | 125 ++ lib/plugin/pause.js | 2 +- package.json | 1 + test/acceptance/codecept.Moli.js | 32 + test/acceptance/config_test.js | 14 +- test/acceptance/forms_test.js | 10 +- test/acceptance/gherkin/background.feature | 2 +- test/acceptance/gherkin/basic.feature | 2 +- test/acceptance/gherkin/before_hook.feature | 2 +- test/acceptance/gherkin/examples.feature | 2 +- test/acceptance/within_test.js | 2 +- test/helper/CDPBrowser_moli_test.js | 159 ++ test/helper/webapi.js | 4 + typings/fixDefFiles.js | 1 + 22 files changed, 2445 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/moli.yml create mode 100644 docs/helpers/Moli.md create mode 100644 lib/helper/Moli.js create mode 100644 test/acceptance/codecept.Moli.js create mode 100644 test/helper/CDPBrowser_moli_test.js diff --git a/.github/workflows/moli.yml b/.github/workflows/moli.yml new file mode 100644 index 000000000..a2b442cf4 --- /dev/null +++ b/.github/workflows/moli.yml @@ -0,0 +1,72 @@ +name: Moli 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 + MOLI_VERSION: v1.1.17 + MOLI_SHA256: e338cdd2ab6e3e6262117e56aba625bf6c71c44fd72ac3870bbd9c0e6c1ec351 + +jobs: + build: + runs-on: ubuntu-22.04 + timeout-minutes: 15 + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + - name: npm install + run: npm i --force + - name: Cache Moli tarball + uses: actions/cache@v6 + with: + path: ~/moli-bin/moli.tar.gz + key: moli-${{ env.MOLI_VERSION }}-x86_64-linux + - name: Download Moli + run: | + mkdir -p ~/moli-bin + if [ ! -f ~/moli-bin/moli.tar.gz ]; then + curl -sfL --retry 3 -o ~/moli-bin/moli.tar.gz https://github.com/lexmount/moli/releases/download/${MOLI_VERSION}/moli-x86_64-unknown-linux-gnu.tar.gz + fi + echo "${MOLI_SHA256} $HOME/moli-bin/moli.tar.gz" | sha256sum -c - + tar xzf ~/moli-bin/moli.tar.gz -C ~/moli-bin --strip-components=1 + ~/moli-bin/moli --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 moli helper tests + run: | + export MOLI_PATH="$HOME/moli-bin/moli" + npm run test:unit:webbapi:moli + - name: run moli acceptance tests + run: | + export MOLI_PATH="$HOME/moli-bin/moli" + ./bin/codecept.js run -c test/acceptance/codecept.Moli.js --grep @Moli --debug diff --git a/Bunoshfile.js b/Bunoshfile.js index f3cf66ff2..2c2a0a2ae 100644 --- a/Bunoshfile.js +++ b/Bunoshfile.js @@ -400,6 +400,7 @@ const inheritedHelperDocs = { }, Obscura: { parent: 'CDPBrowser' }, Lightpanda: { parent: 'CDPBrowser' }, + Moli: { parent: 'CDPBrowser' }, Kitesurf: { parent: 'CDPBrowser', excludeConfig: ['endpoint', 'headers'] }, } diff --git a/README.md b/README.md index 663e6ae2b..0db16aae0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ | 🌐 Web | WebDriver | [![WebDriver Tests](https://github.com/codeceptjs/CodeceptJS/actions/workflows/webdriver.yml/badge.svg)](https://github.com/codeceptjs/CodeceptJS/actions/workflows/webdriver.yml) | | 🌐 Web | Obscura | [![Obscura Helper Tests](https://github.com/codeceptjs/CodeceptJS/actions/workflows/obscura.yml/badge.svg)](https://github.com/codeceptjs/CodeceptJS/actions/workflows/obscura.yml) | | 🌐 Web | Lightpanda | [![Lightpanda Helper Tests](https://github.com/codeceptjs/CodeceptJS/actions/workflows/lightpanda.yml/badge.svg)](https://github.com/codeceptjs/CodeceptJS/actions/workflows/lightpanda.yml) | +| 🌐 Web | Moli | [![Moli Helper Tests](https://github.com/codeceptjs/CodeceptJS/actions/workflows/moli.yml/badge.svg)](https://github.com/codeceptjs/CodeceptJS/actions/workflows/moli.yml) | | 📱 Mobile | Appium | [![Appium Tests - Android](https://github.com/codeceptjs/CodeceptJS/actions/workflows/appium_Android.yml/badge.svg)](https://github.com/codeceptjs/CodeceptJS/actions/workflows/appium_Android.yml) | # CodeceptJS [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://stand-with-ukraine.pp.ua) @@ -46,6 +47,7 @@ CodeceptJS uses **Helper** modules to provide actions to `I` object. Currently, - [**WebDriver**](https://github.com/codeceptjs/CodeceptJS/blob/master/docs/helpers/WebDriver.md) - uses [webdriverio](http://webdriver.io/) to run tests via WebDriver or Devtools protocol. - [**Obscura**](https://codecept.io/helpers/Obscura) - drives the lightweight Obscura browser through Chrome DevTools Protocol. See [Alternative Browser Engines](https://codecept.io/alternative-browsers). - [**Lightpanda**](https://codecept.io/helpers/Lightpanda) - drives the Lightpanda headless browser through Chrome DevTools Protocol. See [Alternative Browser Engines](https://codecept.io/alternative-browsers). +- [**Moli**](https://codecept.io/helpers/Moli) - drives the Moli headless browser through Chrome DevTools Protocol. See [Alternative Browser Engines](https://codecept.io/alternative-browsers). - [**Appium**](https://github.com/codeceptjs/CodeceptJS/blob/master/docs/helpers/Appium.md) - for **mobile testing** with Appium - [**Detox**](https://github.com/codeceptjs/CodeceptJS/blob/master/docs/helpers/Detox.md) - This is a wrapper on top of Detox library, aimed to unify testing experience for CodeceptJS framework. Detox provides a grey box testing for mobile applications, playing especially well for React Native apps. diff --git a/docs/alternative-browsers.md b/docs/alternative-browsers.md index e9f76bfe3..f903f7d5f 100644 --- a/docs/alternative-browsers.md +++ b/docs/alternative-browsers.md @@ -6,7 +6,7 @@ title: Alternative Browser Engines # Alternative Browser Engines ::: warning Experimental -The `Obscura`, `Lightpanda` and `Kitesurf` helpers are experimental. Keep a Playwright or WebDriver job for compatibility-critical tests. +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. @@ -143,6 +143,61 @@ Lightpanda: { All options: [Lightpanda helper reference](/helpers/Lightpanda). +## Moli + +[Moli](https://github.com/lexmount/moli) is an open-source headless browser built in Rust for automation. It runs your app's JavaScript in V8 and computes real CSS, but skips layout and painting, so it starts fast and stays small. Visibility checks such as `seeElement` and `waitForVisible` work. + +Limitations: + +- No screenshots: `saveScreenshot`, failure screenshots and the `screencast` plugin do not work. +- `clickXY` does not work. +- `attachFile` uploads files without a MIME type; the server receives `application/octet-stream`. +- Links have no default color: `a` computes `color: rgb(0, 0, 0)`. + +### Setup + +Install Moli following the [official instructions](https://github.com/lexmount/moli#direct-installation) and put `moli` on `PATH`. Then enable the helper: + +```js +// codecept.conf.js +export const config = { + helpers: { + Moli: { + url: 'http://localhost:3000', + }, + }, +} +``` + +If the binary is not on `PATH`, set `binaryPath` in the config or the `MOLI_PATH` environment variable. + +### Usage + +```sh +npx codeceptjs run +``` + +The helper starts `moli serve` on a free port before the first test and stops it after the last one. Each worker gets its own instance: + +```sh +npx codeceptjs run-workers 8 +``` + +To connect to a Moli you started yourself, set `endpoint`; the helper then never starts or stops it: + +```sh +moli serve --host 127.0.0.1 --port 9222 +``` + +```js +Moli: { + url: 'http://localhost:3000', + endpoint: 'http://127.0.0.1:9222', +} +``` + +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. diff --git a/docs/helpers/Moli.md b/docs/helpers/Moli.md new file mode 100644 index 000000000..d52849a8e --- /dev/null +++ b/docs/helpers/Moli.md @@ -0,0 +1,1956 @@ +--- +permalink: /helpers/Moli +editLink: false +sidebar: auto +title: Moli +--- + + + +## Moli + +**Extends CDPBrowser** + +Moli drives [Moli][1], an open-source headless browser written +in Rust for automation, exposed over the Chrome DevTools Protocol. It runs real JavaScript in V8 +and computes styles with Servo's Stylo, but by default skips layout and painting: element boxes +use deterministic placeholder geometry, so there are no screenshots and no coordinate input. + +This helper is a thin `CDPBrowser` subclass: it changes nothing about how locating or acting on +elements works, it only pins the config presets Moli requires and manages the `moli serve` +process lifecycle, the same way Playwright manages its own browser process. + +> Moli support is experimental. Pin the browser version in CI and keep a +> Playwright/WebDriver job for browser-compatibility coverage. + +## Modes + +* **ATTACH** — `endpoint` is set explicitly in the config. The helper only connects to it; it + never spawns or kills anything, no matter what `binaryPath`/`port` are set to. +* **SELF-LAUNCH** — `endpoint` is unset and a binary can be resolved, in order: `binaryPath` in + the config, then the `MOLI_PATH` environment variable, then `moli` on `PATH`. The helper + spawns `moli serve --host 127.0.0.1 --port ` (`port` from the config, or a free port + picked automatically), waits for it to answer, connects, and kills it in `_finishTest`. +* **COURTESY-ATTACH** — `endpoint` is unset and no binary can be resolved, but something already + answers `http://127.0.0.1:9222/json/version` (e.g. `moli serve` started by hand). The helper + attaches to it and never kills it. If neither a binary nor a running server on :9222 can be + found, the helper throws a loud, actionable error. + +## Install + +Follow the [official installation instructions][2], +then put `moli` on your `PATH`, or point `binaryPath`/`MOLI_PATH` at it. The helper then +launches and tears it down automatically. + +## Config presets + +These are set automatically and only need overriding for unusual setups: + +| option | value | why | +| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `input` | `synthetic` | without layout Moli rejects coordinate mouse input, so `click` always takes the `forceClick` path | +| `xpathPolyfill` | `auto` | probed per binary/page | +| `capabilities.layout` | `real` | Moli computes real styles and gives displayed elements a non-empty placeholder box, which is enough for `seeElement` and the `waitForVisible` family; it is pinned because the runtime probe would also enable coordinate input | +| `capabilities.screenshot` | `false` | screenshots require `moli serve --layout`, which this helper does not use: its layout is a snapshot that goes stale when the page changes | + +## Limitations + +* No screenshots and no screencast: `saveScreenshot` and `saveElementScreenshot` do nothing (a debug message is printed). +* No coordinate-based input (`clickXY`). +* No frames or popups. +* Uploaded files are sent as `application/octet-stream` regardless of their extension. +* The default stylesheet does not color links: `a` computes `color: rgb(0, 0, 0)`. + + + +## Configuration + +This helper should be configured in codecept.conf.js + +Type: [object][4] + +### Properties + +* `endpoint` **[string][3]?** explicit CDP endpoint. Setting this switches the helper to ATTACH + mode: it only connects, and never spawns or kills a process, no matter what else is configured. + Leave it unset for SELF-MANAGED mode (see below). +* `binaryPath` **[string][3]?** path to the `moli` executable, used in SELF-MANAGED mode + (`endpoint` unset). Checked before `MOLI_PATH` and `PATH`. +* `port` **[number][8]?** port `moli serve` listens on, in SELF-MANAGED mode. When unset, a free + port is picked automatically, which is what makes `run-workers` collision-free — every worker + gets its own instance on its own port with zero config. +* `serverStartTimeout` **[number][8]?** milliseconds to wait for a spawned `moli serve` to + answer `/json/version` before `_connect` gives up. +* `url` **[string][3]?** base url of website to be tested. +* `headers` **[object][4]?** headers sent when connecting to the endpoint, e.g. for authenticated remote browsers. +* `input` **[string][3]?** how actions are dispatched: `cdp` (real mouse and keyboard events) or `synthetic` (DOM events). `auto` picks `cdp` when the browser renders layout. +* `xpathPolyfill` **([string][3] | [boolean][18])?** inject an XPath polyfill for browsers with incomplete XPath support. `auto` detects when it is needed. +* `capabilities` **[object][4]?** browser capabilities (`layout`, `xpath`, `screenshot`, `video`, `innerText`, `aria`) to use instead of detecting them at runtime. +* `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`. + + + +## Example + +```js +// inside codecept.conf.js — SELF-LAUNCH mode (recommended): the helper finds/starts/stops +// moli serve on its own, on a free port. Ideal for run-workers: every worker gets its own +// instance with no config. +{ + helpers: { + Moli: { + url: 'http://localhost', + } + } +} +``` + +```js +// ATTACH mode — connect to a Moli instance you manage yourself (remote host, container, etc.) +{ + helpers: { + Moli: { + url: 'http://localhost', + endpoint: 'http://127.0.0.1:9222', + } + } +} +``` + +## Methods + +### Parameters + +* `config` **MoliConfig** + +### amOnPage + +Opens a web page in a browser. Requires relative or absolute url. +If url starts with `/`, opens a web page of a site defined in `url` config parameter. + +```js +I.amOnPage('/'); // opens main page of website +I.amOnPage('https://github.com'); // opens github +I.amOnPage('/login'); // opens a login page +``` + +#### Parameters + +* `url` **[string][3]** url path or global url. + +Returns **void** automatically synchronized promise through #recorder + +### appendField + +Appends text to a input field or textarea. +Field is located by name, label, CSS or XPath + +The third parameter is an optional context (CSS or XPath locator) to narrow the search. + +```js +I.appendField('#myTextField', 'appended'); +// typing secret +I.appendField('password', secret('123456')); +// within a context +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. + +Returns **void** automatically synchronized promise through #recorder + +### attachFile + +Attaches a file to element located by label, name, CSS or XPath +Path to file is relative current codecept directory (where codecept.conf.ts or codecept.conf.js is located). +File will be uploaded to remote system (if tests are running remotely). + +The third parameter is an optional context (CSS or XPath locator) to narrow the search. + +```js +I.attachFile('Avatar', 'data/avatar.jpg'); +I.attachFile('form input[name=avatar]', 'data/avatar.jpg'); +// within a context +I.attachFile('Avatar', 'data/avatar.jpg', '.form-container'); +``` + +If the locator points to a non-file-input element (e.g., a dropzone area), +the file will be dropped onto that element using drag-and-drop events. + +```js +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. + +Returns **void** automatically synchronized promise through #recorder + +### blur + +Remove focus from a text input, button, etc. +Calls [blur][5] on the element. + +Examples: + +```js +I.blur('.text-area') +``` + +```js +//element `#product-tile` is focused +I.see('#add-to-cart-btn'); +I.blur('#product-tile') +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. + +Returns **void** automatically synchronized promise through #recorder + +### capabilities + +Type: CodeceptJS.HelperCapabilities + +### checkOption + +Selects a checkbox or radio button. +Element is located by label or name or CSS or XPath. + +The second parameter is an optional context (CSS or XPath locator) to narrow the search. + +```js +I.checkOption('#agree'); +I.checkOption('I Agree to Terms and Conditions'); +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. + +Returns **void** automatically synchronized promise through #recorder + +### clearClipboard + +Clears the system clipboard. + +```js +I.clearClipboard(); +I.seeClipboardEquals(''); +``` + +Returns **void** automatically synchronized promise through #recorder + +### clearCookie + +Clears a cookie by name, +if none provided clears all cookies. + +```js +I.clearCookie(); +I.clearCookie('test'); +``` + +#### Parameters + +* `name` +* `cookie` **[string][3]?** (optional, `null` by default) cookie name + +### clearField + +Clears a `