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 13650e1240e2836d78cba78913e5c19ebe272ae5 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Fri, 1 May 2026 02:17:26 +0300
Subject: [PATCH 3/5] feat(mcp): per-test plugin overrides + shell session
 lifecycle

- run_test / run_step_by_step accept a `plugins` object that mirrors
  the CLI `-p` flag (e.g. `{ screencast: { saveScreenshots: true },
  aiTrace: { on: 'fail' }, pause: true }`). Container is re-initialized
  when the plugin set changes between calls.
- start_browser / stop_browser now drive a full shell session like
  `codeceptjs shell`: bootstrap, recorder.start, suite.before /
  test.before on start; matching after events plus codecept.teardown
  on stop.
- run_code / snapshot now require an active session (shell or paused
  test) and return a clear error pointing the agent at start_browser
  or run_test otherwise. Plugins and listeners that depend on
  suite.before / test.before now fire correctly during MCP usage.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 bin/mcp-server.js | 131 ++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 110 insertions(+), 21 deletions(-)

diff --git a/bin/mcp-server.js b/bin/mcp-server.js
index 776e31d22..803d2ee61 100644
--- a/bin/mcp-server.js
+++ b/bin/mcp-server.js
@@ -14,6 +14,7 @@ import {
   writeTraceMarkdown,
 } from '../lib/utils/trace.js'
 import event from '../lib/event.js'
+import recorder from '../lib/recorder.js'
 import { setPauseHandler, pauseNow } from '../lib/pause.js'
 import { EventEmitter } from 'events'
 import { fileURLToPath, pathToFileURL } from 'url'
@@ -32,6 +33,85 @@ const __dirname = dirname(__filename)
 let codecept = null
 let containerInitialized = false
 let browserStarted = false
+let shellSessionActive = false
+let bootstrapDone = false
+let currentPluginsSig = ''
+
+const SESSION_REQUIRED_ERROR = 'No active CodeceptJS session. Call `start_browser` to open a shell session, or `run_test` (use `pause()` in the test, or set `pauseAt`) to inspect during a test run.'
+
+async function ensureBootstrap() {
+  if (bootstrapDone) return
+  await codecept.bootstrap()
+  bootstrapDone = true
+}
+
+async function startShellSession() {
+  if (shellSessionActive) return
+  await ensureBootstrap()
+  recorder.start()
+  event.emit(event.suite.before, {
+    fullTitle: () => 'MCP Session',
+    tests: [],
+    retries: () => {},
+  })
+  event.emit(event.test.before, {
+    title: 'MCP Session',
+    artifacts: {},
+    retries: () => {},
+  })
+  shellSessionActive = true
+}
+
+async function endShellSession() {
+  if (!shellSessionActive) return
+  try { event.emit(event.test.after, {}) } catch {}
+  try { event.emit(event.suite.after, {}) } catch {}
+  try { event.emit(event.all.result, {}) } catch {}
+  shellSessionActive = false
+}
+
+function ensureSession() {
+  if (shellSessionActive || pausedController) return
+  throw new Error(SESSION_REQUIRED_ERROR)
+}
+
+function normalizePluginOverrides(plugins) {
+  if (!plugins || typeof plugins !== 'object') return {}
+  const out = {}
+  for (const [name, opts] of Object.entries(plugins)) {
+    if (opts === false) continue
+    out[name] = (opts === true || opts == null) ? {} : opts
+  }
+  return out
+}
+
+function applyPluginOverrides(config, plugins) {
+  config.plugins = config.plugins || {}
+  for (const [name, opts] of Object.entries(plugins)) {
+    config.plugins[name] = { ...(config.plugins[name] || {}), ...opts, enabled: true }
+  }
+}
+
+function pluginsSignature(plugins) {
+  const keys = Object.keys(plugins).sort()
+  return JSON.stringify(keys.map(k => [k, plugins[k]]))
+}
+
+async function teardownContainer() {
+  if (!containerInitialized) return
+  await endShellSession()
+  const helpers = container.helpers()
+  for (const helperName in helpers) {
+    const helper = helpers[helperName]
+    try { if (helper._finish) await helper._finish() } catch {}
+  }
+  try { if (codecept?.teardown) await codecept.teardown() } catch {}
+  containerInitialized = false
+  browserStarted = false
+  bootstrapDone = false
+  codecept = null
+  currentPluginsSig = ''
+}
 
 let runLock = Promise.resolve()
 async function withLock(fn) {
@@ -318,8 +398,14 @@ function pausedPayload() {
   }
 }
 
-async function initCodecept(configPath) {
-  if (containerInitialized) return
+async function initCodecept(configPath, pluginOverrides) {
+  const plugins = normalizePluginOverrides(pluginOverrides)
+  const sig = pluginsSignature(plugins)
+
+  if (containerInitialized) {
+    if (!Object.keys(plugins).length || sig === currentPluginsSig) return
+    await teardownContainer()
+  }
 
   const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
 
@@ -344,6 +430,8 @@ async function initCodecept(configPath) {
   const { getConfig } = await import('../lib/command/utils.js')
   const config = await getConfig(configPath)
 
+  applyPluginOverrides(config, plugins)
+
   codecept = new Codecept(config, {})
   await codecept.init(testRoot)
   await container.create(config, {})
@@ -351,8 +439,11 @@ async function initCodecept(configPath) {
 
   containerInitialized = true
   browserStarted = true
+  currentPluginsSig = sig
 }
 
+const PLUGINS_DESCRIPTION = 'Enable CodeceptJS plugins for this run, mirroring the CLI `-p` flag. Keys are plugin names (e.g. screencast, aiTrace, pause, pageInfo, heal, retryFailedStep, screenshotOnFail, autoDelay). Value `true` or `{}` enables with defaults; an object merges options, e.g. {"screencast": {"saveScreenshots": true}, "aiTrace": {"on": "fail"}}. Changing the plugin set tears down and re-initializes the container (closes the browser).'
+
 const server = new Server(
   { name: 'codeceptjs-mcp-server', version: '1.0.0' },
   { capabilities: { tools: {} } }
@@ -394,6 +485,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
           timeout: { type: 'number' },
           config: { type: 'string' },
           pauseAt: { type: 'number', description: '1-based step index. Test will pause after the Nth step completes. Useful as a programmatic breakpoint without editing the test.' },
+          plugins: { type: 'object', description: PLUGINS_DESCRIPTION, additionalProperties: true },
         },
         required: ['test'],
       },
@@ -407,6 +499,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
           test: { type: 'string' },
           timeout: { type: 'number' },
           config: { type: 'string' },
+          plugins: { type: 'object', description: PLUGINS_DESCRIPTION, additionalProperties: true },
         },
         required: ['test'],
       },
@@ -497,33 +590,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
 
       case 'start_browser': {
         const configPath = args?.config
-        if (browserStarted) {
-          return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started' }, null, 2) }] }
+        if (browserStarted && shellSessionActive) {
+          return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active' }, null, 2) }] }
         }
         await initCodecept(configPath)
-        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully' }, null, 2) }] }
+        await startShellSession()
+        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started โ€” run_code and snapshot are now available' }, null, 2) }] }
       }
 
       case 'stop_browser': {
         if (!containerInitialized) {
           return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
         }
-
-        const helpers = container.helpers()
-        for (const helperName in helpers) {
-          const helper = helpers[helperName]
-          try { if (helper._finish) await helper._finish() } catch {}
-        }
-
-        browserStarted = false
-        containerInitialized = false
-
+        await teardownContainer()
         return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
       }
 
       case 'snapshot': {
         const { config: configPath, fullPage = false } = args || {}
         await initCodecept(configPath)
+        ensureSession()
 
         const helper = pickActingHelper(container.helpers())
         if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
@@ -588,6 +674,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
       case 'run_code': {
         const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
         await initCodecept(configPath)
+        ensureSession()
 
         const I = container.support('I')
         if (!I) throw new Error('I object not available. Make sure helpers are configured.')
@@ -686,8 +773,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           if (pausedController) {
             throw new Error('A previous run_test is still paused. Call "continue" first.')
           }
-          const { test, timeout = 60000, config: configPathArg, pauseAt } = args || {}
-          await initCodecept(configPathArg)
+          const { test, timeout = 60000, config: configPathArg, pauseAt, plugins } = args || {}
+          await initCodecept(configPathArg, plugins)
+          await endShellSession()
 
           return await withSilencedIO(async () => {
             codecept.loadTests()
@@ -740,7 +828,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             let runError = null
             const runPromise = (async () => {
               try {
-                await codecept.bootstrap()
+                await ensureBootstrap()
                 await codecept.run(testFile)
               } catch (err) {
                 runError = err
@@ -779,8 +867,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           if (pausedController) {
             throw new Error('A previous run is still paused. Call "continue" first.')
           }
-          const { test, timeout = 60000, config: configPath } = args || {}
-          await initCodecept(configPath)
+          const { test, timeout = 60000, config: configPath, plugins } = args || {}
+          await initCodecept(configPath, plugins)
+          await endShellSession()
 
           return await withSilencedIO(async () => {
             codecept.loadTests()
@@ -832,7 +921,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             let runError = null
             const runPromise = (async () => {
               try {
-                await codecept.bootstrap()
+                await ensureBootstrap()
                 await codecept.run(testFile)
               } catch (err) {
                 runError = err

From 576fd4fbb8f29e0ff687ea5b2de4d26f8eb6a5b0 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Fri, 1 May 2026 02:57:36 +0300
Subject: [PATCH 4/5] feat(trace): TraceReader API + ariaDiff in run_code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Move artifact-on-disk reading from mcp-server.js into a TraceReader class
in lib/utils/trace.js. Python-style indexing via first / last / nth, kept
generic across kinds (aria / html / screenshot / console / storage). Sort
by filename โ€” aiTrace's zero-padded step prefix means a lexical sort is
chronological.

run_code uses it to diff ARIA between the last aiTrace capture and the
new one produced by the steps inside this call:

  const reader = new TraceReader(currentAiTraceDir)
  const before = reader.last('aria')
  // run code, aiTrace captures per step
  const after = reader.last('aria')
  if (before !== after) result.ariaDiff = ariaDiff(before, after)

initCodecept now force-enables aiTrace whenever the MCP server initializes
the container โ€” it's the canonical per-step capture, no point in MCP doing
its own grabAriaSnapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 bin/mcp-server.js             |  28 ++-
 lib/aria.js                   | 398 ++++++++++++++++++++++++++++++++++
 lib/utils/trace.js            |  57 +++++
 test/unit/utils/trace_test.js |  68 ++++++
 4 files changed, 550 insertions(+), 1 deletion(-)
 create mode 100644 lib/aria.js

diff --git a/bin/mcp-server.js b/bin/mcp-server.js
index 803d2ee61..f80ea0bad 100644
--- a/bin/mcp-server.js
+++ b/bin/mcp-server.js
@@ -12,6 +12,8 @@ import {
   snapshotDirFor,
   artifactsToFileUrls,
   writeTraceMarkdown,
+  TraceReader,
+  ariaDiff,
 } from '../lib/utils/trace.js'
 import event from '../lib/event.js'
 import recorder from '../lib/recorder.js'
@@ -36,6 +38,14 @@ let browserStarted = false
 let shellSessionActive = false
 let bootstrapDone = false
 let currentPluginsSig = ''
+let currentAiTraceDir = null  // mirrors the dir aiTrace plugin computes per test/session
+
+event.dispatcher.on(event.test.before, test => {
+  try {
+    const title = (test && (test.fullTitle ? test.fullTitle() : test.title)) || 'MCP Session'
+    currentAiTraceDir = traceDirFor(test?.file, title, outputBaseDir())
+  } catch {}
+})
 
 const SESSION_REQUIRED_ERROR = 'No active CodeceptJS session. Call `start_browser` to open a shell session, or `run_test` (use `pause()` in the test, or set `pauseAt`) to inspect during a test run.'
 
@@ -430,7 +440,10 @@ async function initCodecept(configPath, pluginOverrides) {
   const { getConfig } = await import('../lib/command/utils.js')
   const config = await getConfig(configPath)
 
-  applyPluginOverrides(config, plugins)
+  // aiTrace is the canonical per-step ARIA/HTML/screenshot capture for MCP.
+  // Always on so run_code / continue can read the latest snapshot from disk
+  // instead of double-capturing through grabAriaSnapshot etc.
+  applyPluginOverrides(config, { aiTrace: {}, ...plugins })
 
   codecept = new Codecept(config, {})
   await codecept.init(testRoot)
@@ -691,6 +704,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         mkdirp.sync(traceDir)
         const startedAt = Date.now()
 
+        // Pin the latest aiTrace ARIA file before running the code, so we
+        // can diff after. aiTrace owns per-step capture; we just read it.
+        const reader = new TraceReader(currentAiTraceDir)
+        const ariaBefore = reader.last('aria')
+
         const MAX_LOG_ENTRIES = 100
         const MAX_LOG_MSG_BYTES = 2000
         const MAX_RETURN_BYTES = 20000
@@ -753,6 +771,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           }
         }
 
+        // Diff against the latest aiTrace ARIA file produced by the steps
+        // that just ran inside this run_code call.
+        const ariaAfter = reader.last('aria')
+        if (ariaBefore && ariaAfter && ariaBefore !== ariaAfter) {
+          const diff = ariaDiff(ariaBefore, ariaAfter)
+          if (diff) result.ariaDiff = diff
+        }
+
         const traceFile = writeTraceMarkdown({
           dir: traceDir,
           title: 'run_code',
diff --git a/lib/aria.js b/lib/aria.js
new file mode 100644
index 000000000..ff4a51aa5
--- /dev/null
+++ b/lib/aria.js
@@ -0,0 +1,398 @@
+import yaml from 'js-yaml'
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// Roles
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+const INTERACTIVE_ROLES = new Set([
+  'button',
+  'link',
+  'textbox',
+  'searchbox',
+  'checkbox',
+  'radio',
+  'radiogroup',
+  'switch',
+  'combobox',
+  'listbox',
+  'listitem',
+  'menu',
+  'menubar',
+  'menuitem',
+  'menuitemcheckbox',
+  'menuitemradio',
+  'option',
+  'tab',
+  'tabpanel',
+  'tablist',
+  'slider',
+  'spinbutton',
+  'tree',
+  'treeitem',
+  'grid',
+  'gridcell',
+  'row',
+  'rowheader',
+  'columnheader',
+  'toolbar',
+  'progressbar',
+])
+
+const IGNORED_ROLES = new Set(['navigation'])
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// Tunables (knobs that change pipeline behavior)
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+const SIBLING_COLLAPSE_THRESHOLD = 50
+const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// STEP 1 ยท Parse: YAML text โ†’ AriaNode[]
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+function normalizeScalar(input) {
+  let value = String(input).trim()
+  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+    value = value.slice(1, -1)
+  }
+  const lower = value.toLowerCase()
+  if (lower === 'true') return true
+  if (lower === 'false') return false
+  if (lower === 'null') return null
+  return value
+}
+
+// Parse one YAML node label like:  `button "Save"`,  `textbox "Email" [focused]`,  `heading "Title" [level=2]`
+function parseLabel(label) {
+  if (!label) return null
+  const trimmed = label.trim()
+  const roleMatch = trimmed.match(/^(\w+)/)
+  if (!roleMatch) return null
+  const role = roleMatch[1].toLowerCase()
+  let rest = trimmed.slice(roleMatch[0].length)
+
+  let name
+  const nameMatch = rest.match(/^\s*"((?:[^"\\]|\\.)*)"/) || rest.match(/^\s*'((?:[^'\\]|\\.)*)'/)
+  if (nameMatch) {
+    name = nameMatch[1]
+    rest = rest.slice(nameMatch[0].length)
+  }
+
+  const attributes = {}
+  const attrMatch = rest.match(/\[([^\]]*)\]/)
+  if (attrMatch) {
+    for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) {
+      const eq = tok.indexOf('=')
+      if (eq === -1) {
+        attributes[tok.toLowerCase()] = true
+        continue
+      }
+      attributes[tok.slice(0, eq).trim().toLowerCase()] = normalizeScalar(tok.slice(eq + 1))
+    }
+  }
+
+  return { role, name, attributes }
+}
+
+function yamlItemToNode(item) {
+  if (typeof item === 'string') {
+    const label = parseLabel(item)
+    if (!label) return null
+    const node = { role: label.role, attributes: label.attributes, children: [] }
+    if (label.name && label.name.trim() !== '') node.name = label.name.trim()
+    return node
+  }
+  if (!item || typeof item !== 'object' || Array.isArray(item)) return null
+
+  const entries = Object.entries(item)
+  if (entries.length === 0) return null
+  const [key, value] = entries[0]
+  const label = parseLabel(key)
+  if (!label) return null
+  const node = { role: label.role, attributes: label.attributes, children: [] }
+  if (label.name && label.name.trim() !== '') node.name = label.name.trim()
+
+  if (Array.isArray(value)) {
+    node.children = value.map(yamlItemToNode).filter(n => n !== null)
+    return node
+  }
+  if (value === null || value === undefined) return node
+  const normalized = normalizeScalar(String(value))
+  if (normalized !== '' && normalized !== undefined) node.value = normalized
+  return node
+}
+
+function parseSnapshot(snapshot) {
+  if (!snapshot) return []
+  let parsed
+  try {
+    parsed = yaml.load(snapshot)
+  } catch {
+    return []
+  }
+  if (!Array.isArray(parsed)) return []
+  return parsed.map(yamlItemToNode).filter(n => n !== null)
+}
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// STEP 2 ยท Transforms: AriaNode[] โ†’ AriaNode[]
+//   Each is a pure function. Compose by stacking calls in the public API.
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+// Dissolve  wrappers into their children.
+function unwrapIgnored(nodes) {
+  return nodes.flatMap(node => {
+    const children = unwrapIgnored(node.children)
+    if (IGNORED_ROLES.has(node.role)) return children
+    return [{ ...node, children }]
+  })
+}
+
+// Walk children to produce a synthetic label for naming icon-only buttons.
+function summarizeChildren(children) {
+  return children
+    .map(child => {
+      let part = child.role
+      if (child.name) part += ` "${child.name}"`
+      const nested = summarizeChildren(child.children)
+      if (nested) part += ` > ${nested}`
+      return part
+    })
+    .join(', ')
+}
+
+// Set node.name = "{img "icon"}" for buttons/links that have no name but do have children.
+// Recurses so nested buttons get named too. Uses ORIGINAL children for the summary, before pruning.
+function nameIconButtons(nodes) {
+  return nodes.map(node => {
+    const namedChildren = nameIconButtons(node.children)
+    if (node.name) return { ...node, children: namedChildren }
+    if (node.role !== 'button' && node.role !== 'link') return { ...node, children: namedChildren }
+    if (node.children.length === 0) return { ...node, children: namedChildren }
+    return { ...node, name: `{${summarizeChildren(node.children)}}`, children: namedChildren }
+  })
+}
+
+// Drop containers that contribute nothing.
+//   keepNamed=true โ†’ also keep named non-interactive nodes (e.g. headings, named text).
+function dropEmpty(nodes, opts = {}) {
+  return nodes.flatMap(node => {
+    const children = dropEmpty(node.children, opts)
+    if (INTERACTIVE_ROLES.has(node.role)) return [{ ...node, children }]
+    if (children.length > 0) return [{ ...node, children }]
+    if (opts.keepNamed && (node.name || node.value !== undefined)) return [{ ...node, children }]
+    return []
+  })
+}
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// STEP 3 ยท Render: AriaNode[] โ†’ text or flat entries
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+// One-line representation of a node. Stable attr order so diff comparisons are deterministic.
+function formatNode(node) {
+  let line = node.role
+  if (node.name?.trim()) line += ` "${node.name.trim()}"`
+  const attrStr = Object.keys(node.attributes)
+    .sort()
+    .map(k => {
+      const v = node.attributes[k]
+      if (v === undefined || v === null || v === '') return ''
+      if (v === true) return k
+      return `${k}=${v}`
+    })
+    .filter(Boolean)
+    .join(' ')
+  if (attrStr) line += ` [${attrStr}]`
+  if (node.value !== undefined && node.value !== null) {
+    const text = String(node.value).trim()
+    if (text) line += `: ${text}`
+  }
+  return line
+}
+
+// Group consecutive same-role siblings.  [a,a,b,a,a,a] โ†’ [[a,a],[b],[a,a,a]]
+function groupByConsecutiveRole(nodes) {
+  return nodes.reduce((groups, node) => {
+    const last = groups[groups.length - 1]
+    if (last && last[0].role === node.role) {
+      last.push(node)
+      return groups
+    }
+    groups.push([node])
+    return groups
+  }, [])
+}
+
+// Large group of same-role siblings โ†’ first N + "...M omitted..." marker + last N.
+function collapseGroup(group, depth) {
+  if (group.length <= SIBLING_COLLAPSE_THRESHOLD) {
+    return group.map(node => ({ node }))
+  }
+  const keep = SIBLING_COLLAPSE_KEEP_EACH_SIDE
+  const omitted = group.length - keep * 2
+  const indent = '  '.repeat(depth)
+  return [
+    ...group.slice(0, keep).map(node => ({ node })),
+    { placeholder: `${indent}- ...${omitted} similar "${group[0].role}" items omitted...` },
+    ...group.slice(-keep).map(node => ({ node })),
+  ]
+}
+
+function collapseSiblingGroups(nodes, depth) {
+  return groupByConsecutiveRole(nodes).flatMap(group => collapseGroup(group, depth))
+}
+
+// Tree โ†’ indented YAML text.
+function renderTree(nodes, depth = 0) {
+  return collapseSiblingGroups(nodes, depth)
+    .map(entry => {
+      if ('placeholder' in entry) return entry.placeholder
+      const { node } = entry
+      const indent = '  '.repeat(depth)
+      const head = `${indent}- ${formatNode(node)}`
+      if (node.children.length === 0) return head
+      return `${head}:\n${renderTree(node.children, depth + 1)}`
+    })
+    .join('\n')
+}
+
+// Build the structured "entry" object for an interactive node, or null if not worth keeping.
+function nodeToEntry(node) {
+  if (!INTERACTIVE_ROLES.has(node.role)) return null
+  const entry = { role: node.role }
+  if (node.name?.trim()) entry.name = node.name.trim()
+  if (node.value !== undefined && node.value !== null) {
+    const text = String(node.value).trim()
+    if (text) entry.value = node.value
+  }
+  for (const [key, value] of Object.entries(node.attributes)) {
+    if (value === undefined || value === null || value === '') continue
+    entry[key] = value
+  }
+  const isButtonOrLink = node.role === 'button' || node.role === 'link'
+  const hasContent = Object.keys(entry).length > 1
+  if (isButtonOrLink && !hasContent) {
+    entry.unnamed = true
+    return entry
+  }
+  if (!hasContent) return null
+  return entry
+}
+
+// Walk tree, emit one FlatEntry per interactive node. Path is dotted index from root.
+function flatten(nodes) {
+  const collect = (node, path) => {
+    const entry = nodeToEntry(node)
+    const here = entry ? [{ path, summary: formatNode(node), entry }] : []
+    const fromChildren = node.children.flatMap((child, i) => collect(child, `${path}.${i}`))
+    return [...here, ...fromChildren]
+  }
+  return nodes.flatMap((node, i) => collect(node, String(i)))
+}
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// STEP 4 ยท Diff: FlatEntry[] ร— FlatEntry[] โ†’ text
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+function countBy(items) {
+  return items.reduce((map, item) => {
+    if (item === '') return map
+    map.set(item, (map.get(item) ?? 0) + 1)
+    return map
+  }, new Map())
+}
+
+// Bag-style diff: any summary appearing more in one bag than the other becomes added/removed.
+function diffByCount(before, after) {
+  const added = []
+  const removed = []
+  const all = new Set([...before.keys(), ...after.keys()])
+  for (const summary of all) {
+    const b = before.get(summary) ?? 0
+    const a = after.get(summary) ?? 0
+    for (let i = 0; i < a - b; i += 1) added.push(summary)
+    for (let i = 0; i < b - a; i += 1) removed.push(summary)
+  }
+  return { added, removed }
+}
+
+// When the same path has a different summary AND the per-summary totals haven't shifted,
+// treat it as a rename (one add + one remove). Catches "button text changed" cases that
+// the count-based diff would miss.
+function detectRenames(prev, curr, prevTotals, currTotals) {
+  const added = []
+  const removed = []
+  const prevByPath = new Map(prev.map(e => [e.path, e.summary]))
+  const currByPath = new Map(curr.map(e => [e.path, e.summary]))
+
+  for (const [path, beforeSummary] of prevByPath) {
+    const afterSummary = currByPath.get(path)
+    if (!afterSummary || afterSummary === beforeSummary) continue
+    const totalsAfter = (currTotals.get(afterSummary) ?? 0) === (prevTotals.get(afterSummary) ?? 0)
+    const totalsBefore = (currTotals.get(beforeSummary) ?? 0) === (prevTotals.get(beforeSummary) ?? 0)
+    if (!totalsAfter || !totalsBefore) continue
+    const beforeElsewhere = curr.some(e => e.path !== path && e.summary === beforeSummary)
+    const afterElsewhere = prev.some(e => e.path !== path && e.summary === afterSummary)
+    if (beforeElsewhere && afterElsewhere) continue
+    added.push(afterSummary)
+    removed.push(beforeSummary)
+  }
+  return { added, removed }
+}
+
+function formatDiff(added, removed) {
+  if (added.length === 0 && removed.length === 0) return null
+  const lines = ['ariaDiff:']
+  const addedSummary = countBy(added)
+  if (addedSummary.size === 0) {
+    lines.push('  added: []')
+  } else {
+    lines.push('  added:')
+    Array.from(addedSummary.entries())
+      .sort(([a], [b]) => a.localeCompare(b))
+      .forEach(([item, count]) => {
+        const suffix = count > 1 ? ` (x${count})` : ''
+        lines.push(`    - ${item}${suffix}`)
+      })
+  }
+  if (removed.length === 0) {
+    lines.push('  removed: []')
+  } else {
+    lines.push(`  removed: ${removed.length} interactive elements`)
+  }
+  return lines.join('\n')
+}
+
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+// Public API โ€” pipelines composed visibly, top-to-bottom
+// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+
+function compactAriaSnapshot(snapshot, keepNamed = false) {
+  if (!snapshot) return ''
+  let tree = parseSnapshot(snapshot)
+  tree = unwrapIgnored(tree)
+  tree = nameIconButtons(tree)
+  tree = dropEmpty(tree, { keepNamed })
+  return renderTree(tree)
+}
+
+function diffAriaSnapshots(previous, current) {
+  const flat = snap => {
+    let tree = parseSnapshot(snap)
+    tree = unwrapIgnored(tree)
+    tree = nameIconButtons(tree)
+    tree = dropEmpty(tree)
+    return flatten(tree)
+  }
+  const prev = flat(previous)
+  const curr = flat(current)
+  const prevTotals = countBy(prev.map(e => e.summary))
+  const currTotals = countBy(curr.map(e => e.summary))
+  const byCount = diffByCount(prevTotals, currTotals)
+  const renames = detectRenames(prev, curr, prevTotals, currTotals)
+  return formatDiff([...byCount.added, ...renames.added], [...byCount.removed, ...renames.removed])
+}
+
+export { diffAriaSnapshots, compactAriaSnapshot }
diff --git a/lib/utils/trace.js b/lib/utils/trace.js
index af6f5863b..a62ded4b5 100644
--- a/lib/utils/trace.js
+++ b/lib/utils/trace.js
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'url'
 import Container from '../container.js'
 import { clearString } from '../utils.js'
 import { formatHtml } from '../html.js'
+import { diffAriaSnapshots } from '../aria.js'
 
 // ---------------------------------------------------------------------------
 // Helper / directory naming
@@ -238,3 +239,59 @@ export async function captureSnapshot(helper, {
 
   return out
 }
+
+// ---------------------------------------------------------------------------
+// TraceReader โ€” read artifacts already on disk (written by aiTrace, MCP, etc.)
+// ---------------------------------------------------------------------------
+
+const KIND_SUFFIX = {
+  aria: '_aria.txt',
+  html: '_page.html',
+  screenshot: '_screenshot.png',
+  console: '_console.json',
+  storage: '_storage.json',
+}
+
+export class TraceReader {
+  constructor(dir) {
+    this.dir = dir
+  }
+
+  // Filenames of a given kind, sorted in capture order. aiTrace prefixes with
+  // a zero-padded step index (`0000_`, `0001_`...), so a lexical sort is
+  // chronological.
+  list(kind) {
+    const suffix = KIND_SUFFIX[kind]
+    if (!suffix || !this.dir || !fs.existsSync(this.dir)) return []
+    let entries
+    try { entries = fs.readdirSync(this.dir) } catch { return [] }
+    return entries.filter(f => f.endsWith(suffix)).sort()
+  }
+
+  // Path of the n-th file of `kind`, or null. Python-style indexing:
+  // 0..N-1 from the start, -1..-N from the end.
+  pathAt(n, kind) {
+    const files = this.list(kind)
+    if (!files.length) return null
+    const i = n < 0 ? files.length + n : n
+    if (i < 0 || i >= files.length) return null
+    return path.join(this.dir, files[i])
+  }
+
+  // Read content of the n-th file of `kind`. Binary kinds (screenshot) are
+  // returned as Buffer; text kinds as utf8 string.
+  nth(n, kind) {
+    const p = this.pathAt(n, kind)
+    if (!p) return null
+    try {
+      if (kind === 'screenshot') return fs.readFileSync(p)
+      return fs.readFileSync(p, 'utf8')
+    } catch { return null }
+  }
+
+  first(kind) { return this.nth(0, kind) }
+  last(kind)  { return this.nth(-1, kind) }
+  count(kind) { return this.list(kind).length }
+}
+
+export const ariaDiff = diffAriaSnapshots
diff --git a/test/unit/utils/trace_test.js b/test/unit/utils/trace_test.js
index 573695f6f..393fcfd07 100644
--- a/test/unit/utils/trace_test.js
+++ b/test/unit/utils/trace_test.js
@@ -12,6 +12,8 @@ import {
   artifactsToFileUrls,
   writeTraceMarkdown,
   captureSnapshot,
+  TraceReader,
+  ariaDiff,
 } from '../../../lib/utils/trace.js'
 
 function makeTmpDir(prefix = 'trace-test') {
@@ -430,4 +432,70 @@ describe('lib/utils/trace.js', () => {
       expect(out.html).to.equal('snapshot_page.html')
     })
   })
+
+  describe('TraceReader', () => {
+    let dir
+
+    beforeEach(() => {
+      dir = makeTmpDir('trace-reader')
+      fs.writeFileSync(path.join(dir, '0000_amOnPage_aria.txt'), '- button "Login"')
+      fs.writeFileSync(path.join(dir, '0001_fillField_aria.txt'), '- button "Login"\n- textbox "Email"')
+      fs.writeFileSync(path.join(dir, '0002_click_aria.txt'), '- button "Logout"')
+      fs.writeFileSync(path.join(dir, '0000_amOnPage_page.html'), '1')
+      fs.writeFileSync(path.join(dir, '0002_click_page.html'), '3')
+    })
+
+    afterEach(() => rmDir(dir))
+
+    it('list returns files of a kind in capture order', () => {
+      const reader = new TraceReader(dir)
+      expect(reader.list('aria')).to.deep.equal([
+        '0000_amOnPage_aria.txt',
+        '0001_fillField_aria.txt',
+        '0002_click_aria.txt',
+      ])
+      expect(reader.list('html')).to.deep.equal([
+        '0000_amOnPage_page.html',
+        '0002_click_page.html',
+      ])
+    })
+
+    it('first / last / nth read content with python-style indexing', () => {
+      const reader = new TraceReader(dir)
+      expect(reader.first('aria')).to.equal('- button "Login"')
+      expect(reader.last('aria')).to.equal('- button "Logout"')
+      expect(reader.nth(0, 'aria')).to.equal('- button "Login"')
+      expect(reader.nth(1, 'aria')).to.equal('- button "Login"\n- textbox "Email"')
+      expect(reader.nth(-1, 'aria')).to.equal('- button "Logout"')
+      expect(reader.nth(-2, 'aria')).to.equal('- button "Login"\n- textbox "Email"')
+    })
+
+    it('returns null for out-of-range or missing kinds', () => {
+      const reader = new TraceReader(dir)
+      expect(reader.nth(99, 'aria')).to.be.null
+      expect(reader.nth(-99, 'aria')).to.be.null
+      expect(reader.first('storage')).to.be.null
+      expect(reader.first('bogus')).to.be.null
+    })
+
+    it('returns empty list for missing or non-existent dir', () => {
+      expect(new TraceReader(null).list('aria')).to.deep.equal([])
+      expect(new TraceReader('/no/such/path').list('aria')).to.deep.equal([])
+      expect(new TraceReader(null).last('aria')).to.be.null
+    })
+
+    it('count returns the number of files for a kind', () => {
+      const reader = new TraceReader(dir)
+      expect(reader.count('aria')).to.equal(3)
+      expect(reader.count('html')).to.equal(2)
+      expect(reader.count('storage')).to.equal(0)
+    })
+
+    it('ariaDiff(prev, last) reports what changed between two captures', () => {
+      const reader = new TraceReader(dir)
+      const diff = ariaDiff(reader.nth(-2, 'aria'), reader.last('aria'))
+      expect(diff).to.be.a('string')
+      expect(diff).to.match(/button "Logout"/)
+    })
+  })
 })

From b1d7cfe4560c0c23b3594940975ee74be79302e1 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Fri, 1 May 2026 04:48:11 +0300
Subject: [PATCH 5/5] docs(mcp): add Agentic Testing guide; simplify ARIA
 snapshot pipeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- docs/agents.md: new top-level page covering the MCP loop (open the
  page โ†’ read โ†’ run a CodeceptJS command โ†’ check โ†’ commit), how the
  agent reads page artifacts, and where MCP fits relative to pause().
- lib/aria.js: trim INTERACTIVE_ROLES to roles that actually take
  user input (drop container roles like grid/tablist/menubar);
  remove IGNORED_ROLES unwrap, icon-button auto-naming, and
  bool/null coercion in attribute values. Names are always
  emitted; attribute values are passed through as plain strings.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 docs/agents.md | 159 ++++++++++++++++++++++++++++++
 lib/aria.js    | 262 ++++++++++++-------------------------------------
 2 files changed, 221 insertions(+), 200 deletions(-)
 create mode 100644 docs/agents.md

diff --git a/docs/agents.md b/docs/agents.md
new file mode 100644
index 000000000..3358256bd
--- /dev/null
+++ b/docs/agents.md
@@ -0,0 +1,159 @@
+---
+permalink: /agents
+title: Agentic Testing
+---
+
+# Agentic Testing
+
+CodeceptJS ships an **MCP server and a skillset** that lets an AI agent (Claude Code, Cursor, Codex, others) write and fix tests by driving the real browser. The agent runs the same `I.*` commands the test does, reads how the page responds, and only commits the lines that succeeded.
+
+## Why MCP
+
+The traditional agent testing loop is test/fix/retry, where the agent executes a test, watches it fail, reads artifacts, performs code fixes, and reruns the test. The agent applies fixes by intelligent guess โ€” looking at the ARIA tree, HTML, and screenshot โ€” then assumes the fix is enough and reruns the test hoping it will pass. If the guess is wrong and the test runs for over a minute, it may take dozens of minutes of iteration and a lot of wasted tokens.
+
+To improve that flow, the agent can spawn a browser and open the page the way the test does. This lets it interact with the page more freely and perform multi-step actions. But putting that experience back into test code is not efficient either: actions executed in the browser may not be relevant in test context, so the agent ends up in another guess-and-try loop.
+
+The problem is that **the test runs in a different context than the agent**.
+
+The agent can launch a test but can't control it while it's running. It can't access the browser. It can't set a breakpoint.
+
+This is where CodeceptJS MCP steps in. Connected to the agent, it can:
+
+- run a test and pause it on failure
+- interact with the browser in a test context
+- test locators and perform actions live while the test is running
+- write successful actions to the test file
+
+This lets the agent get a test working in one iteration. The agent can live-write the test before your eyes by exploring the page and performing actions that eventually land in the CodeceptJS test file.
+
+**Live debugging of tests** is what CodeceptJS MCP provides. The agent receives feedback faster โ€” not from a whole test execution but from specific actions on a specific page โ€” so it can adjust and react faster, trying different approaches.
+
+The MCP server is the agent-facing equivalent of the `pause()` REPL โ€” same access, driven by tool calls instead of keystrokes. Full tool reference at [/mcp](/mcp).
+
+## The loop
+
+Whether the agent is writing a new test or fixing an old one, it follows the same cycle.
+
+1. **Open the page.** Run a stub test (new work) or set a breakpoint at the failing step (fix). The browser lands at the right starting point and yields control to the agent.
+2. **Read the page.** MCP saves HTML, ARIA, and screenshot of the page to files (and the agent can call the `snapshot` tool to refresh them). The agent reads those files before deciding what to try next, controlling its token usage.
+3. **Run a CodeceptJS command.** The agent tries `I.*` commands like `I.click('Add to cart')`, `I.fillField('Email', secret(process.env.EMAIL))`, `I.see('Confirmed')`. On success, that line goes into the test โ€” same syntax.
+4. **Check the result.** The response after each command shows the new page state. If the URL changed and the modal opened, the line goes into the verified sequence. If not, the agent reads the page again and tries a different locator or a wait.
+5. **Move forward.** The agent looks at the new state and chooses the next command. Steps 2โ€“4 repeat until the scenario is whole.
+6. **Commit to the file.** The agent edits the test โ€” replaces `pause()` (new tests) or the broken line (fixes) with the verified sequence โ€” then reruns end-to-end and reads the trace to confirm.
+
+## How the agent reads the page
+
+MCP commands are token efficient โ€” they don't stream large HTML pages back to the model. MCP writes artifacts to disk under `output/trace_*/` and returns file paths. The agent reads each artifact with its own bash tools โ€” `cat`, `grep`, `jq`.
+
+A `run_code` response, for example, looks like this:
+
+```json
+{
+  "status": "success",
+  "artifacts": {
+    "url": "http://localhost:8000/",
+    "html": "file:///output/trace_run_code_.../mcp_page.html",
+    "aria": "file:///output/trace_run_code_.../mcp_aria.txt",
+    "screenshot": "file:///output/trace_run_code_.../mcp_screenshot.png",
+    "console": "file:///output/trace_run_code_.../mcp_console.json",
+    "storage": "file:///output/trace_run_code_.../mcp_storage.json"
+  }
+}
+```
+
+Only `url` is inline. The rest are paths the agent opens with the right tool:
+
+| Artifact | How the agent reads it |
+|----------|------------------------|
+| `*_screenshot.png` | As an image โ€” most agents are multimodal |
+| `*_aria.txt` | Whole โ€” small and structured |
+| `*_page.html` | With `grep` โ€” too large for context, searchable for specific elements/attributes |
+| `*_console.json` | With `jq` โ€” filter for errors, 4xx/5xx, deprecation warnings |
+| `*_storage.json` | Whole โ€” cookies and `localStorage` snapshot |
+| `trace.md` | Whole โ€” markdown index linking every step to its artifacts |
+
+Saved HTML is formatted, with non-semantic elements stripped out: `