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/.github/workflows/webdriver.yml b/.github/workflows/webdriver.yml index 031c44c7a..50e191277 100644 --- a/.github/workflows/webdriver.yml +++ b/.github/workflows/webdriver.yml @@ -59,7 +59,7 @@ jobs: SELENIUM_HOST: localhost SELENIUM_PORT: 4444 - name: run unit tests - run: ./node_modules/.bin/mocha test/helper/WebDriver_test.js --exit --reporter @testomatio/reporter/mocha + run: ./node_modules/.bin/mocha test/helper/WebDriver_test.js --exit timeout-minutes: 10 env: SELENIUM_HOST: localhost diff --git a/Bunoshfile.js b/Bunoshfile.js index f3cf66ff2..c0e1a17e1 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'] }, } @@ -715,21 +716,21 @@ export async function prettier() { * Run unit tests. */ export async function testUnit() { - await shell`mocha test/unit --recursive --timeout 10000 --reporter @testomatio/reporter/mocha` + await shell`mocha test/unit --recursive --timeout 10000` } /** * Run REST tests. */ export async function testRest() { - await shell`mocha test/rest --recursive --timeout 20000 --reporter @testomatio/reporter/mocha` + await shell`mocha test/rest --recursive --timeout 20000` } /** * Run runner tests. */ export async function testRunner() { - await shell`mocha test/runner --recursive --timeout 10000 --reporter @testomatio/reporter/mocha` + await shell`mocha test/runner --recursive --timeout 10000` } /** @@ -755,28 +756,28 @@ export async function testWithMockServer() { * Run quick Appium tests. */ export async function testAppiumQuick() { - await shell`mocha test/helper/Appium_test.js --grep 'quick' --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Appium_test.js --grep 'quick'` } /** * Run the remaining (second) Appium tests. */ export async function testAppiumOther() { - await shell`mocha test/helper/Appium_test.js --grep 'second' --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Appium_test.js --grep 'second'` } /** * Run quick iOS Appium tests. */ export async function testIosAppiumQuick() { - await shell`mocha test/helper/Appium_ios_test.js --grep 'quick' --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Appium_ios_test.js --grep 'quick'` } /** * Run the remaining (second) iOS Appium tests. */ export async function testIosAppiumOther() { - await shell`mocha test/helper/Appium_ios_test.js --grep 'second' --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Appium_ios_test.js --grep 'second'` } /** @@ -797,42 +798,42 @@ export async function testAppStop() { * Run Playwright helper unit tests. */ export async function testWebapiPlaywright() { - await shell`mocha test/helper/Playwright_test.js --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Playwright_test.js` } /** * Run Puppeteer helper unit tests. */ export async function testWebapiPuppeteer() { - await shell`mocha test/helper/Puppeteer_test.js --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Puppeteer_test.js` } /** * Run WebDriver helper unit tests. */ export async function testWebapiWebdriver() { - await shell`mocha test/helper/WebDriver_test.js --timeout 10000 --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/WebDriver_test.js --timeout 10000` } /** * Run WebDriver helper unit tests without a Selenium server. */ export async function testWebapiWebdriverNoSelenium() { - await shell`mocha test/helper/WebDriver.noSeleniumServer_test.js --timeout 10000 --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/WebDriver.noSeleniumServer_test.js --timeout 10000` } /** * Run Expect helper unit tests. */ export async function testExpect() { - await shell`mocha test/helper/Expect_test.js --reporter @testomatio/reporter/mocha` + await shell`mocha test/helper/Expect_test.js` } /** * Run plugin tests. */ export async function testPlugin() { - await shell`mocha test/plugin/plugin_test.js --reporter @testomatio/reporter/mocha` + await shell`mocha test/plugin/plugin_test.js` } /** 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 `