From f572b8e79ad2c722e17045cf2133503f7c624dc4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 26 Apr 2026 22:27:29 +0300 Subject: [PATCH 1/5] update docs --- docs/ai.md | 121 ---------- docs/auth.md | 318 +++++++++++++++++++++++++ docs/debugging.md | 32 +++ docs/helpers/AI.md | 102 -------- docs/helpers/OpenAI.md | 70 ------ docs/pageobjects.md | 2 - docs/probe.html | 21 ++ docs/webapi/seeFileDownloaded.mustache | 23 ++ 8 files changed, 394 insertions(+), 295 deletions(-) create mode 100644 docs/auth.md delete mode 100644 docs/helpers/AI.md delete mode 100644 docs/helpers/OpenAI.md create mode 100644 docs/probe.html create mode 100644 docs/webapi/seeFileDownloaded.mustache diff --git a/docs/ai.md b/docs/ai.md index 15a2b0672..1ed707bac 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -22,9 +22,7 @@ So, instead of asking "write me a test" it can ask "write a test for **this** pa CodeceptJS AI can do the following: - ๐Ÿ‹๏ธโ€โ™€๏ธ **assist writing tests** in `pause()` or interactive shell mode -- ๐Ÿ“ƒ **generate page objects** in `pause()` or interactive shell mode - ๐Ÿš‘ **self-heal failing tests** (can be used on CI) -- ๐Ÿ’ฌ send arbitrary prompts to AI provider from any tested page attaching its HTML contents ![](/img/fill_form.gif) @@ -385,125 +383,6 @@ Run tests with both AI and analyze enabled: npx codeceptjs run --ai ``` -## Arbitrary Prompts - -What if you want to take AI on the journey of test automation and ask it questions while browsing pages? - -This is possible with the new `AI` helper. Enable it in your config file in `helpers` section: - -```js -// inside codecept.conf -helpers: { - // Playwright, Puppeteer, or WebDrver helper should be enabled too - Playwright: { - }, - - AI: {} -} -``` - -AI helper will be automatically attached to Playwright, WebDriver, or another web helper you use. It includes the following methods: - -- `askGptOnPage` - sends GPT prompt attaching the HTML of the page. Large pages will be split into chunks, according to `chunkSize` config. You will receive responses for all chunks. -- `askGptOnPageFragment` - sends GPT prompt attaching the HTML of the specific element. This method is recommended over `askGptOnPage` as you can reduce the amount of data to be processed. -- `askGptGeneralPrompt` - sends GPT prompt without HTML. -- `askForPageObject` - creates PageObject for you, explained in next section. - -`askGpt` methods won't remove non-interactive elements, so it is recommended to manually control the size of the sent HTML. - -Here are some good use cases for this helper: - -- get page summaries -- inside pause mode navigate through your application and ask to document pages -- etc... - -```js -// use it inside test or inside interactive pause -// pretend you are technical writer asking for documentation -const pageDoc = await I.askGptOnPageFragment('Act as technical writer, describe what is this page for', '#container') -``` - -As of now, those use cases do not apply to test automation but maybe you can apply them to your testing setup. - -## Generate PageObjects - -Last but not the least. AI helper can be used to quickly prototype PageObjects on pages browsed within interactive session. - -![](/img/ai_page_object.png) - -Enable AI helper as explained in previous section and launch shell: - -``` -npx codeceptjs shell --ai -``` - -Also this is availble from `pause()` if AI helper is enabled, - -Ensure that browser is started in window mode, then browse the web pages on your site. -On a page you want to create PageObject execute `askForPageObject()` command. The only required parameter is the name of a page: - -```js -I.askForPageObject('login') -``` - -This command sends request to AI provider should create valid CodeceptJS PageObject. -Run it few times or switch AI provider if response is not satisfactory to you. - -> You can change the style of PageObject and locator preferences by adjusting prompt in a config file - -When completed successfully, page object is saved to **output** directory and loaded into the shell as `page` variable so locators and methods can be checked on the fly. - -If page object has `signInButton` locator you can quickly check it by typing: - -```js -I.click(page.signInButton) -``` - -If page object has `clickForgotPassword` method you can execute it as: - -```js -=> page.clickForgotPassword() -``` - -Here is an example of a session: - -```shell -Page object for login is saved to .../output/loginPage-1718579784751.js -Page object registered for this session as `page` variable -Use `=>page.methodName()` in shell to run methods of page object -Use `click(page.locatorName)` to check locators of page object - - I.=>page.clickSignUp() - I.click(page.signUpLink) - I.=> page.enterPassword('asdasd') - I.=> page.clickSignIn() -``` - -You can improve prompt by passing custom request as a second parameter: - -```js -I.askForPageObject('login', 'implement signIn(username, password) method') -``` - -To generate page object for the part of a page, pass in root locator as third parameter. - -```js -I.askForPageObject('login', '', '#auth') -``` - -In this case, all generated locators, will use `#auth` as their root element. - -Don't aim for perfect PageObjects but find a good enough one, which you can use for writing your tests. -All created page objects are considered temporary, that's why saved to `output` directory. - -Rename created PageObject to remove timestamp and move it from `output` to `pages` folder and include it into codecept.conf file: - -```js - include: { - loginPage: "./pages/loginPage.js", - // ... -``` - ## Advanced Configuration AI prompts and HTML compression can be configured inside `ai` section of `codecept.conf` file: diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 000000000..38de6b0ae --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,318 @@ +--- +permalink: /auth +title: Authorization +--- + +# Authorization + +The `auth` plugin logs a user in once and reuses that session for every test that follows. After the first login it stores the cookies (in memory or in a file) and replays them on later tests. If the session expires, the plugin notices and logs in again. + +## Quick Start + +Enable the plugin in `codecept.conf.js` and define one user with `login` and `check` functions: + +```js +plugins: { + auth: { + enabled: true, + users: { + admin: { + login: (I) => { + I.amOnPage('/login') + I.fillField('email', 'admin@site.com') + I.fillField('password', secret('123456')) + I.click('Sign in') + }, + check: (I) => { + I.amOnPage('/') + I.see('Admin', '.navbar') + }, + }, + }, + }, +} +``` + +Inject `login` into a test and call it with the user name: + +```js +Feature('Dashboard') + +Before(({ login }) => { + login('admin') +}) + +Scenario('admin sees the dashboard', ({ I }) => { + I.amOnPage('/dashboard') + I.see('Welcome, Admin') +}) +``` + +## How It Works + +When you call `login('admin')`: + +1. **`restore`** opens a page and applies the saved cookies. +2. **`check`** verifies the user is signed in. If it throws or fails an assertion, the plugin assumes the session is dead. +3. **`login`** runs the sign-in flow when `restore` + `check` fail (or no cookies exist yet). +4. **`fetch`** reads the new cookies and stores them for the next test. + +Defaults cover the common case: `fetch` calls `I.grabCookie()`, `restore` calls `I.amOnPage('/')` then `I.setCookie(cookies)`, and `check` is a no-op. Override any of them when your app needs something different. + +## Configuration + +| Option | Default | Purpose | +| ------------ | --------- | -------------------------------------------------------- | +| `users` | โ€” | Map of session names to user definitions. | +| `inject` | `'login'` | Name of the function injected into tests. | +| `saveToFile` | `false` | Write cookies to `/_session.json`. | + +Each user accepts four functions: + +- `login(I)` โ€” sign-in flow. Required. +- `check(I, session)` โ€” verify the session is still valid. Throw to force a re-login. +- `fetch(I)` โ€” return the cookies (or token) to store. Defaults to `I.grabCookie()`. +- `restore(I, session)` โ€” replay the stored session. Defaults to `I.amOnPage('/')` + `I.setCookie()`. + +## When to Log In: `Before` vs `BeforeSuite` + +You can call `login()` in either hook. Pick based on how many users a suite touches. + +### `Before` โ€” one login per test + +The default and the safe choice. Use it whenever a suite mixes users, or when you are not on Playwright. + +```js +Feature('Mixed users') + +Scenario('admin can ban a user', ({ I, login }) => { + login('admin') + I.amOnPage('/users/42') + I.click('Ban') +}) + +Scenario('regular user cannot see the ban button', ({ I, login }) => { + login('user') + I.amOnPage('/users/42') + I.dontSee('Ban') +}) +``` + +When the user changes between tests, the plugin clears the previous user's cookies before applying the new ones. + +### `BeforeSuite` โ€” one login per suite (Playwright only) + +Calling `login()` from `BeforeSuite` lets Playwright load cookies *before* it opens the browser, which removes the extra navigation that `restore` would otherwise need. Use this only when every test in the suite runs as the same user. + +```js +Feature('Admin reports') + +BeforeSuite(({ login }) => { + login('admin') +}) + +Scenario('export sales report', ({ I }) => { + I.amOnPage('/reports/sales') + I.click('Export') +}) + +Scenario('export traffic report', ({ I }) => { + I.amOnPage('/reports/traffic') + I.click('Export') +}) +``` + +> โš  If a test inside the suite calls `login()` with a different user, the plugin resets the cookies and signs in again. That cancels the speed-up. When the suite needs more than one user, prefer `Before`. + +## Persisting Sessions to a File + +Set `saveToFile: true` to keep sessions across test runs. The plugin writes one JSON file per user into the output directory and reloads them on the next start. + +```js +plugins: { + auth: { + enabled: true, + saveToFile: true, + users: { admin: { login: (I) => I.loginAsAdmin() } }, + }, +} +``` + +This is most useful while writing tests: you log in once, then iterate without paying the sign-in cost on every run. Delete the JSON file (or let it expire on the server) to force a fresh login. + +## Examples + +### Reuse a `steps_file.js` helper + +Move the sign-in flow into a custom step and call it from the plugin: + +```js +plugins: { + auth: { + enabled: true, + saveToFile: true, + users: { + admin: { + login: (I) => I.loginAdmin(), + check: (I) => { + I.amOnPage('/') + I.see('Admin') + }, + }, + }, + }, +} +``` + +### Multiple users with a custom inject name + +Rename the injected function to `loginAs` for readability: + +```js +plugins: { + auth: { + enabled: true, + inject: 'loginAs', + users: { + user: { + login: (I) => { + I.amOnPage('/login') + I.fillField('email', 'user@site.com') + I.fillField('password', secret('123456')) + I.click('Login') + }, + check: (I) => I.see('User', '.navbar'), + }, + admin: { + login: (I) => { + I.amOnPage('/login') + I.fillField('email', 'admin@site.com') + I.fillField('password', secret('123456')) + I.click('Login') + }, + check: (I) => I.see('Admin', '.navbar'), + }, + }, + }, +} +``` + +Inside a test: + +```js +Before(({ loginAs }) => loginAs('user')) +``` + +### Let the helper keep cookies, skip `fetch`/`restore` + +If your helper already keeps cookies between tests (e.g. WebDriver's `keepCookies: true`), disable `fetch` and `restore` so the plugin only handles the first login: + +```js +helpers: { + WebDriver: { keepCookies: true }, +}, +plugins: { + auth: { + enabled: true, + users: { + admin: { + login: (I) => { + I.amOnPage('/login') + I.fillField('email', 'admin@site.com') + I.fillField('password', secret('123456')) + I.click('Login') + }, + check: (I) => { + I.amOnPage('/dashboard') + I.see('Admin', '.navbar') + }, + fetch: () => {}, + restore: () => {}, + }, + }, + }, +} +``` + +### Sessions stored in local storage + +Override `fetch` and `restore` to read and write a token instead of cookies: + +```js +plugins: { + auth: { + enabled: true, + users: { + admin: { + login: (I) => I.loginAsAdmin(), + check: (I) => I.see('Admin', '.navbar'), + fetch: (I) => I.executeScript(() => localStorage.getItem('session_id')), + restore: (I, session) => { + I.amOnPage('/') + I.executeScript((s) => localStorage.setItem('session_id', s), session) + }, + }, + }, + }, +} +``` + +### Async login + +When `login`, `check`, `restore`, or `fetch` is `async`, the plugin awaits it. Inside your test, `await` the injected function: + +```js +plugins: { + auth: { + enabled: true, + users: { + admin: { + login: async (I) => { + const phrase = await I.grabTextFrom('#phrase') + I.fillField('username', 'admin') + I.fillField('password', secret('password')) + I.fillField('phrase', phrase) + }, + check: (I) => { + I.amOnPage('/') + I.see('Admin') + }, + }, + }, + }, +} +``` + +```js +Scenario('login', async ({ login }) => { + await login('admin') +}) +``` + +### Validate the session inside `check` + +`check` receives the value returned by `fetch` as its second argument. Throw from `check` to force a fresh login: + +```js +plugins: { + auth: { + enabled: true, + users: { + admin: { + login: (I) => I.loginAsAdmin(), + check: (I, session) => { + if (session.profile.email !== 'admin@site.com') { + throw new Error('Wrong user signed in') + } + }, + }, + }, + }, +} +``` + +## Tips + +- **Force a re-login** by throwing inside `check` โ€” the plugin treats it as an expired session and runs `login` again. +- **Mask credentials** with `secret()` so passwords never appear in the test output. See [Secrets](/secrets). +- **Switch users mid-test** with `session()` when one scenario needs two browsers signed in as different users. See [Multiple Sessions](/sessions). diff --git a/docs/debugging.md b/docs/debugging.md index 00c8c87fc..81b8cd66a 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -166,6 +166,38 @@ npx codeceptjs run -p pauseOn:url:/checkout/* This is useful when you want to inspect a specific page regardless of which test step navigates there. +## Browser Control + +For ad-hoc overrides of browser helper config without editing `codecept.conf`, use the `browser` plugin via `-p`. Works for Playwright, Puppeteer, WebDriver and Appium in one call. + +Force a visible browser: + +```bash +npx codeceptjs run -p browser:show +``` + +Force headless (also injects `--headless` into WebDriver chrome/firefox capability args): + +```bash +npx codeceptjs run -p browser:hide +``` + +Switch the browser engine for Playwright / Puppeteer / WebDriver / TestCafe in one shot โ€” no per-helper config gymnastics: + +```bash +npx codeceptjs run -p browser:browser=firefox +npx codeceptjs run -p browser:browser=webkit:hide +``` + +Pass any other helper config as `key=value`. Values are coerced (`true`/`false` โ†’ boolean, digits โ†’ Number, otherwise string). Tokens are colon-chained on a single `-p`: + +```bash +npx codeceptjs run -p browser:windowSize=1024x768:video=false +npx codeceptjs run -p browser:hide:video=true +``` + +`browser=` routes through `setBrowser` (so Puppeteer correctly receives `product`, Playwright receives `browser`, etc.); `windowSize=WxH` routes through `setWindowSize` (which also injects `--window-size=W,H` into chromium/chrome args). Anything else is shallow-merged onto every browser helper present in config. + ## IDE Debugging ### VS Code diff --git a/docs/helpers/AI.md b/docs/helpers/AI.md deleted file mode 100644 index 96e0dc607..000000000 --- a/docs/helpers/AI.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -permalink: /helpers/AI -editLink: false -sidebar: auto -title: AI ---- - - - -## AI - -**Extends Helper** - -AI Helper for CodeceptJS. - -This helper class provides integration with the AI GPT-3.5 or 4 language model for generating responses to questions or prompts within the context of web pages. It allows you to interact with the GPT-3.5 model to obtain intelligent responses based on HTML fragments or general prompts. -This helper should be enabled with any web helpers like Playwright or Puppeteer or WebDriver to ensure the HTML context is available. - -Use it only in development mode. It is recommended to run it only inside pause() mode. - -## Configuration - -This helper should be configured in codecept.conf.{js|ts} - -* `chunkSize`: - The maximum number of characters to send to the AI API at once. We split HTML fragments by 8000 chars to not exceed token limit. Increase this value if you use GPT-4. - -### Parameters - -* `config` - -### askForPageObject - -Generates PageObject for current page using AI. - -It saves the PageObject to the output directory. You can review the page object and adjust it as needed and move to pages directory. -Prompt can be customized in a global config file. - -```js -// create page object for whole page -I.askForPageObject('home'); - -// create page object with extra prompt -I.askForPageObject('home', 'implement signIn(username, password) method'); - -// create page object for a specific element -I.askForPageObject('home', null, '.detail'); -``` - -Asks for a page object based on the provided page name, locator, and extra prompt. - -#### Parameters - -* `pageName` **[string][1]** The name of the page to retrieve the object for. -* `extraPrompt` **([string][1] | null)** An optional extra prompt for additional context or information. -* `locator` **([string][1] | null)** An optional locator to find a specific element on the page. - -Returns **[Promise][2]<[Object][3]>** A promise that resolves to the requested page object. - -### askGptGeneralPrompt - -Send a general request to AI and return response. - -#### Parameters - -* `prompt` **[string][1]** - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated response from the GPT model. - -### askGptOnPage - -Asks the AI GPT language model a question based on the provided prompt within the context of the current page's HTML. - -```js -I.askGptOnPage('what does this page do?'); -``` - -#### Parameters - -* `prompt` **[string][1]** The question or prompt to ask the GPT model. - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated responses from the GPT model, joined by newlines. - -### askGptOnPageFragment - -Asks the AI a question based on the provided prompt within the context of a specific HTML fragment on the current page. - -```js -I.askGptOnPageFragment('describe features of this screen', '.screen'); -``` - -#### Parameters - -* `prompt` **[string][1]** The question or prompt to ask the GPT-3.5 model. -* `locator` **[string][1]** The locator or selector used to identify the HTML fragment on the page. - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated response from the GPT model. - -[1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String - -[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise - -[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object diff --git a/docs/helpers/OpenAI.md b/docs/helpers/OpenAI.md deleted file mode 100644 index 35a5e9406..000000000 --- a/docs/helpers/OpenAI.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -permalink: /helpers/OpenAI -editLink: false -sidebar: auto -title: OpenAI ---- - - - -## OpenAI - -**Extends Helper** - -OpenAI Helper for CodeceptJS. - -This helper class provides integration with the OpenAI GPT-3.5 or 4 language model for generating responses to questions or prompts within the context of web pages. It allows you to interact with the GPT-3.5 model to obtain intelligent responses based on HTML fragments or general prompts. -This helper should be enabled with any web helpers like Playwright or Puppeteer or WebDrvier to ensure the HTML context is available. - -## Configuration - -This helper should be configured in codecept.json or codecept.conf.js - -- `chunkSize`: - The maximum number of characters to send to the OpenAI API at once. We split HTML fragments by 8000 chars to not exceed token limit. Increase this value if you use GPT-4. - -### Parameters - -- `config` - -### askGptGeneralPrompt - -Send a general request to ChatGPT and return response. - -#### Parameters - -- `prompt` **[string][1]** - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated response from the GPT model. - -### askGptOnPage - -Asks the OpenAI GPT language model a question based on the provided prompt within the context of the current page's HTML. - -```js -I.askGptOnPage('what does this page do?'); -``` - -#### Parameters - -- `prompt` **[string][1]** The question or prompt to ask the GPT model. - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated responses from the GPT model, joined by newlines. - -### askGptOnPageFragment - -Asks the OpenAI GPT-3.5 language model a question based on the provided prompt within the context of a specific HTML fragment on the current page. - -```js -I.askGptOnPageFragment('describe features of this screen', '.screen'); -``` - -#### Parameters - -- `prompt` **[string][1]** The question or prompt to ask the GPT-3.5 model. -- `locator` **[string][1]** The locator or selector used to identify the HTML fragment on the page. - -Returns **[Promise][2]<[string][1]>** A Promise that resolves to the generated response from the GPT model. - -[1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String - -[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise diff --git a/docs/pageobjects.md b/docs/pageobjects.md index c82723e47..809b34c63 100644 --- a/docs/pageobjects.md +++ b/docs/pageobjects.md @@ -55,8 +55,6 @@ export default function() { ## PageObject -> CodeceptJS can [generate PageObjects using AI](/ai#generate-pageobjects). It fetches all interactive elements from a page, generates locators and methods page and writes JS code. Generated page object can be tested on the fly within the same browser session. - If an application has different pages (login, admin, etc) you should use a page object. CodeceptJS can generate a template for it with the following command: diff --git a/docs/probe.html b/docs/probe.html new file mode 100644 index 000000000..b22b06a51 --- /dev/null +++ b/docs/probe.html @@ -0,0 +1,21 @@ + + + + +

+
diff --git a/docs/webapi/seeFileDownloaded.mustache b/docs/webapi/seeFileDownloaded.mustache
new file mode 100644
index 000000000..63d61d16e
--- /dev/null
+++ b/docs/webapi/seeFileDownloaded.mustache
@@ -0,0 +1,23 @@
+Checks that a file was downloaded during the current test.
+Downloads are automatically saved to `output/downloads`.
+
+Can be called with different arguments:
+
+- **No argument** โ€” asserts that at least one file was downloaded.
+- **Number** โ€” asserts that exactly N files were downloaded.
+- **String** โ€” asserts that a file with the exact name was downloaded.
+- **Glob pattern** (contains `*`, `?`, `[`) โ€” asserts that a file matching the pattern was downloaded.
+- **Regex string** (`/pattern/`) โ€” asserts that a file matching the regex was downloaded.
+
+```js
+I.click('Download');
+I.seeFileDownloaded();
+
+I.seeFileDownloaded('report.pdf');
+I.seeFileDownloaded(2);
+I.seeFileDownloaded('*.pdf');
+I.seeFileDownloaded('/report-.+\\.pdf/');
+```
+
+@param {string|number} [arg] filename, number of files, glob pattern, or regex string.
+@returns {void} automatically synchronized promise through #recorder

From 1ae964b555fdad16e4d6e979e1e0b893bc3a8d07 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Mon, 27 Apr 2026 01:13:48 +0300
Subject: [PATCH 2/5] updated docs, added browser plugin

---
 .gitignore                       |   3 +-
 docs/ai.md                       |   8 +-
 docs/installation.md             |   2 +-
 docs/migration-4.md              | 450 +++++++++++++++++++++++++++++++
 docs/probe.html                  |  21 --
 docs/quickstart.md               | 118 +++-----
 lib/config.js                    |  18 ++
 lib/container.js                 |  37 ++-
 lib/helper/AI.js                 | 214 ---------------
 lib/plugin/browser.js            | 173 ++++++++++++
 package.json                     |   6 +-
 test/unit/plugin/browser_test.js | 140 ++++++++++
 typings/index.d.ts               |   3 -
 13 files changed, 869 insertions(+), 324 deletions(-)
 create mode 100644 docs/migration-4.md
 delete mode 100644 docs/probe.html
 delete mode 100644 lib/helper/AI.js
 create mode 100644 lib/plugin/browser.js
 create mode 100644 test/unit/plugin/browser_test.js

diff --git a/.gitignore b/.gitignore
index 1439146bc..dd3afd1f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,5 @@ yarn.lock
 /.vs
 typings/types.d.ts
 typings/promiseBasedTypes.d.ts
-reflection/
\ No newline at end of file
+reflection/
+skills/
diff --git a/docs/ai.md b/docs/ai.md
index 1ed707bac..6471de539 100644
--- a/docs/ai.md
+++ b/docs/ai.md
@@ -58,7 +58,7 @@ import { openai } from '@ai-sdk/openai'
 export default {
   // ... other config
   ai: {
-    model: openai('gpt-4o-mini'),
+    model: openai('gpt-5'),
   },
 }
 ```
@@ -92,7 +92,7 @@ import { openai } from '@ai-sdk/openai'
 
 export default {
   ai: {
-    model: openai('gpt-4o-mini'),
+    model: openai('gpt-5'),
     // or use gpt-4o, gpt-3.5-turbo, etc.
   },
 }
@@ -119,8 +119,8 @@ import { anthropic } from '@ai-sdk/anthropic'
 
 export default {
   ai: {
-    model: anthropic('claude-3-5-sonnet-20241022'),
-    // or use claude-3-opus-20240229, claude-3-haiku-20240307, etc.
+    model: anthropic('claude-sonnet-4-6'),
+    // or use claude-opus-4-7, claude-haiku-4-5, etc.
   },
 }
 ```
diff --git a/docs/installation.md b/docs/installation.md
index de9098aa7..318e412e6 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -64,7 +64,7 @@ CodeceptJS v4.x supports ECMAScript Modules (ESM) format. To use ESM:
 1. Add `"type": "module"` to your `package.json`
 2. Update import syntax in configuration files to use ESM format
 
-For detailed migration instructions and important behavioral changes, see the **[ESM Migration Guide](esm-migration.md)**.
+For detailed migration instructions and important behavioral changes, see the **[3.x โ†’ 4.x Migration Guide](migration-4.md)**.
 
 ## WebDriver
 
diff --git a/docs/migration-4.md b/docs/migration-4.md
new file mode 100644
index 000000000..fa354c4c1
--- /dev/null
+++ b/docs/migration-4.md
@@ -0,0 +1,450 @@
+---
+permalink: /migration-4
+title: Migrating from 3.x to 4.x
+---
+
+# Migrating from 3.x to 4.x
+
+CodeceptJS 4.x is a major release. It moves the codebase from CommonJS to native ESM, drops several long-deprecated helpers and plugins, replaces legacy plugins with first-class APIs, and bumps most third-party dependencies.
+
+This guide tells you exactly what to change in your project to upgrade.
+
+## 1. Update Node and Package
+
+CodeceptJS 4.x supports Node 16+, but Node 20 or newer is recommended.
+
+```bash
+npm install codeceptjs@4
+```
+
+If you write tests in TypeScript, install `tsx`:
+
+```bash
+npm install --save-dev tsx
+```
+
+> 4.x replaces `ts-node/esm` with `tsx`. `ts-node/esm` is no longer recommended and emits a warning.
+
+## 2. Switch Your Project to ESM
+
+CodeceptJS 4.x ships as native ESM (`"type": "module"`). **Convert your project to ESM**.
+Add to your `package.json`:
+
+```json
+{
+  "type": "module"
+}
+```
+
+Then convert your config, page objects, and custom helpers to ESM (sections below).
+
+
+### Convert Custom Helpers
+
+3.x:
+
+```js
+const Helper = require('@codeceptjs/helper')
+
+class MyHelper extends Helper {
+  doSomething() { /* ... */ }
+}
+
+module.exports = MyHelper
+```
+
+4.x:
+
+```js
+import Helper from '@codeceptjs/helper'
+
+class MyHelper extends Helper {
+  doSomething() { /* ... */ }
+}
+
+export default MyHelper
+```
+
+### Convert Page Objects
+
+Replace `module.exports = { ... }` with `export default { ... }`.
+
+Page objects gain new lifecycle hooks in 4.x: `_before`, `_after`, `_afterSuite`. They run automatically around suites that include the page object.
+
+### Convert Programmatic Usage
+
+3.x:
+
+```js
+const { codecept, container, event } = require('codeceptjs')
+```
+
+4.x:
+
+```js
+import codeceptjs, { container, event } from 'codeceptjs'
+```
+
+`Container.create()` and `Config.load()` are now **async**. Await them:
+
+```js
+const config = await Config.load('./codecept.conf.js')
+await Container.create(config, opts)
+```
+
+## 3. Remove Helpers That No Longer Exist
+
+| Removed helper | What to do |
+|----------------|------------|
+| `Nightmare` | Switch to `Playwright`, `Puppeteer`, or `WebDriver`. |
+| `Protractor` | Switch to `Playwright` or `WebDriver`. |
+| `TestCafe` | Switch to `Playwright`. |
+| `AI` | Use the top-level `ai:` config option and the new `aiTrace` plugin. |
+
+`Container.STANDARD_ACTING_HELPERS` no longer lists `TestCafe`.
+
+## 4. Replace or Remove Plugins
+
+| Removed plugin | Replacement |
+|----------------|-------------|
+| `autoLogin` | **`auth` plugin** โ€” see [Authorization](/auth). |
+| `tryTo` | `import { tryTo } from 'codeceptjs/effects'` |
+| `retryTo` | `import { retryTo } from 'codeceptjs/effects'` |
+| `eachElement` | `import { eachElement } from 'codeceptjs/els'` |
+| `commentStep` | `import step from 'codeceptjs/steps'` then `step.section('name')` / `step.endSection()` |
+| `fakerTransform` | Import `@faker-js/faker` directly in tests. |
+| `enhancedRetryFailedStep` | Merged into `retryFailedStep`. Rename in config. |
+| `allure` | Use [@testomatio/reporter](https://testomat.io) or Mochawesome. |
+| `htmlReporter` | Use an external reporter. |
+| `wdio` | Configure WebdriverIO services directly in `helpers.WebDriver`. |
+| `selenoid` | Run Selenoid externally. |
+| `standardActingHelpers` | No longer needed; the list lives in core. |
+
+### `autoLogin` โ†’ `auth`
+
+3.x:
+
+```js
+plugins: {
+  autoLogin: {
+    enabled: true,
+    saveToFile: true,
+    inject: 'login',
+    users: { admin: { login, check, fetch } },
+  },
+}
+```
+
+4.x:
+
+```js
+plugins: {
+  auth: {
+    enabled: true,
+    users: {
+      admin: {
+        login: (I) => { /* ... */ },
+        check: (I) => { /* ... */ },
+      },
+    },
+  },
+}
+```
+
+Inject `login` and call `login('admin')` โ€” same as before.
+
+### New Plugins You Can Enable
+
+- **`aiTrace`** โ€” captures failure traces (DOM, console, network, screenshots) for AI debugging. See [AI Trace](/aitrace).
+- **`pauseOn`** โ€” pauses execution on a chosen event or on failure. See [Debugging](/debugging).
+
+## 5. Update Removed and Changed APIs
+
+### AI Config Now Uses Vercel AI SDK
+
+3.x required a hand-written `request` function that called your provider's SDK directly. 4.x replaces this with [Vercel AI SDK](https://ai-sdk.dev) โ€” pass a `model` and CodeceptJS handles the calls.
+
+Install the SDK and the provider package you want:
+
+```bash
+npm install ai @ai-sdk/openai
+# or @ai-sdk/anthropic, @ai-sdk/google, @ai-sdk/mistral, @ai-sdk/groq, @ai-sdk/xai, @ai-sdk/azure, @ai-sdk/cohere
+```
+
+3.x:
+
+```js
+ai: {
+  request: async messages => {
+    const OpenAI = require('openai')
+    const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
+    const completion = await openai.chat.completions.create({
+      model: 'gpt-3.5-turbo',
+      messages,
+    })
+    return completion?.choices[0]?.message?.content
+  },
+}
+```
+
+4.x:
+
+```js
+import { openai } from '@ai-sdk/openai'
+
+export default {
+  ai: {
+    model: openai('gpt-5'),
+  },
+}
+```
+
+The same shape works for every supported provider โ€” swap `openai('gpt-5')` for `anthropic('claude-sonnet-4-6')`, `google('gemini-1.5-flash')`, etc. API keys still come from environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, ...).
+
+The `request` function is no longer supported. Delete it from your config.
+
+See [Testing with AI](/ai) for the full provider list and prompt customization.
+
+### JSON Schema Validation: Joi โ†’ Zod
+
+`I.seeResponseMatchesJsonSchema()` (from the `JSONResponse` helper) now validates with [Zod](https://zod.dev) instead of [Joi](https://joi.dev). Joi is gone from the dependency tree; Zod is bundled.
+
+Rewrite your schemas:
+
+3.x:
+
+```js
+const Joi = require('joi')
+
+I.seeResponseMatchesJsonSchema(Joi.object().keys({
+  name: Joi.string().required(),
+  email: Joi.string().email().required(),
+  age: Joi.number().integer().min(0),
+}))
+```
+
+4.x:
+
+```js
+import { z } from 'zod'
+
+I.seeResponseMatchesJsonSchema(z.object({
+  name: z.string(),
+  email: z.string().email(),
+  age: z.number().int().min(0),
+}))
+```
+
+Or pass a callback that receives `z`:
+
+```js
+I.seeResponseMatchesJsonSchema(z => z.object({
+  name: z.string(),
+  id: z.number(),
+}))
+```
+
+Common rewrites:
+
+| Joi | Zod |
+|-----|-----|
+| `Joi.object().keys({...})` | `z.object({...})` |
+| `Joi.string().required()` | `z.string()` (required by default) |
+| `Joi.string().email()` | `z.string().email()` |
+| `Joi.number().integer()` | `z.number().int()` |
+| `Joi.array().items(...)` | `z.array(...)` |
+| `Joi.string().optional()` | `z.string().optional()` |
+| `Joi.date()` | `z.string().datetime()` or `z.date()` |
+| `Joi.alternatives().try(a, b)` | `z.union([a, b])` |
+
+Uninstall `joi` from your project if you only used it for CodeceptJS schemas:
+
+```bash
+npm uninstall joi
+```
+
+### `restart: 'browser'` removed (Playwright)
+
+Use one of:
+
+- `restart: 'session'` โ€” reset session per test (default)
+- `restart: 'context'` โ€” new browser context per test
+- `restart: 'keep'` โ€” keep one browser across tests
+
+### Custom Locator Strategy removed (Playwright)
+
+The `customLocators` strategy registration in Playwright config is removed. Use the `customLocator` plugin or built-in ARIA locators (`{ role: 'button', name: 'Submit' }`).
+
+### `I.retry()` is deprecated
+
+Use the step options API:
+
+```js
+import step from 'codeceptjs/steps'
+
+I.click('Submit', step.retry(3))
+I.fillField('Email', 'a@b.c', step.timeout(10))
+I.click('Add', step.opts({ elementIndex: 2 }))
+```
+
+### Effects and Assertions Are Subpath Imports
+
+```js
+import { within, tryTo, retryTo, hopeThat } from 'codeceptjs/effects'
+import { hopeThat } from 'codeceptjs/assertions'
+import { eachElement, element, expectElement } from 'codeceptjs/els'
+import step from 'codeceptjs/steps'
+import store from 'codeceptjs/store'
+```
+
+`tryTo` and `hopeThat` now return `Promise`. The 3.x generic `Promise` signature is gone.
+
+`hopeThat.noErrors()` is new โ€” call it once at the end of a scenario to fail the test if any soft assertion failed.
+
+### Globals Are Deprecated โ€” `noGlobals: true` Is the New Default
+
+Up to 3.x, almost everything was global: `Feature`, `Scenario`, `Before`, `pause`, `within`, `session`, `secret`, `Helper`, `actor`, `inject`, `share`, `locate`, `DataTable`, `Given`/`When`/`Then`, `codecept_dir`, `output_dir`.
+
+In 4.x:
+
+- `npx codeceptjs init` writes `noGlobals: true` into new configs.
+- Projects without `noGlobals` set keep the old behavior but print a deprecation warning on every run:
+
+  > Global functions are deprecated. Use `import { Helper, pause, within, session } from "codeceptjs"` instead. Set `noGlobals: true` in config to disable globals.
+
+To silence the warning, set `noGlobals: true`:
+
+```js
+// codecept.conf.js
+export const config = {
+  noGlobals: true,
+  // ...
+}
+```
+
+What changes when `noGlobals: true`:
+
+| Symbol | With `noGlobals: true` |
+|--------|------------------------|
+| `Feature`, `Scenario`, `xFeature`, `xScenario`, `BeforeSuite`, `AfterSuite`, `Before`, `After`, `Background`, `BeforeAll`, `AfterAll` | **Still work in test files** โ€” Mocha injects these into the test context. No import needed. |
+| `inject()`, `share()` | **Still global.** No package export โ€” keep using them as globals. |
+| `codecept_dir`, `output_dir` | **Still global** (kept for backward compatibility with external plugins). |
+| `pause`, `within`, `session`, `secret`, `locate`, `dataTable`, `actor`, `codeceptjs` | Import from `codeceptjs`. |
+| `Helper` (base class) | Import from `@codeceptjs/helper`. |
+| `Given`, `When`, `Then`, `And`, `DefineParameterType` (BDD step definitions) | Available as globals **inside Gherkin step definition files** (CodeceptJS scope-injects them while loading the step files). No import needed. |
+
+Imports for the new style:
+
+```js
+import { pause, within, session, secret, locate, dataTable, actor } from 'codeceptjs'
+import Helper from '@codeceptjs/helper'
+```
+
+Test files written for 3.x keep working until you flip the flag.
+
+### `wait*` Methods Resolve Relative URLs
+
+`waitInUrl`, `waitUrlEquals`, and `waitCurrentPathEquals` now resolve a relative path against the helper's configured `url` before comparing. In 3.x a literal substring match against `window.location.href` would fail for relative paths.
+
+```js
+// helpers: { Playwright: { url: 'https://app.example.com' } }
+
+I.waitUrlEquals('/dashboard')   // matches https://app.example.com/dashboard
+I.waitInUrl('/users')           // matches any URL containing /users
+```
+
+`waitUrlEquals` error messages now include the actual URL the page was on when the wait timed out โ€” easier to diagnose `/dashboard` vs `/dashboard?session=expired`.
+
+## 6. Adopt New Behaviors
+
+### Strict Mode
+
+Playwright, Puppeteer, and WebDriver helpers support `strict: true`. Any locator that matches more than one element throws `MultipleElementsFound` instead of silently picking the first match.
+
+```js
+helpers: {
+  Playwright: { url: '...', strict: true },
+}
+```
+
+Per-step alternative: `I.click('a', step.opts({ exact: true }))`.
+
+The error includes a `fetchDetails()` method that prints XPaths and HTML for every match.
+
+### Element Index
+
+Pick a specific match without writing a more specific locator:
+
+```js
+I.click('a', step.opts({ elementIndex: 2 }))
+I.click('a', step.opts({ elementIndex: 'last' }))
+I.fillField('input', 'x', step.opts({ elementIndex: -1 }))
+```
+
+### Unfocused Element Detection
+
+`I.type()` and `I.pressKey()` throw `NonFocusedType` if no element has focus. Click or focus the field first.
+
+### Context Parameter on Form Methods
+
+`appendField`, `clearField`, `attachFile`, and `moveCursorTo` accept an optional second context argument, matching `fillField` and `click`.
+
+### Other New Methods
+
+- `I.seeCurrentPathEquals(path)` / `I.dontSeeCurrentPathEquals(path)` โ€” compare the path ignoring query strings.
+- `I.waitCurrentPathEquals(path, sec?)` โ€” wait until the path matches.
+- `I.seeFileDownloaded(name)`
+- `I.clickXY(locator?, x, y)` โ€” click at coordinates, either page-relative or element-relative.
+- `I.grabAriaSnapshot(locator?)` โ€” capture an accessibility-tree snapshot for the page or a region (Playwright).
+- `I.grabWebElement(locator)` / `I.grabWebElements(locator)` โ€” return helper-agnostic `WebElement` wrappers.
+- `attachFile` โ€” supports drag-and-drop dropzones.
+- `fillField` โ€” supports rich text editors (CKEditor, ProseMirror, etc.).
+- BDD: `But` keyword is recognized.
+
+## 7. Update Dependency Versions
+
+If your project depends on these directly, check for breakage:
+
+| Package | 3.x | 4.x |
+|---------|-----|-----|
+| `chai` | ^4 | ^6 (ESM-only) |
+| `chai-as-promised` | 7 | 8 (ESM-only) |
+| `@cucumber/gherkin` | 35 | 38 |
+| `@cucumber/messages` | 29 | 32 |
+| `chokidar` | 4 | 5 |
+| `commander` | 11 | 14 |
+| `@faker-js/faker` | 9 | 10 |
+| `webdriverio` | 9.12 | 9.23 |
+| `puppeteer` | 24.15 | 24.36 |
+| `electron` | 38 | 40 |
+| `typescript` | 5.8 | 5.9 |
+| `testcafe` | 3.7.2 | **removed** |
+| `inquirer-test` | 2.0.1 | **removed** |
+| `joi` | 18 | **removed** โ€” use `zod` |
+| `zod` | โ€” | added (^4) โ€” schema validation in `JSONResponse` |
+| `tsx` | โ€” | added as optional peer |
+| `@modelcontextprotocol/sdk` | โ€” | added |
+| `@testomatio/reporter` | โ€” | added |
+
+## 8. New Capabilities Worth Knowing
+
+You don't need these to upgrade, but they unlock new workflows:
+
+- **MCP server** โ€” `bin/mcp-server.js` (also installed as `codeceptjs-mcp`) exposes CodeceptJS to AI agents through Model Context Protocol. See [MCP](/mcp).
+- **WebElement wrapper** โ€” `grabWebElements()` returns helper-agnostic `WebElement` instances with a unified API.
+- **ARIA-first locators** โ€” `{ role: 'button', name: 'Submit' }` works in Playwright, Puppeteer, and WebDriver. The `role` type is now first-class in `Locator`. See [Locators](/locators#aria-locators).
+- **Locator DSL** โ€” `locate(...)` gains `.withClass()`, `.not()` negation, raw-predicate helpers, and a `role` selector type.
+- **Workers** โ€” the `event` dispatcher fires inside worker processes, so listeners and plugins observe parallel runs the same way they observe single-process runs.
+- **Path normalization** โ€” file-path handling is normalized cross-platform; tests authored on Windows run unchanged on Linux/CI.
+- **Test metadata** โ€” the `Scenario` callback receives a `test` object with `test.tags`, `test.artifacts`, `test.meta`, and `test.notes` for custom reporting.
+- **Security** โ€” the `emptyFolder` utility (used by output cleanup) no longer shells out via `rm -rf`, closing a command-injection vector ([#5191](https://github.com/codeceptjs/CodeceptJS/pull/5191)).
+
+## 9. Verify the Upgrade
+
+1. `npx codeceptjs check` โ€” surfaces config issues.
+2. `npx codeceptjs run --debug` on a small smoke suite. Confirm the run starts and steps execute.
+3. `npx codeceptjs run --workers 2` โ€” confirm parallel execution.
+4. TypeScript users: run with `tsx` installed and confirm error stack traces point at `.ts` files.
+5. If you removed `autoLogin`: confirm sessions restore under the `auth` plugin.
+6. If you used `tryTo` / `retryTo` / `eachElement` plugins: grep your tests for the old globals and switch to subpath imports.
+7. CI: bump the Node version to 20+ if you were on 18 or below.
diff --git a/docs/probe.html b/docs/probe.html
deleted file mode 100644
index b22b06a51..000000000
--- a/docs/probe.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-

-
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 69cb9e5f2..593dfe4b5 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -5,101 +5,72 @@ layout: Section
 sidebar: true
 ---
 
-::: slot sidebar
-
-#### Use WebDriver for classical Selenium setup
-
-
-This gives you access to rich Selenium ecosystem and cross-browser support for majority of browsers and devices.
-
-
-
-Start with WebDriver »
-
- WebDriver support is implemented via [webdriverio](https://webdriver.io) library 
-
----
-
-* [Mobile Testing with Appium ยป](/mobile)
-
-:::
-
 # Quickstart
 
-
-Use [CodeceptJS all-in-one installer](https://github.com/codeceptjs/create-codeceptjs) to get CodeceptJS, a demo project, and Playwright.
+Install CodeceptJS into your project:
 
 ```
-npx create-codeceptjs .
+npm install codeceptjs playwright --save-dev
 ```
 
-If you prefer not to use Playwright see other [installation options](/installation/).
-
-![Installation](/img/codeceptinstall.gif)
-
-> To install codeceptjs into a different folder, like `tests` use `npx create-codeceptjs tests`
-
-After CodeceptJS is installed, try running **demo tests** using this commands:
+Then install the browser binaries:
 
-* `npm run codeceptjs:demo` - executes demo tests in window mode
-* `npm run codeceptjs:demo:headless` - executes demo tests in headless mode
-* `npm run codeceptjs:demo:ui` - open CodeceptJS UI to list and run demo tests. 
+```
+npx playwright install --with-deps
+```
 
-[CodeceptJS UI](/ui) application:
+The `--with-deps` flag also installs required system dependencies for the browsers.
 
-![](https://user-images.githubusercontent.com/220264/93860826-4d5fbc80-fcc8-11ea-99dc-af816f3db466.png)
+> Prefer WebDriver or Appium? See [installation options](/installation/) for all supported helpers.
 
 ---
 
 ### Init
 
-To start a new project initialize CodeceptJS to create main config file: `codecept.conf.js`.
+Initialize CodeceptJS to set up the config file and test directory:
 
 ```
 npx codeceptjs init
 ```
 
-Answer questions, agree on defaults:
+This command walks you through a short setup wizard and creates `codecept.conf.js`, a sample test file, and any required browser binaries.
 
+Answer the questions, accepting defaults to get started quickly:
 
 | Question | Default Answer  | Alternative
 |---|---|---|
 | Do you plan to write tests in TypeScript?  | **n** (No)  | or [learn how to use TypeScript](/typescript)
 | Where are your tests located? | `**./*_test.js` | or any glob pattern like `**.spec.js`
-| What helpers do you want to use? | **Playwright** | Which helper to use for: [web testing](https://codecept.io/basics/#architecture), [mobile testing](https://codecept.io/mobile/), [API testing](https://codecept.io/api/)
-| Where should logs, screenshots, and reports to be stored? | `./output` | path to store artifacts and temporary files 
-| Do you want to enable localization for tests? | **n** English (no localization) | or write [localized tests](https://codecept.io/translation/) in your language
-  
-
-Sample output:
+| What helpers do you want to use? | **Playwright** | See options for [web testing](https://codecept.io/basics/#architecture), [mobile testing](https://codecept.io/mobile/), [API testing](https://codecept.io/api/)
+| Where should logs, screenshots, and reports be stored? | `./output` | path to store artifacts and temporary files
 
-```js
-? Do you plan to write tests in TypeScript? 'No'
-? Where are your tests located? '**./*_test.js'
-? What helpers do you want to use? 'Playwright'
-? Where should logs, screenshots, and reports to be stored? '**./output**'
-? Do you want to enable localization for tests? 'English (no localization)'
-```
-
-For Playwright helper provide a website to be tested and browser to be used:
+For Playwright, you'll also be asked about the site and browser:
 
 | Question | Default Answer  | Alternative
 |---|---|---|
-| Base url of site to be tested | http://localhost | Base URL of website you plan to test. Use http://github.com or [sample checkout page](https://getbootstrap.com/docs/5.2/examples/checkout/) if you just want to play around
-| Show browser window | **y** Yes | or run browser in **headless mode** 
-| Browser in which testing will be performed | **chromium** | or run tests in firefox, webkit (which is opensource version of Safari) or launch electron app
+| Base url of site to be tested | http://localhost | URL of the site you plan to test
+| Show browser window | **y** Yes | or run in **headless mode**
+| Browser | **chromium** | or `firefox`, `webkit` (open-source Safari), or `electron`
 
-```js
-? [Playwright] Base url of site to be tested 'http://mysite.com'
-? [Playwright] Show browser window 'Yes'
-? [Playwright] Browser in which testing will be performed. Possible options: chromium, firefox, webkit or electron 'chromium'
+Sample output:
 
 ```
+? Do you plan to write tests in TypeScript? No
+? Where are your tests located? **./*_test.js
+? What helpers do you want to use? Playwright
+? Where should logs, screenshots, and reports be stored? ./output
+? [Playwright] Base url of site to be tested http://localhost
+? [Playwright] Show browser window Yes
+? [Playwright] Browser in which testing will be performed chromium
+```
+
+When asked, create your first feature and test file.
+
+---
 
-Create first feature and test when asked
+### Write Your First Test
 
-Open a newly created file in your favorite JavaScript editor. 
-The file should look like this:
+Open the generated test file. It will look like this:
 
 ```js
 Feature('My First Test');
@@ -108,7 +79,8 @@ Scenario('test something', ({ I }) => {
 
 });
 ```
-Write a simple test scenario:
+
+Add a simple scenario:
 
 ```js
 Feature('My First Test');
@@ -119,13 +91,15 @@ Scenario('test something', ({ I }) => {
 });
 ```
 
-Run a test:
+---
+
+### Run Tests
 
 ```
 npx codeceptjs run
 ```
 
-The output should be similar to this:
+Expected output:
 
 ```bash
 My First Test --
@@ -135,18 +109,12 @@ My First Test --
  โœ“ OK
 ```
 
-To quickly execute tests use following npm scripts:
-
-After CodeceptJS is installed, try running **demo tests** using this commands:
-
-* `npm run codeceptjs` - executes tests in window mode
-* `npm run codeceptjs:headless` - executes tests in headless mode
-* `npm run codeceptjs:ui` - open CodeceptJS UI to list and run tests. 
+Run in headless mode:
 
-More commands available in [CodeceptJS CLI runner](https://codecept.io/commands/).
+```
+npx codeceptjs run --headless
+```
 
+See all available commands in the [CLI reference](https://codecept.io/commands/).
 
 > [โ–ถ Next: CodeceptJS Basics](/basics/)
-
-> [โ–ถ Next: CodeceptJS with Playwright](/playwright/)
-
diff --git a/lib/config.js b/lib/config.js
index 0b3372e32..7f54dfe20 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -124,6 +124,24 @@ class Config {
     hooks.push(fn)
   }
 
+  /**
+   * Number of registered config hooks. Useful for snapshotting before a phase
+   * (e.g. plugin loading) and re-running only the hooks added during it.
+   * @return {number}
+   */
+  static hooksCount() {
+    return hooks.length
+  }
+
+  /**
+   * Run hooks in `[fromIndex, end)` against the given config object, mutating it.
+   * @param {number} fromIndex
+   * @param {Object} cfg
+   */
+  static runHooksFrom(fromIndex, cfg) {
+    for (let i = fromIndex; i < hooks.length; i++) hooks[i](cfg)
+  }
+
   /**
    * Appends values to current config
    *
diff --git a/lib/container.js b/lib/container.js
index faa1bde06..108cf331e 100644
--- a/lib/container.js
+++ b/lib/container.js
@@ -15,6 +15,7 @@ import store from './store.js'
 import Result from './result.js'
 import ai from './ai.js'
 import actorFactory from './actor.js'
+import Config from './config.js'
 
 let asyncHelperPromise
 
@@ -76,6 +77,7 @@ class Container {
     container.translation = await loadTranslation(config.translation || null, config.vocabularies || [])
     container.proxySupportConfig = config.include || {}
     container.proxySupport = createSupportObjects(container.proxySupportConfig)
+    const hooksBeforePlugins = Config.hooksCount()
     container.plugins = await createPlugins(config.plugins || {}, opts)
     container.result = new Result()
 
@@ -121,6 +123,18 @@ class Container {
     // Wait for all async helpers to finish loading and populate the actor
     await asyncHelperPromise
 
+    // If plugins registered any Config hooks during their boot, run them now
+    // and re-apply the (possibly mutated) helper config to already-instantiated helpers.
+    if (Config.hooksCount() > hooksBeforePlugins) {
+      Config.runHooksFrom(hooksBeforePlugins, config)
+      for (const name of Object.keys(container.helpers)) {
+        const helper = container.helpers[name]
+        if (helper && typeof helper._setConfig === 'function' && config.helpers && config.helpers[name]) {
+          helper._setConfig(config.helpers[name])
+        }
+      }
+    }
+
     if (opts && opts.ai) ai.enable(config.ai) // enable AI Assistant
     if (config.gherkin) await loadGherkinStepsAsync(config.gherkin.steps || [])
     if (opts && typeof opts.timeouts === 'boolean') store.timeouts = opts.timeouts
@@ -748,12 +762,24 @@ async function createPlugins(config, options = {}) {
 }
 
 async function loadGherkinStepsAsync(paths) {
+  // Import BDD module to access step file tracking functions and step DSL
+  const bddModule = await import('./mocha/bdd.js')
+
   global.Before = fn => event.dispatcher.on(event.test.started, fn)
   global.After = fn => event.dispatcher.on(event.test.finished, fn)
   global.Fail = fn => event.dispatcher.on(event.test.failed, fn)
 
-  // Import BDD module to access step file tracking functions
-  const bddModule = await import('./mocha/bdd.js')
+  // Scope-inject Given/When/Then/And while loading step files so they work
+  // with noGlobals: true. When noGlobals: false, globals.js has already set
+  // them as permanent globals โ€” skip to avoid deleting them at the end.
+  const injectStepDsl = !!store.noGlobals
+  if (injectStepDsl) {
+    global.Given = bddModule.Given
+    global.When = bddModule.When
+    global.Then = bddModule.Then
+    global.And = bddModule.And
+    global.DefineParameterType = bddModule.defineParameterType
+  }
 
   // If gherkin.steps is string, then this will iterate through that folder and send all step def js files to loadSupportObject
   // If gherkin.steps is Array, it will go the old way
@@ -781,6 +807,13 @@ async function loadGherkinStepsAsync(paths) {
   delete global.Before
   delete global.After
   delete global.Fail
+  if (injectStepDsl) {
+    delete global.Given
+    delete global.When
+    delete global.Then
+    delete global.And
+    delete global.DefineParameterType
+  }
 }
 
 function loadGherkinSteps(paths) {
diff --git a/lib/helper/AI.js b/lib/helper/AI.js
deleted file mode 100644
index 8d709449c..000000000
--- a/lib/helper/AI.js
+++ /dev/null
@@ -1,214 +0,0 @@
-import HelperModule from '@codeceptjs/helper'
-import ora from 'ora-classic'
-import fs from 'fs'
-import path from 'path'
-import ai from '../ai.js'
-import Container from '../container.js'
-import { splitByChunks, minifyHtml } from '../html.js'
-import { beautify } from '../utils.js'
-import output from '../output.js'
-import { registerVariable } from '../pause.js'
-
-const standardActingHelpers = Container.STANDARD_ACTING_HELPERS
-
-const gtpRole = {
-  user: 'user',
-}
-
-/**
- * AI Helper for CodeceptJS.
- *
- * This helper class provides integration with the AI GPT-3.5 or 4 language model for generating responses to questions or prompts within the context of web pages. It allows you to interact with the GPT-3.5 model to obtain intelligent responses based on HTML fragments or general prompts.
- * This helper should be enabled with any web helpers like Playwright or Puppeteer or WebDriver to ensure the HTML context is available.
- *
- * Use it only in development mode. It is recommended to run it only inside pause() mode.
- *
- * ## Configuration
- *
- * This helper should be configured in codecept.conf.{js|ts}
- *
- * * `chunkSize`: (optional, default: 80000) - The maximum number of characters to send to the AI API at once. We split HTML fragments by 8000 chars to not exceed token limit. Increase this value if you use GPT-4.
- */
-class AI extends Helper {
-  constructor(config) {
-    super(config)
-    this.aiAssistant = ai
-
-    this.options = {
-      chunkSize: 80000,
-    }
-    this.options = { ...this.options, ...config }
-    this.aiAssistant.enable(this.config)
-  }
-
-  _beforeSuite() {
-    const helpers = Container.helpers()
-
-    for (const helperName of standardActingHelpers) {
-      if (Object.keys(helpers).indexOf(helperName) > -1) {
-        this.helper = helpers[helperName]
-        break
-      }
-    }
-  }
-
-  /**
-   * Asks the AI GPT language model a question based on the provided prompt within the context of the current page's HTML.
-   *
-   * ```js
-   * I.askGptOnPage('what does this page do?');
-   * ```
-   *
-   * @async
-   * @param {string} prompt - The question or prompt to ask the GPT model.
-   * @returns {Promise} - A Promise that resolves to the generated responses from the GPT model, joined by newlines.
-   */
-  async askGptOnPage(prompt) {
-    const html = await this.helper.grabSource()
-
-    const htmlChunks = splitByChunks(html, this.options.chunkSize)
-
-    if (htmlChunks.length > 1) this.debug(`Splitting HTML into ${htmlChunks.length} chunks`)
-
-    const responses = []
-
-    for (const chunk of htmlChunks) {
-      const messages = [
-        { role: gtpRole.user, content: prompt },
-        { role: gtpRole.user, content: `Within this HTML: ${await minifyHtml(chunk)}` },
-      ]
-
-      if (htmlChunks.length > 1)
-        messages.push({
-          role: 'user',
-          content: 'If action is not possible on this page, do not propose anything, I will send another HTML fragment',
-        })
-
-      const response = await this._processAIRequest(messages)
-
-      output.print(response)
-
-      responses.push(response)
-    }
-
-    return responses.join('\n\n')
-  }
-
-  /**
-   * Asks the AI a question based on the provided prompt within the context of a specific HTML fragment on the current page.
-   *
-   * ```js
-   * I.askGptOnPageFragment('describe features of this screen', '.screen');
-   * ```
-   *
-   * @async
-   * @param {string} prompt - The question or prompt to ask the GPT-3.5 model.
-   * @param {string} locator - The locator or selector used to identify the HTML fragment on the page.
-   * @returns {Promise} - A Promise that resolves to the generated response from the GPT model.
-   */
-  async askGptOnPageFragment(prompt, locator) {
-    const html = await this.helper.grabHTMLFrom(locator)
-
-    const messages = [
-      { role: gtpRole.user, content: prompt },
-      { role: gtpRole.user, content: `Within this HTML: ${await minifyHtml(html)}` },
-    ]
-
-    const response = await this._processAIRequest(messages)
-
-    output.print(response)
-
-    return response
-  }
-
-  /**
-   * Send a general request to AI and return response.
-   * @param {string} prompt
-   * @returns {Promise} - A Promise that resolves to the generated response from the GPT model.
-   */
-  async askGptGeneralPrompt(prompt) {
-    const messages = [{ role: gtpRole.user, content: prompt }]
-
-    const response = await this._processAIRequest(messages)
-
-    output.print(response)
-
-    return response
-  }
-
-  /**
-   * Generates PageObject for current page using AI.
-   *
-   * It saves the PageObject to the output directory. You can review the page object and adjust it as needed and move to pages directory.
-   * Prompt can be customized in a global config file.
-   *
-   * ```js
-   * // create page object for whole page
-   * I.askForPageObject('home');
-   *
-   * // create page object with extra prompt
-   * I.askForPageObject('home', 'implement signIn(username, password) method');
-   *
-   * // create page object for a specific element
-   * I.askForPageObject('home', null, '.detail');
-   * ```
-   *
-   * Asks for a page object based on the provided page name, locator, and extra prompt.
-   *
-   * @async
-   * @param {string} pageName - The name of the page to retrieve the object for.
-   * @param {string|null} [extraPrompt=null] - An optional extra prompt for additional context or information.
-   * @param {string|null} [locator=null] - An optional locator to find a specific element on the page.
-   * @returns {Promise} A promise that resolves to the requested page object.
-   */
-  async askForPageObject(pageName, extraPrompt = null, locator = null) {
-    const spinner = ora(' Processing AI request...').start()
-
-    try {
-      const html = locator ? await this.helper.grabHTMLFrom(locator) : await this.helper.grabSource()
-      await this.aiAssistant.setHtmlContext(html)
-      const response = await this.aiAssistant.generatePageObject(extraPrompt, locator)
-      spinner.stop()
-
-      if (!response[0]) {
-        output.error('No response from AI')
-        return ''
-      }
-
-      const code = beautify(response[0])
-
-      output.print('----- Generated PageObject ----')
-      output.print(code)
-      output.print('-------------------------------')
-
-      const fileName = path.join(output_dir, `${pageName}Page-${Date.now()}.js`)
-
-      output.print(output.styles.bold(`Page object for ${pageName} is saved to ${output.styles.bold(fileName)}`))
-      fs.writeFileSync(fileName, code)
-
-      try {
-        registerVariable('page', require(fileName))
-        output.success('Page object registered for this session as `page` variable')
-        output.print('Use `=>page.methodName()` in shell to run methods of page object')
-        output.print('Use `click(page.locatorName)` to check locators of page object')
-      } catch (err) {
-        output.error('Error while registering page object')
-        output.error(err.message)
-      }
-
-      return code
-    } catch (e) {
-      spinner.stop()
-      throw Error(`Something went wrong! ${e.message}`)
-    }
-  }
-
-  async _processAIRequest(messages) {
-    const spinner = ora(' Processing AI request...').start()
-    const response = await this.aiAssistant.createCompletion(messages)
-    spinner.stop()
-    return response
-  }
-}
-
-export default AI
diff --git a/lib/plugin/browser.js b/lib/plugin/browser.js
new file mode 100644
index 000000000..b7033bde0
--- /dev/null
+++ b/lib/plugin/browser.js
@@ -0,0 +1,173 @@
+import output from '../output.js'
+import Config from '../config.js'
+
+const BROWSER_HELPERS = ['Playwright', 'Puppeteer', 'WebDriver', 'Appium']
+
+const PUPPETEER_BROWSERS = ['chrome', 'firefox']
+const PLAYWRIGHT_BROWSERS = ['chromium', 'webkit', 'firefox']
+
+/**
+ * Overrides browser helper config from the command line. Works for all browser helpers
+ * (Playwright, Puppeteer, WebDriver, Appium) without touching `codecept.conf`.
+ *
+ * Enable it via `-p` option with one or more colon-chained args:
+ *
+ * ```
+ * npx codeceptjs run -p browser:show
+ * npx codeceptjs run -p browser:hide
+ * npx codeceptjs run -p browser:browser=firefox
+ * npx codeceptjs run -p browser:windowSize=1024x768:video=false
+ * npx codeceptjs run -p browser:hide:browser=webkit:windowSize=800x600
+ * ```
+ *
+ * #### Args
+ *
+ * * **show** โ€” force visible browser
+ * * **hide** โ€” force headless (also injects `--headless` into WebDriver chrome/firefox capability args)
+ * * **`=`** โ€” sets `helpers.. = `. Three keys
+ *   get per-helper translation:
+ *     * `browser=` โ€” Puppeteer receives `product`, Playwright receives `browser`,
+ *       WebDriver receives `browser`. Validated per helper.
+ *     * `windowSize=WxH` โ€” sets `windowSize` on each helper, plus `--window-size=W,H`
+ *       chromium/chrome args for Playwright/Puppeteer.
+ *     * `show=true|false` โ€” sets `show` on Playwright/Puppeteer; injects/strips
+ *       `--headless` in WebDriver chrome/firefox capability args.
+ *
+ * Values are coerced: `true`/`false` โ†’ boolean, numbers โ†’ Number, otherwise string.
+ * Keys whose value is `undefined` are skipped.
+ */
+export default function (config = {}) {
+  const args = config._args || []
+  if (!args.length) return
+
+  const opts = {}
+  for (const arg of args) {
+    if (!arg) continue
+    if (arg === 'show') {
+      opts.show = true
+      continue
+    }
+    if (arg === 'hide') {
+      opts.show = false
+      continue
+    }
+    const eq = arg.indexOf('=')
+    if (eq < 0) {
+      output.error(`browser plugin: unknown arg "${arg}"`)
+      continue
+    }
+    opts[arg.slice(0, eq)] = coerce(arg.slice(eq + 1))
+  }
+
+  if (Object.keys(opts).length === 0) return
+
+  Config.addHook(cfg => applyToHelpers(cfg, opts))
+
+  const summary = Object.entries(opts).map(([k, v]) => `${k}=${v}`).join(', ')
+  output.debug(`browser plugin: applied ${summary}`)
+}
+
+function applyToHelpers(cfg, opts) {
+  if (!cfg.helpers) return
+  const { browser, show, windowSize, ...rest } = opts
+
+  for (const name of BROWSER_HELPERS) {
+    const helper = cfg.helpers[name]
+    if (!helper) continue
+
+    if (browser !== undefined && browser !== null && browser !== '') {
+      applyBrowser(name, helper, browser)
+    }
+    if (show === true) applyHeaded(name, helper)
+    else if (show === false) applyHeadless(name, helper)
+    if (windowSize) applyWindowSize(name, helper, String(windowSize))
+
+    for (const k of Object.keys(rest)) {
+      if (rest[k] !== undefined) helper[k] = rest[k]
+    }
+  }
+}
+
+function applyBrowser(helperName, helper, browser) {
+  if (helperName === 'Puppeteer') {
+    if (!PUPPETEER_BROWSERS.includes(browser)) {
+      throw new Error(`Browser ${browser} is not supported by Puppeteer engine`)
+    }
+    helper.product = browser
+    return
+  }
+  if (helperName === 'Playwright') {
+    if (!PLAYWRIGHT_BROWSERS.includes(browser)) {
+      throw new Error(`Browser ${browser} is not supported by Playwright engine`)
+    }
+    helper.browser = browser
+    return
+  }
+  helper.browser = browser
+}
+
+function applyHeaded(helperName, helper) {
+  if (helperName === 'Playwright' || helperName === 'Puppeteer') {
+    helper.show = true
+    return
+  }
+  if (helperName === 'WebDriver') {
+    stripHeadlessArgs(helper, 'desiredCapabilities')
+    stripHeadlessArgs(helper, 'capabilities')
+  }
+}
+
+function applyHeadless(helperName, helper) {
+  if (helperName === 'Playwright' || helperName === 'Puppeteer') {
+    helper.show = false
+    return
+  }
+  if (helperName === 'WebDriver') {
+    if (helper.browser === 'chrome') {
+      injectHeadlessArgs(helper, 'chromeOptions', ['--headless', '--disable-gpu'])
+    } else if (helper.browser === 'firefox') {
+      injectHeadlessArgs(helper, 'firefoxOptions', ['--headless'])
+    }
+  }
+}
+
+function applyWindowSize(helperName, helper, windowSize) {
+  if (!/^\d+x\d+$/.test(windowSize)) return
+  helper.windowSize = windowSize
+  const [w, h] = windowSize.split('x')
+
+  if (helperName === 'Playwright') {
+    helper.chromium = helper.chromium || {}
+    helper.chromium.args = (helper.chromium.args || []).concat(['--no-sandbox', `--window-size=${w},${h}`])
+    helper.chromium.defaultViewport = null
+    return
+  }
+  if (helperName === 'Puppeteer') {
+    helper.chrome = helper.chrome || {}
+    helper.chrome.args = (helper.chrome.args || []).concat(['--no-sandbox', `--window-size=${w},${h}`])
+    helper.chrome.defaultViewport = null
+  }
+}
+
+function injectHeadlessArgs(helper, optsKey, args) {
+  helper.desiredCapabilities = helper.desiredCapabilities || {}
+  helper.desiredCapabilities[optsKey] = helper.desiredCapabilities[optsKey] || {}
+  helper.desiredCapabilities[optsKey].args = (helper.desiredCapabilities[optsKey].args || []).concat(args)
+}
+
+function stripHeadlessArgs(helper, capsKey) {
+  const caps = helper[capsKey]
+  if (!caps) return
+  for (const optsKey of ['chromeOptions', 'firefoxOptions']) {
+    if (caps[optsKey] && Array.isArray(caps[optsKey].args)) {
+      caps[optsKey].args = caps[optsKey].args.filter(a => a !== '--headless')
+    }
+  }
+}
+
+function coerce(v) {
+  if (v === 'true') return true
+  if (v === 'false') return false
+  if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
+  return v
+}
diff --git a/package.json b/package.json
index c25a15410..dbc6430df 100644
--- a/package.json
+++ b/package.json
@@ -87,7 +87,7 @@
     "publish-beta": "./runok.cjs publish:next-beta-version"
   },
   "dependencies": {
-    "@codeceptjs/configure": "1.0.6",
+    "@codeceptjs/configure": "^4.0.0-beta.2",
     "@codeceptjs/helper": "2.0.4",
     "@cucumber/cucumber-expressions": "18",
     "@cucumber/gherkin": "38.0.0",
@@ -115,7 +115,6 @@
     "html-minifier-terser": "7.2.0",
     "inquirer": "^8.2.7",
     "invisi-data": "^1.0.0",
-    "joi": "18.0.2",
     "js-beautify": "1.15.4",
     "lodash.clonedeep": "4.5.0",
     "lodash.merge": "4.6.2",
@@ -131,7 +130,8 @@
     "promise-retry": "1.1.1",
     "resq": "1.11.0",
     "sprintf-js": "1.1.3",
-    "uuid": "11.1.0"
+    "uuid": "11.1.0",
+    "zod": "^4.1.11"
   },
   "optionalDependencies": {
     "@codeceptjs/detox-helper": "1.1.13"
diff --git a/test/unit/plugin/browser_test.js b/test/unit/plugin/browser_test.js
new file mode 100644
index 000000000..8a6e07601
--- /dev/null
+++ b/test/unit/plugin/browser_test.js
@@ -0,0 +1,140 @@
+import { expect } from 'chai'
+import browser from '../../../lib/plugin/browser.js'
+import Config from '../../../lib/config.js'
+
+function applyAndCreate(args, base = {}) {
+  Config.reset()
+  browser({ _args: args })
+  return Config.create(base)
+}
+
+describe('browser plugin', () => {
+  beforeEach(() => Config.reset())
+
+  it('does nothing when no args passed', () => {
+    const cfg = applyAndCreate([], { helpers: { Playwright: { show: true } } })
+    expect(cfg.helpers.Playwright.show).to.equal(true)
+  })
+
+  describe('show / hide flags', () => {
+    it('show forces headed for Playwright + Puppeteer', () => {
+      const cfg = applyAndCreate(['show'], {
+        helpers: { Playwright: { show: false }, Puppeteer: { show: false } },
+      })
+      expect(cfg.helpers.Playwright.show).to.equal(true)
+      expect(cfg.helpers.Puppeteer.show).to.equal(true)
+    })
+
+    it('hide forces headless for Playwright + Puppeteer', () => {
+      const cfg = applyAndCreate(['hide'], {
+        helpers: { Playwright: { show: true }, Puppeteer: { show: true } },
+      })
+      expect(cfg.helpers.Playwright.show).to.equal(false)
+      expect(cfg.helpers.Puppeteer.show).to.equal(false)
+    })
+
+    it('hide injects --headless into WebDriver chrome capability args', () => {
+      const cfg = applyAndCreate(['hide'], {
+        helpers: { WebDriver: { browser: 'chrome' } },
+      })
+      const args = cfg.helpers.WebDriver.desiredCapabilities.chromeOptions.args
+      expect(args).to.include('--headless')
+    })
+
+    it('show strips --headless from WebDriver chrome capability args', () => {
+      const cfg = applyAndCreate(['show'], {
+        helpers: {
+          WebDriver: { browser: 'chrome', desiredCapabilities: { chromeOptions: { args: ['--headless', '--disable-gpu'] } } },
+        },
+      })
+      const args = cfg.helpers.WebDriver.desiredCapabilities.chromeOptions.args
+      expect(args).not.to.include('--headless')
+      expect(args).to.include('--disable-gpu')
+    })
+  })
+
+  describe('windowSize', () => {
+    it('windowSize=WxH sets windowSize across browser helpers and chrome args', () => {
+      const cfg = applyAndCreate(['windowSize=800x600'], {
+        helpers: { Playwright: {}, Puppeteer: {}, WebDriver: {} },
+      })
+      expect(cfg.helpers.Playwright.windowSize).to.equal('800x600')
+      expect(cfg.helpers.Playwright.chromium.args).to.include('--window-size=800,600')
+      expect(cfg.helpers.Puppeteer.windowSize).to.equal('800x600')
+      expect(cfg.helpers.WebDriver.windowSize).to.equal('800x600')
+    })
+  })
+
+  describe('generic key=value passthrough', () => {
+    it('coerces booleans and applies to every browser helper present', () => {
+      const cfg = applyAndCreate(['video=false'], {
+        helpers: { Playwright: {}, Puppeteer: {}, WebDriver: {}, Appium: {} },
+      })
+      expect(cfg.helpers.Playwright.video).to.equal(false)
+      expect(cfg.helpers.Puppeteer.video).to.equal(false)
+      expect(cfg.helpers.WebDriver.video).to.equal(false)
+      expect(cfg.helpers.Appium.video).to.equal(false)
+    })
+
+    it('coerces numbers', () => {
+      const cfg = applyAndCreate(['waitForTimeout=5000'], {
+        helpers: { Playwright: {} },
+      })
+      expect(cfg.helpers.Playwright.waitForTimeout).to.equal(5000)
+    })
+
+    it('keeps strings as strings', () => {
+      const cfg = applyAndCreate(['url=http://staging.test'], {
+        helpers: { Playwright: {} },
+      })
+      expect(cfg.helpers.Playwright.url).to.equal('http://staging.test')
+    })
+
+    it('skips helpers not present in config without errors', () => {
+      const cfg = applyAndCreate(['video=true'], {
+        helpers: { Playwright: {} }, // Puppeteer/WebDriver absent
+      })
+      expect(cfg.helpers.Playwright.video).to.equal(true)
+      expect(cfg.helpers.Puppeteer).to.equal(undefined)
+    })
+  })
+
+  describe('browser engine selection', () => {
+    it('browser=firefox routes through setBrowser, Puppeteer gets product', () => {
+      const cfg = applyAndCreate(['browser=firefox'], {
+        helpers: { Puppeteer: {}, Playwright: {} },
+      })
+      expect(cfg.helpers.Puppeteer.product).to.equal('firefox')
+      expect(cfg.helpers.Puppeteer.browser).to.equal(undefined)
+      expect(cfg.helpers.Playwright.browser).to.equal('firefox')
+    })
+
+    it('browser=webkit + show=false combine cleanly', () => {
+      const cfg = applyAndCreate(['hide', 'browser=webkit'], {
+        helpers: { Playwright: { show: true } },
+      })
+      expect(cfg.helpers.Playwright.browser).to.equal('webkit')
+      expect(cfg.helpers.Playwright.show).to.equal(false)
+    })
+  })
+
+  describe('combined args', () => {
+    it('applies show + windowSize + key=value in a single call', () => {
+      const cfg = applyAndCreate(['show', 'windowSize=1024x768', 'video=false'], {
+        helpers: { Playwright: { show: false }, Puppeteer: { show: false }, WebDriver: { browser: 'chrome' } },
+      })
+      expect(cfg.helpers.Playwright.show).to.equal(true)
+      expect(cfg.helpers.Playwright.windowSize).to.equal('1024x768')
+      expect(cfg.helpers.Playwright.video).to.equal(false)
+      expect(cfg.helpers.Puppeteer.show).to.equal(true)
+      expect(cfg.helpers.Puppeteer.windowSize).to.equal('1024x768')
+      expect(cfg.helpers.WebDriver.windowSize).to.equal('1024x768')
+    })
+  })
+
+  describe('unknown arg', () => {
+    it('does not throw when an arg has no value and is not a flag', () => {
+      expect(() => applyAndCreate(['weirdtoken'], { helpers: { Playwright: {} } })).not.to.throw()
+    })
+  })
+})
diff --git a/typings/index.d.ts b/typings/index.d.ts
index e06a8620e..97db06b22 100644
--- a/typings/index.d.ts
+++ b/typings/index.d.ts
@@ -211,9 +211,6 @@ declare namespace CodeceptJS {
        */
       JSONResponse?: any
 
-      /** Enable AI features for development purposes */
-      AI?: any
-
       [key: string]: any
     }
     /**

From b7284ba6a0be63d1e04f8c30a74bddcf2b20657d Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Wed, 29 Apr 2026 03:57:45 +0300
Subject: [PATCH 3/5] feat(plugins): unify screenshot/pause/aiTrace/heal under
 shared on= parameter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds a shared lib/utils/pluginParser.js with key=value parsing (`:` and `;`
separators) and a `resolveTrigger` helper. Renames screenshotOnFail โ†’ screenshot
and consolidates pauseOn/pauseOnFail โ†’ pause; aiTrace and heal grow `on=` filters.
Old plugin names live on as deprecated alias shims that warn and forward.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 docs/commands.md                              |   8 +-
 docs/configuration.md                         |  15 +-
 docs/debugging.md                             |  27 +-
 docs/migration-4.md                           |  24 +-
 docs/plugins.md                               |  94 ++++---
 docs/tutorial.md                              |   4 +-
 lib/config.js                                 |   5 +-
 lib/plugin/aiTrace.js                         |  60 ++++-
 lib/plugin/heal.js                            |  56 +++-
 lib/plugin/pause.js                           | 145 ++++++++++
 lib/plugin/pauseOn.js                         | 180 ++-----------
 lib/plugin/pauseOnFail.js                     |  44 +--
 lib/plugin/screenshot.js                      | 253 ++++++++++++++++++
 lib/plugin/screenshotOnFail.js                | 178 +-----------
 lib/utils/pluginParser.js                     | 124 +++++++++
 .../codecept.Playwright.coverage.js           |   3 +-
 test/acceptance/codecept.Playwright.js        |   3 +-
 test/acceptance/codecept.Puppeteer.js         |   3 +-
 test/acceptance/codecept.WebDriver.js         |   3 +-
 test/acceptance/codecept.els.Playwright.js    |   3 +-
 test/bdd/codecept.faker.js                    |   3 +-
 test/plugin/plugin_test.js                    |   2 +-
 22 files changed, 797 insertions(+), 440 deletions(-)
 create mode 100644 lib/plugin/pause.js
 create mode 100644 lib/plugin/screenshot.js
 create mode 100644 lib/utils/pluginParser.js

diff --git a/docs/commands.md b/docs/commands.md
index 8e087ae6c..2fe864449 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -115,10 +115,10 @@ Plugins listed via `-p` are activated even when their config has `enabled: false
 A few examples:
 
 ```sh
-npx codeceptjs run -p pauseOnFail                  # pause on first failure
-npx codeceptjs run -p pauseOn:step                 # pause before every step
-npx codeceptjs run -p pauseOn:url:/checkout/*      # pause on URL match
-npx codeceptjs run -p stepByStepReport             # produce a step-by-step HTML report
+npx codeceptjs run -p pause                              # pause on first failure (default on=fail)
+npx codeceptjs run -p pause:on=step                      # pause before every step
+npx codeceptjs run -p pause:on=url:pattern=/checkout/*   # pause on URL match
+npx codeceptjs run -p stepByStepReport                   # produce a step-by-step HTML report
 ```
 
 ### Browser Control
diff --git a/docs/configuration.md b/docs/configuration.md
index 8aa05c3ad..73d50992b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -194,13 +194,14 @@ import { setCommonPlugins } from '@codeceptjs/configure'
 setCommonPlugins()
 ```
 
-| Plugin             | Default        | Notes                                                                          |
-| :----------------- | :------------- | :----------------------------------------------------------------------------- |
-| `retryFailedStep`  | enabled        | Retry steps that fail with transient errors                                    |
-| `screenshotOnFail` | enabled        | Capture a screenshot when a test fails                                         |
-| `pauseOn`          | registered     | Pause on failure / step / file / URL โ€” `-p pauseOn:fail`, `-p pauseOn:step`, `-p pauseOn:file:tests/login_test.js`, `-p pauseOn:url:/checkout/*` |
-| `browser`          | registered     | CLI overrides for browser helpers โ€” `-p browser:show`, `-p browser:browser=firefox`, see [commands](/commands#browser-control) |
-| `aiTrace`          | registered     | Capture AI traces โ€” `-p aiTrace`                                               |
+| Plugin            | Default        | Notes                                                                          |
+| :---------------- | :------------- | :----------------------------------------------------------------------------- |
+| `retryFailedStep` | enabled        | Retry steps that fail with transient errors                                    |
+| `screenshot`      | enabled        | Screenshot on `fail` (default) / `test` / `step` / `file` / `url`              |
+| `pause`           | registered     | Pause on failure / step / file / URL โ€” `-p pause:on=fail`, `-p pause:on=step`, `-p pause:on=file:path=tests/login_test.js`, `-p pause:on=url:pattern=/checkout/*` |
+| `browser`         | registered     | CLI overrides for browser helpers โ€” `-p browser:show`, `-p browser:browser=firefox`, see [commands](/commands#browser-control) |
+| `aiTrace`         | registered     | Capture AI traces โ€” `-p aiTrace`, narrow with `on=fail|test|step|file|url`     |
+| `heal`            | registered     | Self-heal failing steps โ€” `-p heal`, narrow with `on=file|url`                 |
 
 > `eachElement`, `tryTo`, and `retryTo` are no longer plugins in 4.x โ€” import them from `codeceptjs/effects`.
 
diff --git a/docs/debugging.md b/docs/debugging.md
index 81b8cd66a..729d03d25 100644
--- a/docs/debugging.md
+++ b/docs/debugging.md
@@ -107,28 +107,30 @@ After(({ I }) => {
 })
 ```
 
-## Pause On Plugin
+## Pause Plugin
 
-For automated debugging without modifying test code, use the `pauseOn` plugin. It pauses tests based on different triggers, controlled entirely from the command line.
+For automated debugging without modifying test code, use the `pause` plugin. It pauses tests based on different triggers, controlled entirely from the command line. The default is `on=fail`.
 
 ### Pause on Failure
 
 Automatically enters interactive pause when a step fails:
 
 ```bash
-npx codeceptjs run -p pauseOn:fail
+npx codeceptjs run -p pause
+# or, explicit:
+npx codeceptjs run -p pause:on=fail
 ```
 
 This is the most common debug workflow โ€” run your tests, and when one fails, you land in the interactive shell with the browser in the exact state of the failure. You can inspect elements, try different selectors, and figure out what went wrong.
 
-> The older `pauseOnFail` plugin still works: `npx codeceptjs run -p pauseOnFail`
+> The legacy `pauseOnFail` and `pauseOn` plugins still work as deprecated aliases.
 
 ### Pause on Every Step
 
 Enters interactive pause at the start of the test. Use *ENTER* to advance step by step:
 
 ```bash
-npx codeceptjs run -p pauseOn:step
+npx codeceptjs run -p pause:on=step
 ```
 
 This gives you full step-by-step execution. After each step, you're back in the interactive shell where you can inspect the page before pressing ENTER to continue.
@@ -138,13 +140,13 @@ This gives you full step-by-step execution. After each step, you're back in the
 Pauses when execution reaches a specific file:
 
 ```bash
-npx codeceptjs run -p pauseOn:file:tests/login_test.js
+npx codeceptjs run -p pause:on=file:path=tests/login_test.js
 ```
 
 With a specific line number:
 
 ```bash
-npx codeceptjs run -p pauseOn:file:tests/login_test.js:43
+npx codeceptjs run -p pause:on=file:path=tests/login_test.js;line=43
 ```
 
 This works like a breakpoint โ€” the test runs normally until it hits a step defined at that file and line, then opens the interactive shell.
@@ -154,14 +156,14 @@ This works like a breakpoint โ€” the test runs normally until it hits a step def
 Pauses when the browser navigates to a matching URL:
 
 ```bash
-npx codeceptjs run -p pauseOn:url:/users/1
+npx codeceptjs run -p pause:on=url:pattern=/users/1
 ```
 
 Supports `*` wildcards:
 
 ```bash
-npx codeceptjs run -p pauseOn:url:/api/*/edit
-npx codeceptjs run -p pauseOn:url:/checkout/*
+npx codeceptjs run -p pause:on=url:pattern=/api/*/edit
+npx codeceptjs run -p pause:on=url:pattern=/checkout/*
 ```
 
 This is useful when you want to inspect a specific page regardless of which test step navigates there.
@@ -243,15 +245,16 @@ Enabled by default. Saves a screenshot when a test fails:
 
 ```js
 plugins: {
-  screenshotOnFail: {
+  screenshot: {
     enabled: true,
+    on: 'fail',
     uniqueScreenshotNames: true,
     fullPageScreenshots: true,
   }
 }
 ```
 
-Screenshots are saved in the `output` directory.
+Screenshots are saved in the `output` directory. The same plugin also supports `on=test`, `on=step`, `on=file`, and `on=url` to capture screenshots in other situations.
 
 ### Page Info on Failure
 
diff --git a/docs/migration-4.md b/docs/migration-4.md
index 145428755..7c4dad653 100644
--- a/docs/migration-4.md
+++ b/docs/migration-4.md
@@ -187,10 +187,21 @@ plugins: {
 
 Inject `login` and call `login('admin')` โ€” same as before.
 
+### Renamed Plugins
+
+4.x unifies four plugins (`screenshot`, `pause`, `aiTrace`, `heal`) under a shared `on=` parameter. The old names live on as deprecated aliases that emit a warning and forward to the new plugin.
+
+| Old plugin          | New plugin   | Notes                                              |
+| :------------------ | :----------- | :------------------------------------------------- |
+| `screenshotOnFail`  | `screenshot` | Default `on='fail'`, same behavior                 |
+| `pauseOnFail`       | `pause`      | Default `on='fail'`, same behavior                 |
+| `pauseOn`           | `pause`      | Use `on=fail|test|step|file|url` instead           |
+
 ### New Plugins You Can Enable
 
 - **`aiTrace`** โ€” captures failure traces (DOM, console, network, screenshots) for AI debugging. See [AI Trace](/aitrace).
-- **`pauseOn`** โ€” pauses execution on a chosen event or on failure. See [Debugging](/debugging).
+- **`pause`** โ€” pauses execution on a chosen event or on failure. See [Debugging](/debugging).
+- **`heal`** โ€” self-heals failing steps with AI; narrow with `on=file|url`.
 
 ## 5. Update Removed and Changed APIs
 
@@ -455,13 +466,16 @@ I.fillField('input', 'x', step.opts({ elementIndex: -1 }))
 `-p` accepts colon-chained arguments, so plugins can be enabled and configured from the command line without editing config:
 
 ```bash
-npx codeceptjs run -p pauseOn:fail              # pause on every failure
-npx codeceptjs run -p pauseOn:url:/checkout/*   # pause when URL matches
-npx codeceptjs run -p browser:show              # force visible browser
+npx codeceptjs run -p pause                                   # pause on every failure
+npx codeceptjs run -p pause:on=url:pattern=/checkout/*        # pause when URL matches
+npx codeceptjs run -p screenshot:on=step                      # screenshot every step
+npx codeceptjs run -p browser:show                            # force visible browser
 npx codeceptjs run -p browser:browser=firefox:windowSize=1024x768
-npx codeceptjs run -p plugin1,plugin2:arg       # multiple plugins
+npx codeceptjs run -p plugin1,plugin2:arg                     # multiple plugins
 ```
 
+Each argument after the plugin name is a `key=value` pair. `:` separates pairs. `;` is an inline alternative for visually grouping related pairs (e.g. `path=...;line=...`). Reserved keys: `on`, `path`, `line`, `pattern`.
+
 The `browser` plugin is new in 4.x โ€” it overrides the active browser helper (Playwright, Puppeteer, WebDriver, Appium) from the CLI, useful for ad-hoc local runs and CI matrices. See [Commands](/commands).
 
 The old `-p all` magic keyword is gone (it conflicted with the colon syntax). Enable specific plugins explicitly: `-p pluginA,pluginB`.
diff --git a/docs/plugins.md b/docs/plugins.md
index bed98c8cc..a51c9633e 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -595,47 +595,42 @@ Additional config options:
 
 *   `config`   (optional, default `{}`)
 
-## pauseOn
+## pause
 
-Pauses test execution in different modes. Unlike `pauseOnFail`, this plugin supports
-multiple triggers for pausing and is controlled via CLI arguments.
+Pauses test execution interactively. Replaces the legacy `pauseOn` and `pauseOnFail`
+plugins. Default `on=fail` matches the old `pauseOnFail` behavior.
 
-Enable it via `-p` option with a mode:
-
-    npx codeceptjs run -p pauseOn:fail
-    npx codeceptjs run -p pauseOn:step
-    npx codeceptjs run -p pauseOn:file:tests/login_test.js
-    npx codeceptjs run -p pauseOn:file:tests/login_test.js:43
-    npx codeceptjs run -p pauseOn:url:/users/*
-
-#### Modes
-
-*   **fail** โ€” pause when a step fails (same as `pauseOnFail` plugin)
-*   **step** โ€” pause before first step, use `next` to advance step-by-step
-*   **file** โ€” pause when execution reaches a specific file (and optionally line)
-*   **url** โ€” pause when the browser URL matches a pattern (supports `*` wildcards)
-
-### Parameters
+```js
+plugins: {
+  pause: {
+    enabled: false,
+    on: 'fail',
+  }
+}
+```
 
-*   `config`   (optional, default `{}`)
+#### `on=` modes
 
-## pauseOnFail
+*   **fail** โ€” pause when a step fails (default)
+*   **test** โ€” pause after each test
+*   **step** โ€” pause before the first step (interactive walk-through)
+*   **file** โ€” pause when execution reaches `path=...[;line=...]`
+*   **url** โ€” pause when the current URL matches `pattern=...`
 
-Automatically launches [interactive pause][5] when a test fails.
+CLI examples:
 
-Useful for debugging flaky tests on local environment.
-Add this plugin to config file:
+    npx codeceptjs run -p pause
+    npx codeceptjs run -p pause:on=step
+    npx codeceptjs run -p pause:on=file:path=tests/login_test.js
+    npx codeceptjs run -p pause:on=file:path=tests/login_test.js;line=43
+    npx codeceptjs run -p pause:on=url:pattern=/users/*
 
-```js
-plugins: {
-  pauseOnFail: {},
-}
-```
+> The legacy `pauseOn` and `pauseOnFail` plugins remain as deprecated aliases
+> that translate to the new syntax and emit a deprecation warning.
 
-Unlike other plugins, `pauseOnFail` is not recommended to be enabled by default.
-Enable it manually on each run via `-p` option:
+### Parameters
 
-    npx codeceptjs run -p pauseOnFail
+*   `config`   (optional, default `{}`)
 
 ## retryFailedStep
 
@@ -705,31 +700,44 @@ Scenario('scenario tite', { disableRetryFailedStep: true }, () => {
 
 *   `config`  
 
-## screenshotOnFail
-
-Creates screenshot on failure. Screenshot is saved into `output` directory.
+## screenshot
 
-Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
+Saves screenshots from the browser at points triggered by `on=`. Replaces the
+legacy `screenshotOnFail` plugin. Default `on=fail` preserves the old behavior.
 
 This plugin is **enabled by default**.
 
-#### Configuration
-
-Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
-
 ```js
 plugins: {
-   screenshotOnFail: {
-     enabled: true
-   }
+  screenshot: {
+    enabled: true,
+    on: 'fail',
+  }
 }
 ```
 
+#### `on=` modes
+
+*   **fail** โ€” screenshot when a test fails (default)
+*   **test** โ€” screenshot at the end of every test
+*   **step** โ€” screenshot after every step
+*   **file** โ€” screenshot for steps in `path=...[;line=...]`
+*   **url** โ€” screenshot when the current URL matches `pattern=...`
+
+CLI examples:
+
+    npx codeceptjs run -p screenshot
+    npx codeceptjs run -p screenshot:on=step
+    npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
+    npx codeceptjs run -p screenshot:on=url:pattern=/users/*
+
 Possible config options:
 
 *   `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
 *   `fullPageScreenshots`: make full page screenshots. Default: false.
 
+> The legacy `screenshotOnFail` plugin remains as a deprecated alias.
+
 ### Parameters
 
 *   `config`  
diff --git a/docs/tutorial.md b/docs/tutorial.md
index f117628ac..13a44397a 100644
--- a/docs/tutorial.md
+++ b/docs/tutorial.md
@@ -153,13 +153,13 @@ Please note, that you shouldn't use a real credit card number here. Good news, y
 Run the test with next command:
 
 ```
-npx codeceptjs run --debug -p pauseOnFail
+npx codeceptjs run --debug -p pause
 ```
 
 What are special options here?
 
 * `--debug` flag is used to output additional information to the console, such as the details of each step in the test, the values of variables, and the results of test assertions. This can help you to identify and fix any issues in your tests.
-* `-p pauseOnFail` option is also used to keep the browser opened even if a test fails. It will help us to identify to which point test was executed and what can be improved.
+* `-p pause` option is also used to keep the browser opened even if a test fails (default `on=fail`). It will help us to identify to which point test was executed and what can be improved.
 
 Add more test steps if needed, update locators, and notify business owners that all that purchases are made by you so your collegues won't call you in the night asking when you want to get a coffee cup ๐Ÿ˜€ Also the good idea is to run tests on staging website, to not interfere with business process.
 
diff --git a/lib/config.js b/lib/config.js
index 0a33d24fd..7779c0cd3 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -15,8 +15,9 @@ const defaultConfig = {
   hooks: [],
   gherkin: {},
   plugins: {
-    screenshotOnFail: {
-      enabled: true, // will be disabled by default in 2.0
+    screenshot: {
+      enabled: true,
+      on: 'fail',
     },
   },
   stepTimeout: 0,
diff --git a/lib/plugin/aiTrace.js b/lib/plugin/aiTrace.js
index f2668afbc..e34e23f10 100644
--- a/lib/plugin/aiTrace.js
+++ b/lib/plugin/aiTrace.js
@@ -11,10 +11,19 @@ import event from '../event.js'
 import output from '../output.js'
 import { deleteDir, clearString } from '../utils.js'
 import colors from 'chalk'
+import {
+  parsePluginArgs,
+  resolveTrigger,
+  matchStepFile,
+  matchUrl,
+} from '../utils/pluginParser.js'
 
 const supportedHelpers = Container.STANDARD_ACTING_HELPERS
 
+const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
+
 const defaultConfig = {
+  on: 'step',
   deleteSuccessful: false,
   fullPageScreenshots: false,
   output: store.outputDir,
@@ -53,13 +62,38 @@ const defaultConfig = {
  * * `captureHTTP`: capture HTTP requests (requires `trace` or `recordHar` enabled in helper config). Default: true.
  * * `captureDebugOutput`: capture CodeceptJS debug output. Default: true.
  * * `ignoreSteps`: steps to ignore in trace. Array of RegExps is expected.
+ * * `on`: trigger mode โ€” `step` (default), `fail`, `test`, `file`, `url`.
+ *
+ * #### `on=` modes
+ *
+ * * **step** โ€” persist every step (default)
+ * * **fail** โ€” persist only the failed step
+ * * **test** โ€” persist only the last step of each test
+ * * **file** โ€” persist steps from `path=...[;line=...]`
+ * * **url** โ€” persist when the current URL matches `pattern=...`
  *
  * @param {*} config
  */
-export default function (config) {
+export default function (config = {}) {
   const helpers = Container.helpers()
   let helper
 
+  const cliArgs = parsePluginArgs(config._args)
+  const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on })
+
+  if (!VALID_MODES.has(trigger.on)) {
+    output.error(`aiTrace: unknown on="${trigger.on}". Valid: fail, test, step, file, url`)
+    return
+  }
+  if (trigger.on === 'file' && !trigger.path) {
+    output.error('aiTrace:on=file requires path=. Example: -p aiTrace:on=file:path=tests/foo.js')
+    return
+  }
+  if (trigger.on === 'url' && !trigger.pattern) {
+    output.error('aiTrace:on=url requires pattern=. Example: -p aiTrace:on=url:pattern=/users/*')
+    return
+  }
+
   config = Object.assign(defaultConfig, config)
 
   for (const helperName of supportedHelpers) {
@@ -141,6 +175,24 @@ export default function (config) {
       output.debug(`aiTrace: Skipping failed step "${step.toString()}" - already handled by step.failed event`)
       return
     }
+
+    // on= filtering
+    if (trigger.on === 'fail') return // failed steps handled by step.failed
+    if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
+    if (trigger.on === 'url') {
+      recorder.add('aiTrace:url check', async () => {
+        try {
+          if (!helper.grabCurrentUrl) return
+          const url = await helper.grabCurrentUrl()
+          if (!matchUrl(url, trigger.pattern)) return
+          await persistStep(step)
+        } catch (err) {
+          output.debug(`aiTrace: Error in url-mode step persistence: ${err.message}`)
+        }
+      }, true)
+      return
+    }
+
     const stepPersistPromise = persistStep(step).catch(err => {
       output.debug(`aiTrace: Error saving step: ${err.message}`)
     })
@@ -361,6 +413,12 @@ export default function (config) {
       return
     }
 
+    // on=test: only render the last step in markdown; artifacts of earlier steps
+    // remain on disk unreferenced.
+    if (trigger.on === 'test') {
+      steps = steps.slice(-1)
+    }
+
     const testDuration = ((Date.now() - testStartTime) / 1000).toFixed(2)
 
     let markdown = `file: ${test.file || 'unknown'}\n`
diff --git a/lib/plugin/heal.js b/lib/plugin/heal.js
index c5163c64d..0fc36ade9 100644
--- a/lib/plugin/heal.js
+++ b/lib/plugin/heal.js
@@ -10,21 +10,32 @@ import output from '../output.js'
 import healModule from '../heal.js'
 const heal = healModule.default || healModule
 import store from '../store.js'
+import {
+  parsePluginArgs,
+  resolveTrigger,
+  matchStepFile,
+  matchUrl,
+  getBrowserHelper,
+} from '../utils/pluginParser.js'
 
 
+const VALID_MODES = new Set(['fail', 'file', 'url'])
+
 const defaultConfig = {
+  on: 'fail',
   healLimit: 2,
 }
 
 /**
  * Self-healing tests with AI.
  *
- * Read more about heaking in [Self-Healing Tests](https://codecept.io/heal/)
+ * Read more about healing in [Self-Healing Tests](https://codecept.io/heal/)
  *
  * ```js
  * plugins: {
  *   heal: {
  *    enabled: true,
+ *    on: 'fail',
  *   }
  * }
  * ```
@@ -32,7 +43,17 @@ const defaultConfig = {
  * More config options are available:
  *
  * * `healLimit` - how many steps can be healed in a single test (default: 2)
+ * * `on` - trigger mode. `fail` (default), `file` (filter to a path), `url` (filter to a URL pattern).
+ *
+ * #### `on=` modes
+ *
+ * Heal always runs on step failures; `on=` narrows when it engages.
+ *
+ * * **fail** โ€” heal any failing step (default)
+ * * **file** โ€” heal only failures in `path=...[;line=...]`
+ * * **url** โ€” heal only failures when the current URL matches `pattern=...`
  *
+ * `on=step` and `on=test` are not supported and are rejected with an error.
  */
 export default function (config = {}) {
   if (store.debugMode && !process.env.DEBUG) {
@@ -42,6 +63,22 @@ export default function (config = {}) {
     return
   }
 
+  const cliArgs = parsePluginArgs(config._args)
+  const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on })
+
+  if (!VALID_MODES.has(trigger.on)) {
+    output.error(`heal: unsupported on="${trigger.on}". Valid: fail, file, url`)
+    return
+  }
+  if (trigger.on === 'file' && !trigger.path) {
+    output.error('heal:on=file requires path=. Example: -p heal:on=file:path=tests/foo.js')
+    return
+  }
+  if (trigger.on === 'url' && !trigger.pattern) {
+    output.error('heal:on=url requires pattern=. Example: -p heal:on=url:pattern=/users/*')
+    return
+  }
+
   let currentTest = null
   let currentStep = null
   let healedSteps = 0
@@ -65,6 +102,8 @@ export default function (config = {}) {
 
     if (!heal.hasCorrespondingRecipes(step)) return
 
+    if (trigger.on === 'file' && !matchStepFile(step, trigger.path, trigger.line)) return
+
     recorder.catchWithoutStop(async err => {
       isHealing = true
       if (caughtError === err) throw err // avoid double handling
@@ -72,6 +111,21 @@ export default function (config = {}) {
 
       const test = currentTest
 
+      if (trigger.on === 'url') {
+        try {
+          const helper = getBrowserHelper()
+          const url = helper && helper.grabCurrentUrl ? await helper.grabCurrentUrl() : null
+          if (!matchUrl(url, trigger.pattern)) {
+            isHealing = false
+            throw err
+          }
+        } catch (e) {
+          if (e === err) throw e
+          isHealing = false
+          throw err
+        }
+      }
+
       recorder.session.start('heal')
 
       debug('Self-healing started', step.toCode())
diff --git a/lib/plugin/pause.js b/lib/plugin/pause.js
new file mode 100644
index 000000000..7a837ae29
--- /dev/null
+++ b/lib/plugin/pause.js
@@ -0,0 +1,145 @@
+import event from '../event.js'
+import pause from '../pause.js'
+import recorder from '../recorder.js'
+import output from '../output.js'
+import {
+  parsePluginArgs,
+  resolveTrigger,
+  matchStepFile,
+  matchUrl,
+  getBrowserHelper,
+} from '../utils/pluginParser.js'
+
+const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
+
+/**
+ * Pauses test execution interactively. Replaces the legacy `pauseOn` and `pauseOnFail`
+ * plugins. The default `on=fail` matches the old `pauseOnFail` behavior.
+ *
+ * #### Configuration
+ *
+ * ```js
+ * plugins: {
+ *   pause: {
+ *     enabled: false,
+ *     on: 'fail',
+ *   }
+ * }
+ * ```
+ *
+ * #### `on=` modes
+ *
+ * * **fail** โ€” pause when a step fails (default)
+ * * **test** โ€” pause after each test
+ * * **step** โ€” pause before the first step (interactive walk-through)
+ * * **file** โ€” pause when execution reaches `path=...[;line=...]`
+ * * **url** โ€” pause when the browser URL matches `pattern=...`
+ *
+ * CLI examples:
+ *
+ * ```
+ * npx codeceptjs run -p pause
+ * npx codeceptjs run -p pause:on=step
+ * npx codeceptjs run -p pause:on=file:path=tests/login_test.js;line=43
+ * npx codeceptjs run -p pause:on=url:pattern=/users/*
+ * ```
+ */
+export default function (config = {}) {
+  const cliArgs = parsePluginArgs(config._args)
+  const trigger = resolveTrigger(cliArgs, config, { on: 'fail' })
+
+  if (!VALID_MODES.has(trigger.on)) {
+    output.error(`pause: unknown on="${trigger.on}". Valid: fail, test, step, file, url`)
+    return
+  }
+
+  switch (trigger.on) {
+    case 'fail':
+      return initFailMode()
+    case 'test':
+      return initTestMode()
+    case 'step':
+      return initStepMode()
+    case 'file':
+      if (!trigger.path) {
+        output.error('pause:on=file requires path=. Example: -p pause:on=file:path=tests/foo.js')
+        return
+      }
+      return initFileMode(trigger.path, trigger.line)
+    case 'url':
+      if (!trigger.pattern) {
+        output.error('pause:on=url requires pattern=. Example: -p pause:on=url:pattern=/users/*')
+        return
+      }
+      return initUrlMode(trigger.pattern)
+  }
+}
+
+function initFailMode() {
+  let failed = false
+
+  event.dispatcher.on(event.test.started, () => {
+    failed = false
+  })
+
+  event.dispatcher.on(event.step.failed, () => {
+    failed = true
+  })
+
+  event.dispatcher.on(event.test.after, () => {
+    if (failed) pause()
+  })
+}
+
+function initTestMode() {
+  event.dispatcher.on(event.test.after, () => pause())
+}
+
+function initStepMode() {
+  let activated = false
+
+  event.dispatcher.on(event.test.before, () => {
+    if (activated) return
+    activated = true
+    recorder.add('pause:step', () => pause())
+  })
+}
+
+function initFileMode(targetPath, targetLine) {
+  let paused = false
+
+  event.dispatcher.on(event.step.before, step => {
+    if (paused) return
+    if (!matchStepFile(step, targetPath, targetLine)) return
+    paused = true
+    recorder.add('pause:file', () => pause())
+  })
+}
+
+function initUrlMode(pattern) {
+  const helper = getBrowserHelper()
+
+  if (!helper) {
+    output.error('pause:on=url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
+    return
+  }
+
+  let paused = false
+
+  event.dispatcher.on(event.step.after, () => {
+    if (paused) return
+
+    recorder.add('pause:url check', async () => {
+      if (paused) return
+      try {
+        const currentUrl = await helper.grabCurrentUrl()
+        if (matchUrl(currentUrl, pattern)) {
+          paused = true
+          return pause()
+        }
+      } catch (err) {
+        // page may not be loaded yet
+      }
+    })
+  })
+}
diff --git a/lib/plugin/pauseOn.js b/lib/plugin/pauseOn.js
index dea25dea5..478e8b209 100644
--- a/lib/plugin/pauseOn.js
+++ b/lib/plugin/pauseOn.js
@@ -1,167 +1,43 @@
-import event from '../event.js'
-import pause from '../pause.js'
-import recorder from '../recorder.js'
-import Container from '../container.js'
 import output from '../output.js'
+import pause from './pause.js'
 
-const supportedHelpers = Container.STANDARD_ACTING_HELPERS
+let warned = false
 
 /**
- * Pauses test execution in different modes. Unlike `pauseOnFail`, this plugin supports
- * multiple triggers for pausing and is controlled via CLI arguments.
- *
- * Enable it via `-p` option with a mode:
- *
- * ```
- * npx codeceptjs run -p pauseOn:fail
- * npx codeceptjs run -p pauseOn:step
- * npx codeceptjs run -p pauseOn:file:tests/login_test.js
- * npx codeceptjs run -p pauseOn:file:tests/login_test.js:43
- * npx codeceptjs run -p pauseOn:url:/users/*
- * ```
- *
- * #### Modes
- *
- * * **fail** โ€” pause when a step fails (same as `pauseOnFail` plugin)
- * * **step** โ€” pause before first step, use `next` to advance step-by-step
- * * **file** โ€” pause when execution reaches a specific file (and optionally line)
- * * **url** โ€” pause when the browser URL matches a pattern (supports `*` wildcards)
+ * @deprecated Use the `pause` plugin. Old positional CLI args are translated
+ * to the new `on=key:param=value` form:
  *
+ *   `-p pauseOn:fail`                                  โ†’ `-p pause:on=fail`
+ *   `-p pauseOn:step`                                  โ†’ `-p pause:on=step`
+ *   `-p pauseOn:file:tests/foo.js:43`                  โ†’ `-p pause:on=file:path=tests/foo.js;line=43`
+ *   `-p pauseOn:url:/users/*`                          โ†’ `-p pause:on=url:pattern=/users/*`
  */
 export default function (config = {}) {
-  const args = config._args || []
-  const mode = args[0] || 'fail'
-
-  switch (mode) {
-    case 'fail':
-      return initFailMode()
-    case 'step':
-      return initStepMode()
-    case 'file':
-      return initFileMode(args.slice(1))
-    case 'url':
-      return initUrlMode(args.slice(1))
-    default:
-      output.error(`pauseOn: unknown mode "${mode}". Available: fail, step, file, url`)
+  if (!warned) {
+    output.error('pauseOn is deprecated; use the `pause` plugin with the new on=key:param=value syntax.')
+    warned = true
   }
-}
-
-function initFailMode() {
-  let failed = false
-
-  event.dispatcher.on(event.test.started, () => {
-    failed = false
-  })
-
-  event.dispatcher.on(event.step.failed, () => {
-    failed = true
-  })
-
-  event.dispatcher.on(event.test.after, () => {
-    if (failed) pause()
-  })
-}
-
-function initStepMode() {
-  let activated = false
-
-  event.dispatcher.on(event.test.before, () => {
-    if (activated) return
-    activated = true
-    recorder.add('pauseOn:step', () => pause())
-  })
-}
-
-function initFileMode(fileArgs) {
-  if (fileArgs.length === 0) {
-    output.error('pauseOn:file requires a path. Usage: -p pauseOn:file:[:]')
-    return
-  }
-
-  const targetFile = fileArgs[0]
-  const targetLine = fileArgs[1] ? parseInt(fileArgs[1], 10) : null
-  let paused = false
-
-  event.dispatcher.on(event.step.before, (step) => {
-    if (paused) return
 
-    const stepLine = step.line()
-    if (!stepLine) return
-
-    const match = parseStepLine(stepLine)
-    if (!match) return
-
-    const fileMatches = match.file.includes(targetFile) || match.file.endsWith(targetFile)
-    if (!fileMatches) return
-
-    if (targetLine !== null && match.line !== targetLine) return
-
-    paused = true
-    recorder.add('pauseOn:file', () => pause())
-  })
+  const args = config._args || []
+  const translated = translatePositionalArgs(args)
+  return pause({ ...config, _args: translated })
 }
 
-function initUrlMode(urlArgs) {
-  if (urlArgs.length === 0) {
-    output.error('pauseOn:url requires a pattern. Usage: -p pauseOn:url:')
-    return
+function translatePositionalArgs(args) {
+  if (!args.length) return []
+  const mode = args[0]
+  if (mode === 'fail' || mode === 'step') {
+    return [`on=${mode}`]
   }
-
-  const urlPattern = urlArgs.join(':')
-
-  const helpers = Container.helpers()
-  let helper = null
-  for (const helperName of supportedHelpers) {
-    if (Object.keys(helpers).indexOf(helperName) > -1) {
-      helper = helpers[helperName]
-    }
+  if (mode === 'file') {
+    const path = args[1]
+    const line = args[2]
+    if (!path) return ['on=file']
+    return line ? [`on=file`, `path=${path};line=${line}`] : [`on=file`, `path=${path}`]
   }
-
-  if (!helper) {
-    output.error('pauseOn:url requires a browser helper (Playwright, WebDriver, Puppeteer, Appium)')
-    return
+  if (mode === 'url') {
+    const pattern = args.slice(1).join(':')
+    return pattern ? [`on=url`, `pattern=${pattern}`] : ['on=url']
   }
-
-  const regex = patternToRegex(urlPattern)
-  let paused = false
-
-  event.dispatcher.on(event.step.after, () => {
-    if (paused) return
-
-    recorder.add('pauseOn:url check', async () => {
-      if (paused) return
-      try {
-        const currentUrl = await helper.grabCurrentUrl()
-        if (regex.test(currentUrl)) {
-          paused = true
-          return pause()
-        }
-      } catch (err) {
-        // page may not be loaded yet
-      }
-    })
-  })
-}
-
-function parseStepLine(stepLine) {
-  let line = stepLine.trim()
-  if (line.startsWith('at ')) line = line.substring(3).trim()
-
-  const lastColon = line.lastIndexOf(':')
-  if (lastColon < 0) return null
-  const secondLastColon = line.lastIndexOf(':', lastColon - 1)
-  if (secondLastColon < 0) return null
-
-  const file = line.substring(0, secondLastColon)
-  const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
-
-  if (isNaN(lineNum)) return null
-
-  return { file, line: lineNum }
-}
-
-function patternToRegex(pattern) {
-  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
-  const regexStr = escaped.replace(/\*/g, '.*')
-  return new RegExp(regexStr)
+  return args
 }
diff --git a/lib/plugin/pauseOnFail.js b/lib/plugin/pauseOnFail.js
index 36511ff1b..625b5a0bb 100644
--- a/lib/plugin/pauseOnFail.js
+++ b/lib/plugin/pauseOnFail.js
@@ -1,39 +1,15 @@
-import event from '../event.js'
+import output from '../output.js'
+import pause from './pause.js'
 
-import pause from '../pause.js'
+let warned = false
 
 /**
- * Automatically launches [interactive pause](/basics/#pause) when a test fails.
- *
- * Useful for debugging flaky tests on local environment.
- * Add this plugin to config file:
- *
- * ```js
- * plugins: {
- *   pauseOnFail: {},
- * }
- * ```
- *
- * Unlike other plugins, `pauseOnFail` is not recommended to be enabled by default.
- * Enable it manually on each run via `-p` option:
- *
- * ```
- * npx codeceptjs run -p pauseOnFail
- * ```
- *
+ * @deprecated Use the `pause` plugin with `on: 'fail'` (the default).
  */
-export default function() {
-  let failed = false
-
-  event.dispatcher.on(event.test.started, () => {
-    failed = false
-  })
-
-  event.dispatcher.on(event.step.failed, () => {
-    failed = true
-  })
-
-  event.dispatcher.on(event.test.after, () => {
-    if (failed) pause()
-  })
+export default function (config = {}) {
+  if (!warned) {
+    output.error('pauseOnFail is deprecated; use the `pause` plugin (default on=fail).')
+    warned = true
+  }
+  return pause({ ...config, on: 'fail' })
 }
diff --git a/lib/plugin/screenshot.js b/lib/plugin/screenshot.js
new file mode 100644
index 000000000..eee865de0
--- /dev/null
+++ b/lib/plugin/screenshot.js
@@ -0,0 +1,253 @@
+import fs from 'fs'
+import path from 'path'
+
+import Container from '../container.js'
+import recorder from '../recorder.js'
+import event from '../event.js'
+import output from '../output.js'
+import store from '../store.js'
+
+import { fileExists } from '../utils.js'
+import Codeceptjs from '../index.js'
+import { testToFileName } from '../mocha/test.js'
+import {
+  parsePluginArgs,
+  resolveTrigger,
+  matchStepFile,
+  matchUrl,
+  getBrowserHelper,
+} from '../utils/pluginParser.js'
+
+const defaultConfig = {
+  on: 'fail',
+  uniqueScreenshotNames: false,
+  disableScreenshots: false,
+  fullPageScreenshots: false,
+}
+
+const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
+
+/**
+ * Saves screenshots from the browser at points triggered by `on=`.
+ *
+ * Replaces the legacy `screenshotOnFail` plugin. Default `on=fail` preserves the
+ * old behavior (screenshot when a test fails).
+ *
+ * #### Configuration
+ *
+ * ```js
+ * plugins: {
+ *   screenshot: {
+ *     enabled: true,
+ *     on: 'fail',
+ *   }
+ * }
+ * ```
+ *
+ * #### `on=` modes
+ *
+ * * **fail** โ€” screenshot when a test fails (default)
+ * * **test** โ€” screenshot at the end of every test
+ * * **step** โ€” screenshot after every step
+ * * **file** โ€” screenshot for steps in `path=...[;line=...]`
+ * * **url** โ€” screenshot when the current browser URL matches `pattern=...`
+ *
+ * Other config options:
+ *
+ * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
+ * * `fullPageScreenshots`: make full page screenshots. Default: false.
+ * * `disableScreenshots`: legacy switch to skip the plugin entirely.
+ *
+ * CLI examples:
+ *
+ * ```
+ * npx codeceptjs run -p screenshot
+ * npx codeceptjs run -p screenshot:on=step
+ * npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
+ * npx codeceptjs run -p screenshot:on=url:pattern=/users/*
+ * ```
+ */
+export default function (config = {}) {
+  const helper = getBrowserHelper()
+  if (!helper) return
+
+  const cliArgs = parsePluginArgs(config._args)
+  const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on })
+
+  if (!VALID_MODES.has(trigger.on)) {
+    output.error(`screenshot: unknown on="${trigger.on}". Valid: fail, test, step, file, url`)
+    return
+  }
+
+  const helpers = Container.helpers()
+  const options = Object.assign({}, defaultConfig, helper.options, config)
+
+  if (helpers.Mochawesome?.config) {
+    options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
+  }
+
+  if (Codeceptjs.container.mocha()) {
+    options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions
+      && Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
+  }
+
+  if (options.disableScreenshots) return
+
+  switch (trigger.on) {
+    case 'fail':
+      return wireOnFail(options)
+    case 'test':
+      return wireOnTest(options)
+    case 'step':
+      return wireOnStep(options, () => true)
+    case 'file':
+      if (!trigger.path) {
+        output.error('screenshot:on=file requires path=. Example: -p screenshot:on=file:path=tests/foo.js')
+        return
+      }
+      return wireOnStep(options, step => matchStepFile(step, trigger.path, trigger.line))
+    case 'url':
+      if (!trigger.pattern) {
+        output.error('screenshot:on=url requires pattern=. Example: -p screenshot:on=url:pattern=/users/*')
+        return
+      }
+      return wireOnUrl(options, trigger.pattern)
+  }
+}
+
+function wireOnFail(options) {
+  let currentTest = null
+  event.dispatcher.on(event.test.before, test => {
+    currentTest = test
+  })
+  event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
+    if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
+    const t = test || currentTest
+    if (!t) return
+    scheduleScreenshot(t, suffix(t, options, 'failed'), options)
+  })
+}
+
+function wireOnTest(options) {
+  event.dispatcher.on(event.test.after, test => {
+    if (!test) return
+    scheduleScreenshot(test, suffix(test, options, 'test'), options)
+  })
+}
+
+function wireOnStep(options, filter) {
+  let currentTest = null
+  let stepCount = 0
+  event.dispatcher.on(event.test.before, test => {
+    currentTest = test
+    stepCount = 0
+  })
+  event.dispatcher.on(event.step.after, step => {
+    if (!currentTest) return
+    if (!filter(step)) return
+    stepCount++
+    const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.step_${stepCount}.png`
+    scheduleScreenshot(currentTest, name, options)
+  })
+}
+
+function wireOnUrl(options, pattern) {
+  let currentTest = null
+  let stepCount = 0
+  event.dispatcher.on(event.test.before, test => {
+    currentTest = test
+    stepCount = 0
+  })
+  event.dispatcher.on(event.step.after, () => {
+    if (!currentTest) return
+    const helper = getBrowserHelper()
+    if (!helper) return
+    recorder.add('screenshot:url check', async () => {
+      try {
+        const url = await helper.grabCurrentUrl()
+        if (!matchUrl(url, pattern)) return
+        stepCount++
+        const name = `${testToFileName(currentTest, { suffix: '', unique: options.uniqueScreenshotNames })}.url_${stepCount}.png`
+        await takeScreenshot(currentTest, name, options)
+      } catch (err) {
+        // page may not be ready
+      }
+    })
+  })
+}
+
+function suffix(test, options, kind) {
+  const base = testToFileName(test, { suffix: '', unique: options.uniqueScreenshotNames })
+  return `${base}.${kind}.png`
+}
+
+function scheduleScreenshot(test, fileName, options) {
+  recorder.add(
+    'screenshot capture',
+    async () => takeScreenshot(test, fileName, options),
+    true,
+  )
+}
+
+async function takeScreenshot(test, fileName, options) {
+  const quietMode = !store.outputDir
+  if (!quietMode) {
+    output.plugin('screenshot', `Saving screenshot ${fileName}`)
+  }
+
+  const helper = getBrowserHelper()
+  if (!helper || typeof helper.saveScreenshot !== 'function') return
+
+  try {
+    if (options.reportDir) {
+      fileName = path.join(options.reportDir, fileName)
+      const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
+      if (!fileExists(mochaReportDir)) fs.mkdirSync(mochaReportDir)
+    }
+
+    if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
+      throw new Error('Browser page has been closed')
+    }
+    if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
+      throw new Error('Browser has been disconnected')
+    }
+
+    const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
+    const timeoutPromise = new Promise((_, reject) => {
+      setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
+    })
+
+    await Promise.race([screenshotPromise, timeoutPromise])
+
+    if (!test.artifacts) test.artifacts = {}
+    const baseOutputDir = store.outputDir || null
+    if (baseOutputDir) {
+      test.artifacts.screenshot = path.join(baseOutputDir, fileName)
+      const mocha = Container.mocha()
+      const junit = mocha?.options?.reporterOptions?.['mocha-junit-reporter']
+      if (junit?.options?.attachments) {
+        test.attachments = [path.join(baseOutputDir, fileName)]
+      }
+    } else {
+      test.artifacts.screenshot = fileName
+    }
+  } catch (err) {
+    if (!quietMode) {
+      output.plugin('screenshot', `Failed to save screenshot: ${err.message}`)
+    }
+    if (
+      err
+      && ((err.message
+        && (err.message.includes('Target page, context or browser has been closed')
+          || err.message.includes('Browser page has been closed')
+          || err.message.includes('Browser has been disconnected')
+          || err.message.includes('was terminated due to')
+          || err.message.includes('no such window: target window already closed')
+          || err.message.includes('Screenshot timeout after')))
+        || (err.type && err.type === 'RuntimeError'))
+    ) {
+      output.log(`Can't make screenshot, ${err.message}`)
+      helper.isRunning = false
+    }
+  }
+}
diff --git a/lib/plugin/screenshotOnFail.js b/lib/plugin/screenshotOnFail.js
index 57c181d0f..eae177f7b 100644
--- a/lib/plugin/screenshotOnFail.js
+++ b/lib/plugin/screenshotOnFail.js
@@ -1,177 +1,15 @@
-import fs from 'fs'
-import path from 'path'
-
-import Container from '../container.js'
-
-import recorder from '../recorder.js'
-
-import event from '../event.js'
-
 import output from '../output.js'
-import store from '../store.js'
-
-import { fileExists } from '../utils.js'
-import Codeceptjs from '../index.js'
-import { testToFileName } from '../mocha/test.js'
-
-const defaultConfig = {
-  uniqueScreenshotNames: false,
-  disableScreenshots: false,
-  fullPageScreenshots: false,
-}
+import screenshot from './screenshot.js'
 
-const supportedHelpers = Container.STANDARD_ACTING_HELPERS
+let warned = false
 
 /**
- * Creates screenshot on failure. Screenshot is saved into `output` directory.
- *
- * Initially this functionality was part of corresponding helper but has been moved into plugin since 1.4
- *
- * This plugin is **enabled by default**.
- *
- * #### Configuration
- *
- * Configuration can either be taken from a corresponding helper (deprecated) or a from plugin config (recommended).
- *
- * ```js
- * plugins: {
- *    screenshotOnFail: {
- *      enabled: true
- *    }
- * }
- * ```
- *
- * Possible config options:
- *
- * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
- * * `fullPageScreenshots`: make full page screenshots. Default: false.
- *
- *
+ * @deprecated Use the `screenshot` plugin with `on: 'fail'` (the default).
  */
-export default function (config) {
-  const helpers = Container.helpers()
-  let helper
-
-  for (const helperName of supportedHelpers) {
-    if (Object.keys(helpers).indexOf(helperName) > -1) {
-      helper = helpers[helperName]
-    }
-  }
-
-  if (!helper) return // no helpers for screenshot
-
-  const options = Object.assign(defaultConfig, helper.options, config)
-
-  if (helpers.Mochawesome) {
-    if (helpers.Mochawesome.config) {
-      options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
-    }
-  }
-
-  if (Codeceptjs.container.mocha()) {
-    options.reportDir = Codeceptjs.container.mocha()?.options?.reporterOptions && Codeceptjs.container.mocha()?.options?.reporterOptions?.reportDir
-  }
-
-  if (options.disableScreenshots) {
-    // old version of disabling screenshots
-    return
+export default function (config = {}) {
+  if (!warned) {
+    output.error('screenshotOnFail is deprecated; use the `screenshot` plugin (default on=fail).')
+    warned = true
   }
-
-  event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
-    if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') {
-      // no browser here
-      return
-    }
-
-    recorder.add(
-      'screenshot of failed test',
-      async () => {
-        const dataType = 'image/png'
-        // This prevents data driven to be included in the failed screenshot file name
-        let fileName
-
-        if (options.uniqueScreenshotNames && test) {
-          fileName = `${testToFileName(test, { suffix: '', unique: true })}.failed.png`
-        } else {
-          fileName = `${testToFileName(test, { suffix: '', unique: false })}.failed.png`
-        }
-        const quietMode = !store.outputDir
-        if (!quietMode) {
-          output.plugin('screenshotOnFail', 'Test failed, try to save a screenshot')
-        }
-
-        // Re-check helpers at runtime in case they weren't ready during plugin init
-        const runtimeHelpers = Container.helpers()
-        let runtimeHelper = null
-        for (const helperName of supportedHelpers) {
-          if (Object.keys(runtimeHelpers).indexOf(helperName) > -1) {
-            runtimeHelper = runtimeHelpers[helperName]
-            break
-          }
-        }
-
-        if (runtimeHelper && typeof runtimeHelper.saveScreenshot === 'function') {
-          helper = runtimeHelper
-        }
-
-        try {
-          if (options.reportDir) {
-            fileName = path.join(options.reportDir, fileName)
-            const mochaReportDir = path.resolve(process.cwd(), options.reportDir)
-            if (!fileExists(mochaReportDir)) {
-              fs.mkdirSync(mochaReportDir)
-            }
-          }
-
-          // Check if browser/page is still available before attempting screenshot
-          if (helper.page && helper.page.isClosed && helper.page.isClosed()) {
-            throw new Error('Browser page has been closed')
-          }
-          if (helper.browser && helper.browser.isConnected && !helper.browser.isConnected()) {
-            throw new Error('Browser has been disconnected')
-          }
-
-          // Add timeout wrapper to prevent hanging with shorter timeout for ESM
-          const screenshotPromise = helper.saveScreenshot(fileName, options.fullPageScreenshots)
-          const timeoutPromise = new Promise((_, reject) => {
-            setTimeout(() => reject(new Error('Screenshot timeout after 5 seconds')), 5000)
-          })
-
-          await Promise.race([screenshotPromise, timeoutPromise])
-
-          if (!test.artifacts) test.artifacts = {}
-          const baseOutputDir = store.outputDir || null
-          if (baseOutputDir) {
-            test.artifacts.screenshot = path.join(baseOutputDir, fileName)
-            if (Container.mocha().options.reporterOptions['mocha-junit-reporter'] && Container.mocha().options.reporterOptions['mocha-junit-reporter'].options.attachments) {
-              test.attachments = [path.join(baseOutputDir, fileName)]
-            }
-          } else {
-            // Fallback: just store the file name to keep tests stable without triggering path errors
-            test.artifacts.screenshot = fileName
-          }
-        } catch (err) {
-          if (!quietMode) {
-            output.plugin('screenshotOnFail', `Failed to save screenshot: ${err.message}`)
-          }
-          // Enhanced error handling for browser closed scenarios
-          if (
-            err &&
-            ((err.message &&
-              (err.message.includes('Target page, context or browser has been closed') ||
-                err.message.includes('Browser page has been closed') ||
-                err.message.includes('Browser has been disconnected') ||
-                err.message.includes('was terminated due to') ||
-                err.message.includes('no such window: target window already closed') ||
-                err.message.includes('Screenshot timeout after'))) ||
-              (err.type && err.type === 'RuntimeError'))
-          ) {
-            output.log(`Can't make screenshot, ${err.message}`)
-            helper.isRunning = false
-          }
-        }
-      },
-      true,
-    )
-  })
+  return screenshot({ ...config, on: 'fail' })
 }
diff --git a/lib/utils/pluginParser.js b/lib/utils/pluginParser.js
new file mode 100644
index 000000000..25ed2aa3f
--- /dev/null
+++ b/lib/utils/pluginParser.js
@@ -0,0 +1,124 @@
+import Container from '../container.js'
+
+const supportedHelpers = Container.STANDARD_ACTING_HELPERS
+
+const RESERVED_KEYS = new Set(['on', 'path', 'line', 'pattern'])
+
+/**
+ * Parse a plugin's _args (from CLI `-p plugin:key=value:key=value`) into a flat dict.
+ * Each entry is split on `;` then on the first `=`. Bare segments become `{ key: true }`.
+ *
+ * Examples:
+ *   parsePluginArgs(['on=fail'])
+ *     โ†’ { on: 'fail' }
+ *   parsePluginArgs(['on=file', 'path=tests/foo.js;line=43'])
+ *     โ†’ { on: 'file', path: 'tests/foo.js', line: '43' }
+ *   parsePluginArgs(['on=file', 'path=tests/foo.js', 'line=43'])
+ *     โ†’ { on: 'file', path: 'tests/foo.js', line: '43' }
+ *   parsePluginArgs(['show'])
+ *     โ†’ { show: true }
+ */
+export function parsePluginArgs(args = []) {
+  const opts = {}
+  for (const arg of args) {
+    if (!arg) continue
+    for (const segment of arg.split(';')) {
+      if (!segment) continue
+      if (segment.includes('=')) {
+        const eq = segment.indexOf('=')
+        const key = segment.slice(0, eq)
+        const value = segment.slice(eq + 1)
+        opts[key] = coerce(value)
+      } else {
+        opts[segment] = true
+      }
+    }
+  }
+  return opts
+}
+
+function coerce(v) {
+  if (v === 'true') return true
+  if (v === 'false') return false
+  return v
+}
+
+/**
+ * Compose CLI args > config > defaults into a normalized trigger spec.
+ * Returns `{ on, path, line, pattern, ...rest }`. `line` is coerced to a number.
+ */
+export function resolveTrigger(cliArgs = {}, config = {}, defaults = {}) {
+  const merged = { ...defaults, ...pickKnown(config), ...cliArgs }
+  if (merged.line != null) merged.line = parseInt(merged.line, 10)
+  return merged
+}
+
+function pickKnown(config) {
+  const out = {}
+  for (const key of Object.keys(config || {})) {
+    if (RESERVED_KEYS.has(key)) out[key] = config[key]
+  }
+  return out
+}
+
+/**
+ * Match a step's source location against a `path` (substring/suffix) and optional `line`.
+ * Reads the step's stack via `step.line()` to get `file:row:col`.
+ */
+export function matchStepFile(step, targetPath, targetLine) {
+  if (!targetPath) return false
+  const stepLine = step.line && step.line()
+  if (!stepLine) return false
+
+  const parsed = parseStepLine(stepLine)
+  if (!parsed) return false
+
+  const fileMatches = parsed.file.includes(targetPath) || parsed.file.endsWith(targetPath)
+  if (!fileMatches) return false
+
+  if (targetLine != null && !Number.isNaN(targetLine) && parsed.line !== targetLine) return false
+  return true
+}
+
+function parseStepLine(stepLine) {
+  let line = stepLine.trim()
+  if (line.startsWith('at ')) line = line.substring(3).trim()
+
+  const lastColon = line.lastIndexOf(':')
+  if (lastColon < 0) return null
+  const secondLastColon = line.lastIndexOf(':', lastColon - 1)
+  if (secondLastColon < 0) return null
+
+  const file = line.substring(0, secondLastColon)
+  const lineNum = parseInt(line.substring(secondLastColon + 1, lastColon), 10)
+
+  if (Number.isNaN(lineNum)) return null
+  return { file, line: lineNum }
+}
+
+/**
+ * Match a URL string against a glob-style pattern (supports `*` wildcards).
+ */
+export function matchUrl(currentUrl, pattern) {
+  if (!pattern || !currentUrl) return false
+  return patternToRegex(pattern).test(currentUrl)
+}
+
+function patternToRegex(pattern) {
+  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
+  const regexStr = escaped.replace(/\*/g, '.*')
+  return new RegExp(regexStr)
+}
+
+/**
+ * Return the first available standard browser helper, or null.
+ */
+export function getBrowserHelper() {
+  const helpers = Container.helpers()
+  for (const name of supportedHelpers) {
+    if (Object.keys(helpers).indexOf(name) > -1) {
+      return helpers[name]
+    }
+  }
+  return null
+}
diff --git a/test/acceptance/codecept.Playwright.coverage.js b/test/acceptance/codecept.Playwright.coverage.js
index 3fbb21074..f40274703 100644
--- a/test/acceptance/codecept.Playwright.coverage.js
+++ b/test/acceptance/codecept.Playwright.coverage.js
@@ -32,8 +32,9 @@ export const config = {
   bootstrap: installCodeceptjs,
   mocha: {},
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
     coverage: {
       enabled: true,
diff --git a/test/acceptance/codecept.Playwright.js b/test/acceptance/codecept.Playwright.js
index c190315c3..efa845907 100644
--- a/test/acceptance/codecept.Playwright.js
+++ b/test/acceptance/codecept.Playwright.js
@@ -35,8 +35,9 @@ export const config = {
   bootstrap: installCodeceptjs,
   mocha: {},
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
     retryFailedStep: {
       enabled: true,
diff --git a/test/acceptance/codecept.Puppeteer.js b/test/acceptance/codecept.Puppeteer.js
index a821c4fb3..641528757 100644
--- a/test/acceptance/codecept.Puppeteer.js
+++ b/test/acceptance/codecept.Puppeteer.js
@@ -25,8 +25,9 @@ export const config = {
   bootstrap: installCodeceptjs,
   mocha: {},
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
   },
   name: 'acceptance',
diff --git a/test/acceptance/codecept.WebDriver.js b/test/acceptance/codecept.WebDriver.js
index 8a9950619..3df695bd6 100644
--- a/test/acceptance/codecept.WebDriver.js
+++ b/test/acceptance/codecept.WebDriver.js
@@ -34,8 +34,9 @@ export const config = {
   mocha: {},
   name: 'acceptance',
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
   },
   gherkin: {
diff --git a/test/acceptance/codecept.els.Playwright.js b/test/acceptance/codecept.els.Playwright.js
index 3e45755b0..93e270171 100644
--- a/test/acceptance/codecept.els.Playwright.js
+++ b/test/acceptance/codecept.els.Playwright.js
@@ -23,8 +23,9 @@ export const config = {
   bootstrap: false,
   mocha: {},
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
   },
   name: 'acceptance',
diff --git a/test/bdd/codecept.faker.js b/test/bdd/codecept.faker.js
index be1b1b688..08cdc97a3 100644
--- a/test/bdd/codecept.faker.js
+++ b/test/bdd/codecept.faker.js
@@ -20,8 +20,9 @@ export const config = {
   bootstrap: false,
   mocha: {},
   plugins: {
-    screenshotOnFail: {
+    screenshot: {
       enabled: true,
+      on: 'fail',
     },
     fakerTransform: {
       enabled: true,
diff --git a/test/plugin/plugin_test.js b/test/plugin/plugin_test.js
index 02020824c..2f5d41890 100644
--- a/test/plugin/plugin_test.js
+++ b/test/plugin/plugin_test.js
@@ -31,7 +31,7 @@ describe('CodeceptJS plugin', function () {
 
       try {
         // Check that the coverage plugin is loaded and starts attempting coverage collection
-        expect(lines).toEqual(expect.arrayContaining([expect.stringContaining('Plugins: screenshotOnFail, coverage'), expect.stringContaining('writing output/coverage')]))
+        expect(lines).toEqual(expect.arrayContaining([expect.stringContaining('Plugins: screenshot, coverage'), expect.stringContaining('writing output/coverage')]))
 
         // Test should pass regardless of whether coverage data is found (depends on external site)
         // The test may fail due to network issues or browser problems, but the coverage plugin should still initialize

From d6a1dfbf7bd28b1df3a3a3136eeb00fd4595d59e Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Wed, 29 Apr 2026 04:09:34 +0300
Subject: [PATCH 4/5] feat(plugins): drop pauseOn/stepByStepReport, fold slides
 into screenshot
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Remove lib/plugin/pauseOn.js (never shipped in a public release).
- Remove lib/plugin/stepByStepReport.js; slideshow generation moves into the
  screenshot plugin behind slides=true (e.g. -p screenshot:on=step;slides=true)
  with a modern, vanilla-CSS dark UI โ€” no Bootstrap, no jQuery.
- Drop the redundant screenshot blocks from the acceptance test configs; the
  default in lib/config.js already enables screenshot with on=fail.
- Strip allure mentions from plugin code and plugin docs; refresh PR template,
  migration guide, debugging guide, plugins index, and commands docs.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .github/PULL_REQUEST_TEMPLATE.md              |   9 +-
 docs/commands.md                              |   2 +-
 docs/debugging.md                             |  10 +-
 docs/migration-4.md                           |  10 +-
 docs/plugins.md                               |  57 +--
 examples/codecept.config.js                   |   2 +-
 lib/plugin/pause.js                           |   4 +-
 lib/plugin/pauseOn.js                         |  43 --
 lib/plugin/screenshot.js                      | 328 ++++++++++++-
 lib/plugin/stepByStepReport.js                | 432 ------------------
 .../codecept.Playwright.coverage.js           |   4 -
 test/acceptance/codecept.Playwright.js        |   4 -
 test/acceptance/codecept.Puppeteer.js         |   6 -
 test/acceptance/codecept.WebDriver.js         |   6 -
 test/acceptance/codecept.els.Playwright.js    |   6 -
 test/bdd/codecept.faker.js                    |   4 -
 16 files changed, 362 insertions(+), 565 deletions(-)
 delete mode 100644 lib/plugin/pauseOn.js
 delete mode 100644 lib/plugin/stepByStepReport.js

diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 0999af523..3c33fd607 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -13,18 +13,17 @@ Applicable helpers:
 
 Applicable plugins:
 
-- [ ] allure
+- [ ] aiTrace
 - [ ] autoDelay
 - [ ] autoLogin
 - [ ] customLocator
-- [ ] pauseOnFail
+- [ ] pause
 - [ ] coverage
+- [ ] heal
 - [ ] retryFailedStep
-- [ ] screenshotOnFail
+- [ ] screenshot
 - [ ] selenoid
-- [ ] stepByStepReport
 - [ ] stepTimeout
-- [ ] wdio
 - [ ] subtitles
 
 ## Type of change
diff --git a/docs/commands.md b/docs/commands.md
index 2fe864449..dff6a23b6 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -118,7 +118,7 @@ A few examples:
 npx codeceptjs run -p pause                              # pause on first failure (default on=fail)
 npx codeceptjs run -p pause:on=step                      # pause before every step
 npx codeceptjs run -p pause:on=url:pattern=/checkout/*   # pause on URL match
-npx codeceptjs run -p stepByStepReport                   # produce a step-by-step HTML report
+npx codeceptjs run -p "screenshot:on=step;slides=true"   # produce a step-by-step HTML report
 ```
 
 ### Browser Control
diff --git a/docs/debugging.md b/docs/debugging.md
index 729d03d25..9368423dc 100644
--- a/docs/debugging.md
+++ b/docs/debugging.md
@@ -123,7 +123,7 @@ npx codeceptjs run -p pause:on=fail
 
 This is the most common debug workflow โ€” run your tests, and when one fails, you land in the interactive shell with the browser in the exact state of the failure. You can inspect elements, try different selectors, and figure out what went wrong.
 
-> The legacy `pauseOnFail` and `pauseOn` plugins still work as deprecated aliases.
+> The legacy `pauseOnFail` plugin still works as a deprecated alias.
 
 ### Pause on Every Step
 
@@ -270,12 +270,14 @@ plugins: {
 
 ### Step-by-Step Report
 
-Generates a slideshow of screenshots taken after every step โ€” a visual replay of what the test did:
+Generates a slideshow of screenshots taken after every step โ€” a visual replay of what the test did. Set `slides: true` on the `screenshot` plugin (with `on=step`):
 
 ```js
 plugins: {
-  stepByStepReport: {
+  screenshot: {
     enabled: true,
+    on: 'step',
+    slides: true,
     deleteSuccessful: true,   // keep only failed tests
     fullPageScreenshots: true,
   }
@@ -283,7 +285,7 @@ plugins: {
 ```
 
 ```bash
-npx codeceptjs run -p stepByStepReport
+npx codeceptjs run -p screenshot:on=step;slides=true
 ```
 
 After the run, open `output/records.html` to browse through the slideshows.
diff --git a/docs/migration-4.md b/docs/migration-4.md
index 7c4dad653..bda2baa7c 100644
--- a/docs/migration-4.md
+++ b/docs/migration-4.md
@@ -191,11 +191,11 @@ Inject `login` and call `login('admin')` โ€” same as before.
 
 4.x unifies four plugins (`screenshot`, `pause`, `aiTrace`, `heal`) under a shared `on=` parameter. The old names live on as deprecated aliases that emit a warning and forward to the new plugin.
 
-| Old plugin          | New plugin   | Notes                                              |
-| :------------------ | :----------- | :------------------------------------------------- |
-| `screenshotOnFail`  | `screenshot` | Default `on='fail'`, same behavior                 |
-| `pauseOnFail`       | `pause`      | Default `on='fail'`, same behavior                 |
-| `pauseOn`           | `pause`      | Use `on=fail|test|step|file|url` instead           |
+| Old plugin          | New plugin                       | Notes                                              |
+| :------------------ | :------------------------------- | :------------------------------------------------- |
+| `screenshotOnFail`  | `screenshot`                     | Default `on='fail'`, same behavior                 |
+| `pauseOnFail`       | `pause`                          | Default `on='fail'`, same behavior                 |
+| `stepByStepReport`  | `screenshot` with `slides: true` | Use `on=step` to capture every step                |
 
 ### New Plugins You Can Enable
 
diff --git a/docs/plugins.md b/docs/plugins.md
index a51c9633e..21278f98a 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -597,8 +597,8 @@ Additional config options:
 
 ## pause
 
-Pauses test execution interactively. Replaces the legacy `pauseOn` and `pauseOnFail`
-plugins. Default `on=fail` matches the old `pauseOnFail` behavior.
+Pauses test execution interactively. Replaces the legacy `pauseOnFail` plugin.
+Default `on=fail` matches the old `pauseOnFail` behavior.
 
 ```js
 plugins: {
@@ -625,8 +625,8 @@ CLI examples:
     npx codeceptjs run -p pause:on=file:path=tests/login_test.js;line=43
     npx codeceptjs run -p pause:on=url:pattern=/users/*
 
-> The legacy `pauseOn` and `pauseOnFail` plugins remain as deprecated aliases
-> that translate to the new syntax and emit a deprecation warning.
+> The legacy `pauseOnFail` plugin remains as a deprecated alias that emits a
+> deprecation warning and forwards to `pause` with `on=fail`.
 
 ### Parameters
 
@@ -728,6 +728,7 @@ CLI examples:
 
     npx codeceptjs run -p screenshot
     npx codeceptjs run -p screenshot:on=step
+    npx codeceptjs run -p screenshot:on=step;slides=true
     npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
     npx codeceptjs run -p screenshot:on=url:pattern=/users/*
 
@@ -735,48 +736,24 @@ Possible config options:
 
 *   `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
 *   `fullPageScreenshots`: make full page screenshots. Default: false.
+*   `slides`: generate a step-by-step slideshow report (works with `on=step|file|url`). Replaces the legacy `stepByStepReport` plugin. Default: false.
+*   `deleteSuccessful`: when `slides=true`, drop slideshow folders for passing tests. Default: true.
+*   `animateSlides`: when `slides=true`, animate transitions between slides. Default: true.
+*   `ignoreSteps`: when `slides=true`, RegExps of step names to skip in the slideshow.
 
-> The legacy `screenshotOnFail` plugin remains as a deprecated alias.
+#### Step-by-step slideshow
 
-### Parameters
-
-*   `config`  
-
-## stepByStepReport
-
-![step-by-step-report][6]
-
-Generates step by step report for a test.
-After each step in a test a screenshot is created. After test executed screenshots are combined into slideshow.
-By default, reports are generated only for failed tests.
+`screenshot:on=step;slides=true` writes a per-test `record_/index.html`
+slideshow and a top-level `records.html` index. The output is a self-contained
+HTML page with keyboard / dot navigation โ€” no external assets, no JavaScript
+frameworks.
 
-Run tests with plugin enabled:
-
-    npx codeceptjs run --plugins stepByStepReport
-
-#### Configuration
-
-```js
-"plugins": {
-   "stepByStepReport": {
-     "enabled": true
-   }
-}
-```
-
-Possible config options:
-
-*   `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true.
-*   `animateSlides`: should animation for slides to be used. Default: true.
-*   `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps.
-*   `fullPageScreenshots`: should full page screenshots be used. Default: false.
-*   `output`: a directory where reports should be stored. Default: `output`.
-*   `screenshotsForAllureReport`: If Allure plugin is enabled this plugin attaches each saved screenshot to allure report. Default: false.
-*   \`disableScreenshotOnFail : Disables the capturing of screeshots after the failed step. Default: true.
+> The legacy `screenshotOnFail` plugin remains as a deprecated alias. The legacy
+> `stepByStepReport` plugin has been replaced by `screenshot:slides=true`.
 
 ### Parameters
 
-*   `config` **any** 
+*   `config`  
 
 ## stepTimeout
 
diff --git a/examples/codecept.config.js b/examples/codecept.config.js
index c59e980be..c6da729d7 100644
--- a/examples/codecept.config.js
+++ b/examples/codecept.config.js
@@ -50,7 +50,7 @@ export const config = {
       enabled: false,
       services: ['selenium-standalone'],
     },
-    stepByStepReport: {},
+    screenshot: { on: 'step', slides: true },
     autoDelay: {
       enabled: false,
     },
diff --git a/lib/plugin/pause.js b/lib/plugin/pause.js
index 7a837ae29..00ed1edf1 100644
--- a/lib/plugin/pause.js
+++ b/lib/plugin/pause.js
@@ -13,8 +13,8 @@ import {
 const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
 
 /**
- * Pauses test execution interactively. Replaces the legacy `pauseOn` and `pauseOnFail`
- * plugins. The default `on=fail` matches the old `pauseOnFail` behavior.
+ * Pauses test execution interactively. Replaces the legacy `pauseOnFail`
+ * plugin. The default `on=fail` matches the old `pauseOnFail` behavior.
  *
  * #### Configuration
  *
diff --git a/lib/plugin/pauseOn.js b/lib/plugin/pauseOn.js
deleted file mode 100644
index 478e8b209..000000000
--- a/lib/plugin/pauseOn.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import output from '../output.js'
-import pause from './pause.js'
-
-let warned = false
-
-/**
- * @deprecated Use the `pause` plugin. Old positional CLI args are translated
- * to the new `on=key:param=value` form:
- *
- *   `-p pauseOn:fail`                                  โ†’ `-p pause:on=fail`
- *   `-p pauseOn:step`                                  โ†’ `-p pause:on=step`
- *   `-p pauseOn:file:tests/foo.js:43`                  โ†’ `-p pause:on=file:path=tests/foo.js;line=43`
- *   `-p pauseOn:url:/users/*`                          โ†’ `-p pause:on=url:pattern=/users/*`
- */
-export default function (config = {}) {
-  if (!warned) {
-    output.error('pauseOn is deprecated; use the `pause` plugin with the new on=key:param=value syntax.')
-    warned = true
-  }
-
-  const args = config._args || []
-  const translated = translatePositionalArgs(args)
-  return pause({ ...config, _args: translated })
-}
-
-function translatePositionalArgs(args) {
-  if (!args.length) return []
-  const mode = args[0]
-  if (mode === 'fail' || mode === 'step') {
-    return [`on=${mode}`]
-  }
-  if (mode === 'file') {
-    const path = args[1]
-    const line = args[2]
-    if (!path) return ['on=file']
-    return line ? [`on=file`, `path=${path};line=${line}`] : [`on=file`, `path=${path}`]
-  }
-  if (mode === 'url') {
-    const pattern = args.slice(1).join(':')
-    return pattern ? [`on=url`, `pattern=${pattern}`] : ['on=url']
-  }
-  return args
-}
diff --git a/lib/plugin/screenshot.js b/lib/plugin/screenshot.js
index eee865de0..c7fb2a948 100644
--- a/lib/plugin/screenshot.js
+++ b/lib/plugin/screenshot.js
@@ -1,5 +1,7 @@
+import crypto from 'crypto'
 import fs from 'fs'
 import path from 'path'
+import { mkdirp } from 'mkdirp'
 
 import Container from '../container.js'
 import recorder from '../recorder.js'
@@ -7,7 +9,7 @@ import event from '../event.js'
 import output from '../output.js'
 import store from '../store.js'
 
-import { fileExists } from '../utils.js'
+import { fileExists, deleteDir, template } from '../utils.js'
 import Codeceptjs from '../index.js'
 import { testToFileName } from '../mocha/test.js'
 import {
@@ -20,9 +22,13 @@ import {
 
 const defaultConfig = {
   on: 'fail',
+  slides: false,
   uniqueScreenshotNames: false,
   disableScreenshots: false,
   fullPageScreenshots: false,
+  animateSlides: true,
+  deleteSuccessful: true,
+  ignoreSteps: [],
 }
 
 const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
@@ -31,7 +37,9 @@ const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
  * Saves screenshots from the browser at points triggered by `on=`.
  *
  * Replaces the legacy `screenshotOnFail` plugin. Default `on=fail` preserves the
- * old behavior (screenshot when a test fails).
+ * old behavior (screenshot when a test fails). Pass `slides=true` (with `on=step`)
+ * to generate a step-by-step slideshow report โ€” replaces the legacy
+ * `stepByStepReport` plugin.
  *
  * #### Configuration
  *
@@ -57,12 +65,17 @@ const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url'])
  * * `uniqueScreenshotNames`: use unique names for screenshot. Default: false.
  * * `fullPageScreenshots`: make full page screenshots. Default: false.
  * * `disableScreenshots`: legacy switch to skip the plugin entirely.
+ * * `slides`: generate a step-by-step slideshow report (requires `on=step`). Default: false.
+ * * `deleteSuccessful`: when `slides=true`, drop slideshow directories of passing tests. Default: true.
+ * * `animateSlides`: when `slides=true`, animate transitions between slides. Default: true.
+ * * `ignoreSteps`: when `slides=true`, RegExps of step names to skip in the slideshow.
  *
  * CLI examples:
  *
  * ```
  * npx codeceptjs run -p screenshot
  * npx codeceptjs run -p screenshot:on=step
+ * npx codeceptjs run -p screenshot:on=step;slides=true
  * npx codeceptjs run -p screenshot:on=file:path=tests/login_test.js
  * npx codeceptjs run -p screenshot:on=url:pattern=/users/*
  * ```
@@ -81,6 +94,7 @@ export default function (config = {}) {
 
   const helpers = Container.helpers()
   const options = Object.assign({}, defaultConfig, helper.options, config)
+  options.slides = cliArgs.slides ?? config.slides ?? defaultConfig.slides
 
   if (helpers.Mochawesome?.config) {
     options.uniqueScreenshotNames = helpers.Mochawesome.config.uniqueScreenshotNames
@@ -93,6 +107,10 @@ export default function (config = {}) {
 
   if (options.disableScreenshots) return
 
+  if (options.slides) {
+    return wireSlides(options, trigger)
+  }
+
   switch (trigger.on) {
     case 'fail':
       return wireOnFail(options)
@@ -251,3 +269,309 @@ async function takeScreenshot(test, fileName, options) {
     }
   }
 }
+
+function wireSlides(options, trigger) {
+  const reportDir = options.output
+    ? path.resolve(store.codeceptDir, options.output)
+    : (store.outputDir || './_output')
+
+  const stepFilter = makeStepFilter(trigger, options)
+  const recordedTests = {}
+
+  let dir
+  let stepNum
+  let slides = {}
+  let savedStep = null
+  let currentTest = null
+  let scenarioFailed = false
+
+  event.dispatcher.on(event.suite.before, () => {
+    stepNum = -1
+  })
+
+  event.dispatcher.on(event.test.before, test => {
+    const hash = crypto.createHash('sha256').update(test.file + test.title).digest('hex')
+    dir = path.join(reportDir, `record_${hash}`)
+    mkdirp.sync(dir)
+    stepNum = 0
+    slides = {}
+    savedStep = null
+    currentTest = test
+    scenarioFailed = false
+  })
+
+  event.dispatcher.on(event.step.failed, step => {
+    recorder.add('slides: failed step', async () => persistStep(step), true)
+  })
+
+  event.dispatcher.on(event.step.after, step => {
+    recorder.add('slides: step', async () => persistStep(step), true)
+  })
+
+  event.dispatcher.on(event.test.passed, test => {
+    if (options.deleteSuccessful) {
+      deleteDir(dir)
+      return
+    }
+    persist(test)
+  })
+
+  event.dispatcher.on(event.test.failed, (test, _err, hookName) => {
+    if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') return
+    persist(test)
+  })
+
+  event.dispatcher.on(event.all.result, () => {
+    if (Object.keys(recordedTests).length === 0) return
+    writeIndex(reportDir, recordedTests)
+  })
+
+  if (event.workers && event.workers.result) {
+    event.dispatcher.on(event.workers.result, async () => {
+      await recorder.add(() => {
+        const tests = scanRecordDirs(reportDir)
+        if (Object.keys(tests).length) writeIndex(reportDir, tests)
+      })
+    })
+  }
+
+  async function persistStep(step) {
+    if (stepNum === -1) return
+    if (savedStep === step) return
+    if (scenarioFailed) return
+    if (step.metaStep && step.metaStep.name === 'BeforeSuite') return
+    if (!currentTest) return
+    if (!stepFilter(step)) return
+    if (isStepIgnored(step, options.ignoreSteps)) return
+
+    const fileName = `${String(stepNum).padStart(4, '0')}.png`
+    if (step.status === 'failed') scenarioFailed = true
+    stepNum++
+    slides[fileName] = step
+
+    const helper = getBrowserHelper()
+    if (!helper || typeof helper.saveScreenshot !== 'function') return
+
+    try {
+      const screenshotPath = path.join(dir, fileName)
+      await helper.saveScreenshot(screenshotPath, options.fullPageScreenshots)
+      step.artifacts = step.artifacts || {}
+      step.artifacts.screenshot = screenshotPath
+
+      currentTest.artifacts = currentTest.artifacts || {}
+      currentTest.artifacts.screenshots = currentTest.artifacts.screenshots || []
+      currentTest.artifacts.screenshots.push(screenshotPath)
+    } catch (err) {
+      output.plugin('screenshot', `Can't save step screenshot: ${err.message}`)
+    } finally {
+      savedStep = step
+    }
+  }
+
+  function persist(test) {
+    if (!Object.keys(slides).length) return
+
+    const slideHtml = Object.keys(slides)
+      .sort()
+      .map((fileName, idx) => {
+        const step = slides[fileName]
+        const caption = step.toString().replace(/\[\d{2}m/g, '')
+        const failed = step.status === 'failed' ? ' is-failed' : ''
+        return template(SLIDE_TEMPLATE, {
+          image: fileName,
+          caption,
+          index: idx + 1,
+          activeClass: idx === 0 ? ' is-active' : '',
+          failed,
+        })
+      })
+      .join('')
+
+    const dotHtml = Object.keys(slides)
+      .map((_, idx) => ``)
+      .join('')
+
+    const html = template(SLIDESHOW_TEMPLATE, {
+      title: test.title,
+      feature: (test.parent && test.parent.title) || '',
+      slides: slideHtml,
+      dots: dotHtml,
+      animate: options.animateSlides ? 'true' : 'false',
+    })
+
+    const indexFile = path.join(dir, 'index.html')
+    fs.writeFileSync(indexFile, html)
+    recordedTests[`${(test.parent && test.parent.title) || ''}: ${test.title}`] = path.relative(reportDir, indexFile)
+  }
+}
+
+function makeStepFilter(trigger, options) {
+  if (trigger.on === 'file' && trigger.path) {
+    return step => matchStepFile(step, trigger.path, trigger.line)
+  }
+  if (trigger.on === 'fail') {
+    return step => step.status === 'failed'
+  }
+  return () => true
+}
+
+function isStepIgnored(step, patterns) {
+  if (!patterns || !patterns.length) return false
+  for (const pattern of patterns) {
+    if (step.name && step.name.match(pattern)) return true
+  }
+  return false
+}
+
+function scanRecordDirs(reportDir) {
+  const out = {}
+  try {
+    for (const item of fs.readdirSync(reportDir, { withFileTypes: true })) {
+      if (!item.isDirectory() || !item.name.startsWith('record_')) continue
+      const indexFile = path.join(reportDir, item.name, 'index.html')
+      if (!fs.existsSync(indexFile)) continue
+      const html = fs.readFileSync(indexFile, 'utf-8')
+      const titleMatch = html.match(/([^<]*)<\/title>/)
+      const label = titleMatch ? titleMatch[1].replace(/^Slides โ€” /, '') : item.name
+      out[label] = `${item.name}/index.html`
+    }
+  } catch (err) {
+    // ignore
+  }
+  return out
+}
+
+function writeIndex(reportDir, recordedTests) {
+  const items = Object.entries(recordedTests)
+    .map(([name, href]) => `<li><a href="${href}">${escapeHtml(name)}</a></li>`)
+    .join('\n')
+
+  const html = template(INDEX_TEMPLATE, {
+    time: new Date().toString(),
+    records: items,
+  })
+
+  const indexPath = path.join(reportDir, 'records.html')
+  fs.writeFileSync(indexPath, html)
+  output.print(`Step-by-step preview: file://${indexPath}`)
+}
+
+function escapeHtml(s) {
+  return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
+}
+
+const SLIDE_TEMPLATE = `
+<figure class="slides__slide{{activeClass}}{{failed}}" data-index="{{index}}">
+  <img class="slides__image" src="{{image}}" alt="">
+  <figcaption class="slides__caption">
+    <span class="slides__step">{{index}}</span>
+    <span class="slides__text">{{caption}}</span>
+  </figcaption>
+</figure>
+`
+
+const SLIDESHOW_TEMPLATE = `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Slides โ€” {{feature}}: {{title}}
+
+
+
+
+ « back + {{feature}} + {{title}} +
+
+ {{slides}} + + +
+ +

Use ← / → to navigate, click sides of image, or use the dots below.

+ + + +` + +const INDEX_TEMPLATE = ` + + + + +Step-by-step Reports + + + +

Step-by-step Reports

+
{{time}}
+
    + {{records}} +
+ + +` diff --git a/lib/plugin/stepByStepReport.js b/lib/plugin/stepByStepReport.js deleted file mode 100644 index 390f7a635..000000000 --- a/lib/plugin/stepByStepReport.js +++ /dev/null @@ -1,432 +0,0 @@ -import colors from 'chalk' -import crypto from 'crypto' -import figures from 'figures' -import fs from 'fs' -import { mkdirp } from 'mkdirp' -import path from 'path' -import * as cheerio from 'cheerio' - -import store from '../store.js' -import Container from '../container.js' -import recorder from '../recorder.js' -import event from '../event.js' -import output from '../output.js' -import { template, deleteDir } from '../utils.js' - -const supportedHelpers = Container.STANDARD_ACTING_HELPERS - -const defaultConfig = { - deleteSuccessful: true, - animateSlides: true, - ignoreSteps: [], - fullPageScreenshots: false, - output: store.outputDir, - screenshotsForAllureReport: false, - disableScreenshotOnFail: true, -} - -const templates = {} - -/** - * ![step-by-step-report](https://codecept.io/img/codeceptjs-slideshow.gif) - * - * Generates step by step report for a test. - * After each step in a test a screenshot is created. After test executed screenshots are combined into slideshow. - * By default, reports are generated only for failed tests. - * - * - * Run tests with plugin enabled: - * - * ``` - * npx codeceptjs run --plugins stepByStepReport - * ``` - * - * #### Configuration - * - * ```js - * "plugins": { - * "stepByStepReport": { - * "enabled": true - * } - * } - * ``` - * - * Possible config options: - * - * * `deleteSuccessful`: do not save screenshots for successfully executed tests. Default: true. - * * `animateSlides`: should animation for slides to be used. Default: true. - * * `ignoreSteps`: steps to ignore in report. Array of RegExps is expected. Recommended to skip `grab*` and `wait*` steps. - * * `fullPageScreenshots`: should full page screenshots be used. Default: false. - * * `output`: a directory where reports should be stored. Default: `output`. - * * `screenshotsForAllureReport`: If Allure plugin is enabled this plugin attaches each saved screenshot to allure report. Default: false. - * * `disableScreenshotOnFail : Disables the capturing of screeshots after the failed step. Default: true. - * - * @param {*} config - */ - -export default function (config) { - const helpers = Container.helpers() - let helper - - config = Object.assign(defaultConfig, config) - - for (const helperName of supportedHelpers) { - if (Object.keys(helpers).indexOf(helperName) > -1) { - helper = helpers[helperName] - } - } - - if (!helper) return // no helpers for screenshot - - let dir - let stepNum - let slides = {} - let error - let savedStep = null - let currentTest = null - let scenarioFailed = false - - const recordedTests = {} - const pad = '0000' - const reportDir = config.output ? path.resolve(store.codeceptDir, config.output) : defaultConfig.output - - event.dispatcher.on(event.suite.before, suite => { - stepNum = -1 - }) - - event.dispatcher.on(event.test.before, test => { - const sha256hash = crypto - .createHash('sha256') - .update(test.file + test.title) - .digest('hex') - dir = path.join(reportDir, `record_${sha256hash}`) - mkdirp.sync(dir) - stepNum = 0 - error = null - slides = {} - savedStep = null - currentTest = test - }) - - event.dispatcher.on(event.step.failed, step => { - recorder.add('screenshot of failed test', async () => persistStep(step), true) - }) - - event.dispatcher.on(event.step.after, step => { - recorder.add('screenshot of step of test', async () => persistStep(step), true) - }) - - event.dispatcher.on(event.test.passed, test => { - if (!config.deleteSuccessful) return persist(test) - // cleanup - deleteDir(dir) - }) - - event.dispatcher.on(event.test.failed, (test, _err, hookName) => { - if (hookName === 'BeforeSuite' || hookName === 'AfterSuite') { - // no browser here - return - } - - persist(test) - }) - - event.dispatcher.on(event.all.result, () => { - if (Object.keys(recordedTests).length === 0 || !Object.keys(slides).length) return - generateRecordsHtml(recordedTests) - }) - - event.dispatcher.on(event.workers.result, async () => { - await recorder.add(() => { - const recordedTests = getRecordFoldersWithDetails(reportDir) - generateRecordsHtml(recordedTests) - }) - }) - - function getRecordFoldersWithDetails(dirPath) { - let results = {} - - try { - const items = fs.readdirSync(dirPath, { withFileTypes: true }) - - items.forEach(item => { - if (item.isDirectory() && item.name.startsWith('record_')) { - const recordFolderPath = path.join(dirPath, item.name) - const indexPath = path.join(recordFolderPath, 'index.html') - - let name = '' - if (fs.existsSync(indexPath)) { - try { - const htmlContent = fs.readFileSync(indexPath, 'utf-8') - const $ = cheerio.load(htmlContent) - name = $('.navbar-brand').text().trim() - } catch (err) { - console.error(`Error reading index.html in ${recordFolderPath}:`, err.message) - } - } - - results[name || 'Unkown'] = `${item.name}/index.html` - } - }) - } catch (err) { - console.error(`Error reading directory ${dirPath}:`, err.message) - } - - return results - } - - function generateRecordsHtml(recordedTests) { - let links = '' - - for (const link in recordedTests) { - links += `
  • ${link}
  • \n` - } - - const indexHTML = template(templates.index, { - time: Date().toString(), - records: links, - }) - - fs.writeFileSync(path.join(reportDir, 'records.html'), indexHTML) - - output.print(`${figures.circleFilled} Step-by-step preview: ${colors.white.bold(`file://${reportDir}/records.html`)}`) - } - - async function persistStep(step) { - if (stepNum === -1) return // Ignore steps from BeforeSuite function - if (isStepIgnored(step)) return - if (savedStep === step) return // already saved - // Ignore steps from BeforeSuite function - if (scenarioFailed && config.disableScreenshotOnFail) return - if (step.metaStep && step.metaStep.name === 'BeforeSuite') return - if (!currentTest) return // Ignore steps from AfterSuite - - const fileName = `${pad.substring(0, pad.length - stepNum.toString().length) + stepNum.toString()}.png` - if (step.status === 'failed') { - scenarioFailed = true - } - stepNum++ - slides[fileName] = step - try { - const screenshotPath = path.join(dir, fileName) - await helper.saveScreenshot(screenshotPath, config.fullPageScreenshots) - - step.artifacts = step.artifacts || {} - step.artifacts.screenshot = screenshotPath - } catch (err) { - output.plugin(`Can't save step screenshot: ${err}`) - error = err - return - } finally { - savedStep = step - } - - if (!currentTest.artifacts.screenshots) currentTest.artifacts.screenshots = [] - // added attachments to test - currentTest.artifacts.screenshots.push(path.join(dir, fileName)) - - const allureReporter = Container.plugins('allure') - if (allureReporter && config.screenshotsForAllureReport) { - output.plugin('stepByStepReport', 'Adding screenshot to Allure') - allureReporter.addAttachment(`Screenshot of step ${step}`, fs.readFileSync(path.join(dir, fileName)), 'image/png') - } - } - - function persist(test) { - if (error) return - - let indicatorHtml = '' - let slideHtml = '' - - for (const i in slides) { - const step = slides[i] - const stepNum = parseInt(i, 10) - indicatorHtml += template(templates.indicator, { - step: stepNum, - isActive: stepNum ? '' : 'class="active"', - }) - - slideHtml += template(templates.slides, { - image: i, - caption: step.toString().replace(/\[\d{2}m/g, ''), // remove ANSI escape sequence - isActive: stepNum ? '' : 'active', - isError: step.status === 'failed' ? 'error' : '', - }) - } - - const html = template(templates.global, { - indicators: indicatorHtml, - slides: slideHtml, - feature: test.parent && test.parent.title, - test: test.title, - carousel_class: config.animateSlides ? ' slide' : '', - }) - - const index = path.join(dir, 'index.html') - fs.writeFileSync(index, html) - recordedTests[`${test.parent.title}: ${test.title}`] = path.relative(reportDir, index) - } - - function isStepIgnored(step) { - if (!config.ignoreSteps) return - for (const pattern of config.ignoreSteps || []) { - if (step.name.match(pattern)) return true - } - return false - } -} - -templates.slides = ` -
    -
    - -
    - -
    -` - -templates.indicator = ` -
  • -` - -templates.index = ` - - - - - - Step by Steps Report - - - - - - -
    -

    Recorded @ {{time}}

    -
      - {{records}} -
    -
    - - - -` - -templates.global = ` - - - - - - Recorder Result - - - - - - - - - -
    - - - - - - - - - - - - - - -
    - - - - - - - - - - - -` diff --git a/test/acceptance/codecept.Playwright.coverage.js b/test/acceptance/codecept.Playwright.coverage.js index f40274703..f189d75ae 100644 --- a/test/acceptance/codecept.Playwright.coverage.js +++ b/test/acceptance/codecept.Playwright.coverage.js @@ -32,10 +32,6 @@ export const config = { bootstrap: installCodeceptjs, mocha: {}, plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, coverage: { enabled: true, debug: true, diff --git a/test/acceptance/codecept.Playwright.js b/test/acceptance/codecept.Playwright.js index efa845907..dedbfe19f 100644 --- a/test/acceptance/codecept.Playwright.js +++ b/test/acceptance/codecept.Playwright.js @@ -35,10 +35,6 @@ export const config = { bootstrap: installCodeceptjs, mocha: {}, plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, retryFailedStep: { enabled: true, }, diff --git a/test/acceptance/codecept.Puppeteer.js b/test/acceptance/codecept.Puppeteer.js index 641528757..b21abdea4 100644 --- a/test/acceptance/codecept.Puppeteer.js +++ b/test/acceptance/codecept.Puppeteer.js @@ -24,12 +24,6 @@ export const config = { include: {}, bootstrap: installCodeceptjs, mocha: {}, - plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, - }, name: 'acceptance', gherkin: { features: './gherkin/*.feature', diff --git a/test/acceptance/codecept.WebDriver.js b/test/acceptance/codecept.WebDriver.js index 3df695bd6..019011f29 100644 --- a/test/acceptance/codecept.WebDriver.js +++ b/test/acceptance/codecept.WebDriver.js @@ -33,12 +33,6 @@ export const config = { }, mocha: {}, name: 'acceptance', - plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, - }, gherkin: { features: './gherkin/*.feature', steps: ['./gherkin/steps.js'], diff --git a/test/acceptance/codecept.els.Playwright.js b/test/acceptance/codecept.els.Playwright.js index 93e270171..d80fd351d 100644 --- a/test/acceptance/codecept.els.Playwright.js +++ b/test/acceptance/codecept.els.Playwright.js @@ -22,11 +22,5 @@ export const config = { include: {}, bootstrap: false, mocha: {}, - plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, - }, name: 'acceptance', } diff --git a/test/bdd/codecept.faker.js b/test/bdd/codecept.faker.js index 08cdc97a3..d587e1669 100644 --- a/test/bdd/codecept.faker.js +++ b/test/bdd/codecept.faker.js @@ -20,10 +20,6 @@ export const config = { bootstrap: false, mocha: {}, plugins: { - screenshot: { - enabled: true, - on: 'fail', - }, fakerTransform: { enabled: true, }, From 395132a9a0ecbf12d8c6cd748ae6e9f51434d7fb Mon Sep 17 00:00:00 2001 From: DavertMik Date: Wed, 29 Apr 2026 14:56:47 +0300 Subject: [PATCH 5/5] refactor(plugins): centralize on= validation in resolveTrigger resolveTrigger now takes { name, validModes }, performs the unknown-mode and missing-path/pattern checks itself, prints the error via output.error, and returns null. Each plugin shrinks to one call + early-return; heal passes its narrower mode list as { validModes: ['fail', 'file', 'url'] }. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/plugin/aiTrace.js | 18 ++---------------- lib/plugin/heal.js | 21 +++++---------------- lib/plugin/pause.js | 18 ++---------------- lib/plugin/screenshot.js | 18 ++---------------- lib/utils/pluginParser.js | 33 ++++++++++++++++++++++++++++++--- 5 files changed, 41 insertions(+), 67 deletions(-) diff --git a/lib/plugin/aiTrace.js b/lib/plugin/aiTrace.js index e34e23f10..e24f8db49 100644 --- a/lib/plugin/aiTrace.js +++ b/lib/plugin/aiTrace.js @@ -20,8 +20,6 @@ import { const supportedHelpers = Container.STANDARD_ACTING_HELPERS -const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url']) - const defaultConfig = { on: 'step', deleteSuccessful: false, @@ -79,20 +77,8 @@ export default function (config = {}) { let helper const cliArgs = parsePluginArgs(config._args) - const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }) - - if (!VALID_MODES.has(trigger.on)) { - output.error(`aiTrace: unknown on="${trigger.on}". Valid: fail, test, step, file, url`) - return - } - if (trigger.on === 'file' && !trigger.path) { - output.error('aiTrace:on=file requires path=. Example: -p aiTrace:on=file:path=tests/foo.js') - return - } - if (trigger.on === 'url' && !trigger.pattern) { - output.error('aiTrace:on=url requires pattern=. Example: -p aiTrace:on=url:pattern=/users/*') - return - } + const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'aiTrace' }) + if (!trigger) return config = Object.assign(defaultConfig, config) diff --git a/lib/plugin/heal.js b/lib/plugin/heal.js index 0fc36ade9..c8237dcf4 100644 --- a/lib/plugin/heal.js +++ b/lib/plugin/heal.js @@ -19,8 +19,6 @@ import { } from '../utils/pluginParser.js' -const VALID_MODES = new Set(['fail', 'file', 'url']) - const defaultConfig = { on: 'fail', healLimit: 2, @@ -64,20 +62,11 @@ export default function (config = {}) { } const cliArgs = parsePluginArgs(config._args) - const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }) - - if (!VALID_MODES.has(trigger.on)) { - output.error(`heal: unsupported on="${trigger.on}". Valid: fail, file, url`) - return - } - if (trigger.on === 'file' && !trigger.path) { - output.error('heal:on=file requires path=. Example: -p heal:on=file:path=tests/foo.js') - return - } - if (trigger.on === 'url' && !trigger.pattern) { - output.error('heal:on=url requires pattern=. Example: -p heal:on=url:pattern=/users/*') - return - } + const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { + name: 'heal', + validModes: ['fail', 'file', 'url'], + }) + if (!trigger) return let currentTest = null let currentStep = null diff --git a/lib/plugin/pause.js b/lib/plugin/pause.js index 00ed1edf1..9bec1cfe7 100644 --- a/lib/plugin/pause.js +++ b/lib/plugin/pause.js @@ -10,8 +10,6 @@ import { getBrowserHelper, } from '../utils/pluginParser.js' -const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url']) - /** * Pauses test execution interactively. Replaces the legacy `pauseOnFail` * plugin. The default `on=fail` matches the old `pauseOnFail` behavior. @@ -46,12 +44,8 @@ const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url']) */ export default function (config = {}) { const cliArgs = parsePluginArgs(config._args) - const trigger = resolveTrigger(cliArgs, config, { on: 'fail' }) - - if (!VALID_MODES.has(trigger.on)) { - output.error(`pause: unknown on="${trigger.on}". Valid: fail, test, step, file, url`) - return - } + const trigger = resolveTrigger(cliArgs, config, { on: 'fail' }, { name: 'pause' }) + if (!trigger) return switch (trigger.on) { case 'fail': @@ -61,16 +55,8 @@ export default function (config = {}) { case 'step': return initStepMode() case 'file': - if (!trigger.path) { - output.error('pause:on=file requires path=. Example: -p pause:on=file:path=tests/foo.js') - return - } return initFileMode(trigger.path, trigger.line) case 'url': - if (!trigger.pattern) { - output.error('pause:on=url requires pattern=. Example: -p pause:on=url:pattern=/users/*') - return - } return initUrlMode(trigger.pattern) } } diff --git a/lib/plugin/screenshot.js b/lib/plugin/screenshot.js index c7fb2a948..ca58d7367 100644 --- a/lib/plugin/screenshot.js +++ b/lib/plugin/screenshot.js @@ -31,8 +31,6 @@ const defaultConfig = { ignoreSteps: [], } -const VALID_MODES = new Set(['fail', 'test', 'step', 'file', 'url']) - /** * Saves screenshots from the browser at points triggered by `on=`. * @@ -85,12 +83,8 @@ export default function (config = {}) { if (!helper) return const cliArgs = parsePluginArgs(config._args) - const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }) - - if (!VALID_MODES.has(trigger.on)) { - output.error(`screenshot: unknown on="${trigger.on}". Valid: fail, test, step, file, url`) - return - } + const trigger = resolveTrigger(cliArgs, config, { on: defaultConfig.on }, { name: 'screenshot' }) + if (!trigger) return const helpers = Container.helpers() const options = Object.assign({}, defaultConfig, helper.options, config) @@ -119,16 +113,8 @@ export default function (config = {}) { case 'step': return wireOnStep(options, () => true) case 'file': - if (!trigger.path) { - output.error('screenshot:on=file requires path=. Example: -p screenshot:on=file:path=tests/foo.js') - return - } return wireOnStep(options, step => matchStepFile(step, trigger.path, trigger.line)) case 'url': - if (!trigger.pattern) { - output.error('screenshot:on=url requires pattern=. Example: -p screenshot:on=url:pattern=/users/*') - return - } return wireOnUrl(options, trigger.pattern) } } diff --git a/lib/utils/pluginParser.js b/lib/utils/pluginParser.js index 25ed2aa3f..8bcf8a838 100644 --- a/lib/utils/pluginParser.js +++ b/lib/utils/pluginParser.js @@ -1,8 +1,10 @@ import Container from '../container.js' +import output from '../output.js' const supportedHelpers = Container.STANDARD_ACTING_HELPERS const RESERVED_KEYS = new Set(['on', 'path', 'line', 'pattern']) +const ALL_MODES = ['fail', 'test', 'step', 'file', 'url'] /** * Parse a plugin's _args (from CLI `-p plugin:key=value:key=value`) into a flat dict. @@ -44,12 +46,37 @@ function coerce(v) { } /** - * Compose CLI args > config > defaults into a normalized trigger spec. - * Returns `{ on, path, line, pattern, ...rest }`. `line` is coerced to a number. + * Compose CLI args > config > defaults into a normalized trigger spec, then + * validate it. Returns `{ on, path, line, pattern, ...rest }` with `line` + * coerced to a number, or `null` if validation failed (an error is printed). + * + * @param {object} cliArgs โ€” output of parsePluginArgs(config._args) + * @param {object} config โ€” full plugin config object + * @param {object} defaults โ€” fallback values, e.g. `{ on: 'fail' }` + * @param {object} options + * @param {string} options.name โ€” plugin name, used in error messages + * @param {string[]} [options.validModes] โ€” accepted values for `on` + * (default: fail, test, step, file, url) */ -export function resolveTrigger(cliArgs = {}, config = {}, defaults = {}) { +export function resolveTrigger(cliArgs = {}, config = {}, defaults = {}, options = {}) { + const { name = 'plugin', validModes = ALL_MODES } = options const merged = { ...defaults, ...pickKnown(config), ...cliArgs } if (merged.line != null) merged.line = parseInt(merged.line, 10) + + const valid = new Set(validModes) + if (!valid.has(merged.on)) { + output.error(`${name}: unknown on="${merged.on}". Valid: ${validModes.join(', ')}`) + return null + } + if (merged.on === 'file' && !merged.path) { + output.error(`${name}:on=file requires path=. Example: -p ${name}:on=file:path=tests/foo.js`) + return null + } + if (merged.on === 'url' && !merged.pattern) { + output.error(`${name}:on=url requires pattern=. Example: -p ${name}:on=url:pattern=/users/*`) + return null + } + return merged }