From f572b8e79ad2c722e17045cf2133503f7c624dc4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 26 Apr 2026 22:27:29 +0300 Subject: [PATCH 1/6] 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/6] 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 86ac2f2e941c7746f5590c7f25e146f3ec6dd1e6 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Mon, 4 May 2026 04:47:26 +0300
Subject: [PATCH 3/6] MCP run improvmenets and other fixes

---
 bin/codecept.js                |   6 +-
 bin/mcp-server.js              | 255 ++++++++++++----
 docs/continuous-integration.md | 497 +++++++++++++++++++++++++++++++-
 docs/migration-4.md            |   6 +-
 lib/command/dryRun.js          |  12 +-
 lib/command/init.js            | 513 ++++++++++++++++-----------------
 lib/config.js                  |   9 +
 lib/element/WebElement.js      |  37 +++
 lib/globals.js                 |  21 +-
 lib/helper/Playwright.js       |   5 +-
 lib/index.js                   |  10 +-
 lib/mocha/factory.js           |   7 +-
 lib/mocha/inject.js            |   2 +-
 lib/parser.js                  |   4 +-
 lib/plugin/browser.js          |   3 +-
 lib/workers.js                 |  16 +-
 package.json                   |   3 +-
 17 files changed, 1037 insertions(+), 369 deletions(-)
 mode change 100644 => 100755 bin/mcp-server.js

diff --git a/bin/codecept.js b/bin/codecept.js
index 212441579..cf83b4434 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -72,7 +72,11 @@ if (process.versions.node && process.versions.node.split('.') && process.version
 program.usage(' [options]')
 program.version(Codecept.version())
 
-program.command('init [path]').description('Creates dummy config in current dir or [path]').action(commandHandler('../lib/command/init.js'))
+program
+  .command('init [path]')
+  .description('Creates dummy config in current dir or [path]')
+  .option('-y, --yes', 'skip prompts and use defaults (Playwright + chromium, BASE_URL env for url)')
+  .action(commandHandler('../lib/command/init.js'))
 
 program
   .command('check')
diff --git a/bin/mcp-server.js b/bin/mcp-server.js
old mode 100644
new mode 100755
index 776e31d22..868514d34
--- a/bin/mcp-server.js
+++ b/bin/mcp-server.js
@@ -1,3 +1,4 @@
+#!/usr/bin/env node
 import { Server } from '@modelcontextprotocol/sdk/server/index.js'
 import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
 import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
@@ -14,6 +15,12 @@ import {
   writeTraceMarkdown,
 } from '../lib/utils/trace.js'
 import event from '../lib/event.js'
+import recorder from '../lib/recorder.js'
+import WebElement from '../lib/element/WebElement.js'
+import { locate, within, session, secret, inject, pause } from '../lib/index.js'
+import { tryTo, retryTo, hopeThat } from '../lib/effects.js'
+import step from '../lib/steps.js'
+import { element, eachElement, expectElement, expectAnyElement, expectAllElements } from '../lib/els.js'
 import { setPauseHandler, pauseNow } from '../lib/pause.js'
 import { EventEmitter } from 'events'
 import { fileURLToPath, pathToFileURL } from 'url'
@@ -32,6 +39,48 @@ const __dirname = dirname(__filename)
 let codecept = null
 let containerInitialized = false
 let browserStarted = false
+let aiTraceEnabled = false  // tracked across the session so tool responses can surface a hint when off
+
+function aiTraceHint() {
+  if (aiTraceEnabled) return undefined
+  return 'aiTrace plugin is disabled โ€” re-run start_browser with plugins={ aiTrace: { enabled: true } } to capture per-step DOM/ARIA/console traces for debugging.'
+}
+
+function applyMochaGrep(grep) {
+  if (grep && typeof container.mocha?.grep === 'function') container.mocha.grep(grep)
+}
+
+async function teardownContainer() {
+  if (!containerInitialized) return
+  try {
+    await closeSession()
+    for (const helper of Object.values(container.helpers() || {})) {
+      if (helper._cleanup) await helper._cleanup()
+      else if (helper._finishTest) await helper._finishTest()
+    }
+    if (codecept?.teardown) await codecept.teardown()
+  } finally {
+    containerInitialized = false
+    browserStarted = false
+    aiTraceEnabled = false
+  }
+}
+
+let shutdownStarted = false
+function installShutdownHooks() {
+  const onSignal = (signal) => {
+    if (shutdownStarted) return
+    shutdownStarted = true
+    teardownContainer().finally(() => process.exit(signal === 'SIGINT' ? 130 : 0))
+  }
+  process.on('SIGTERM', () => onSignal('SIGTERM'))
+  process.on('SIGINT', () => onSignal('SIGINT'))
+  process.on('beforeExit', () => {
+    if (shutdownStarted) return
+    shutdownStarted = true
+    teardownContainer().catch(() => {})
+  })
+}
 
 let runLock = Promise.resolve()
 async function withLock(fn) {
@@ -298,10 +347,15 @@ function collectRunCompletion(errorMessage) {
   pendingRunResults = null
   pendingTestFile = null
   pendingStepInfo = null
+  let error = errorMessage || null
+  if (!error && results.length === 0) {
+    error = 'No tests ran and no error was reported. The Mocha instance may have been disposed (set mocha.cleanReferencesAfterRun=false in config) or the test file matched no scenarios.'
+  }
   return {
-    status: 'completed',
+    status: error ? 'failed' : 'completed',
     reporterJson: { stats, tests: results },
-    error: errorMessage,
+    error,
+    aiTraceHint: aiTraceHint(),
   }
 }
 
@@ -318,8 +372,11 @@ function pausedPayload() {
   }
 }
 
-async function initCodecept(configPath) {
-  if (containerInitialized) return
+async function initCodecept(configPath, { plugins } = {}) {
+  if (containerInitialized) {
+    if (plugins) throw new Error('Container is already running. Call stop_browser before passing plugins again.')
+    return
+  }
 
   const testRoot = process.env.CODECEPTJS_PROJECT_DIR || process.cwd()
 
@@ -344,13 +401,59 @@ async function initCodecept(configPath) {
   const { getConfig } = await import('../lib/command/utils.js')
   const config = await getConfig(configPath)
 
+  config.plugins ??= {}
+  config.plugins.aiTrace = { on: 'step', ...config.plugins.aiTrace, enabled: true }
+  config.plugins.browser = { show: false, ...config.plugins.browser, enabled: true }
+
+  if (plugins) {
+    for (const [name, pluginConfig] of Object.entries(plugins)) {
+      config.plugins[name] = { ...config.plugins[name], enabled: true, ...pluginConfig }
+    }
+  }
+
   codecept = new Codecept(config, {})
   await codecept.init(testRoot)
-  await container.create(config, {})
   await container.started()
 
   containerInitialized = true
   browserStarted = true
+  aiTraceEnabled = config.plugins?.aiTrace?.enabled === true
+
+  await establishSession()
+}
+
+async function establishSession() {
+  if (recorder.isRunning()) return
+  recorder.start()
+  const syntheticTest = { title: 'mcp_session', artifacts: {}, opts: {} }
+  for (const helper of Object.values(container.helpers() || {})) {
+    if (typeof helper._beforeSuite === 'function') {
+      try { await helper._beforeSuite() } catch {}
+    }
+  }
+  event.emit(event.suite.before, { fullTitle: () => 'MCP Session', tests: [], retries: undefined })
+  event.emit(event.test.before, syntheticTest)
+  for (const helper of Object.values(container.helpers() || {})) {
+    if (typeof helper._before === 'function') {
+      try { await helper._before(syntheticTest) } catch {}
+    }
+  }
+  await recorder.promise()
+}
+
+async function formatReturnValue(value) {
+  if (value instanceof WebElement) return await value.describe()
+  if (Array.isArray(value) && value.length && value.every(v => v instanceof WebElement)) {
+    return await Promise.all(value.map(v => v.describe()))
+  }
+  return value
+}
+
+async function closeSession() {
+  if (!recorder.isRunning()) return
+  event.emit(event.test.after, { title: 'mcp_session', artifacts: {} })
+  event.emit(event.suite.after, { title: 'MCP Session' })
+  try { await recorder.promise() } catch {}
 }
 
 const server = new Server(
@@ -358,41 +461,57 @@ const server = new Server(
   { capabilities: { tools: {} } }
 )
 
+const PLUGINS_PROP = {
+  type: 'object',
+  description: 'Plugin configs to enable for this session, keyed by plugin name. Same shape as `plugins` in codecept.conf.js โ€” each value is the plugin\'s config object (`enabled: true` is added automatically). Common entries:\n' +
+    '  โ€ข { browser: { show: true } } โ€” visible browser (headed)\n' +
+    '  โ€ข { browser: { show: false } } โ€” headless\n' +
+    '  โ€ข { browser: { browser: "firefox", windowSize: "1280x720" } } โ€” switch browser + viewport\n' +
+    '  โ€ข { pause: { on: "fail" } } / { screenshot: { on: "step" } } / { aiTrace: {} }\n' +
+    'Override or add to whatever the project config already enables.',
+  additionalProperties: { type: 'object' },
+}
+
+const CONFIG_PROP = {
+  type: 'string',
+  description: 'Path to codecept.conf.js (or .cjs). Defaults to $CODECEPTJS_CONFIG, then ./codecept.conf.js in $CODECEPTJS_PROJECT_DIR or cwd. Only needed for projects with a non-standard config location.',
+}
+
 server.setRequestHandler(ListToolsRequestSchema, async () => ({
   tools: [
     {
       name: 'list_tests',
-      description: 'List all tests in the CodeceptJS project',
-      inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
+      description: 'List all tests in the CodeceptJS project. Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
+      inputSchema: { type: 'object', properties: {} },
     },
     {
       name: 'list_actions',
-      description: 'List all available CodeceptJS actions (I.* methods)',
-      inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
+      description: 'List all available CodeceptJS actions (I.* methods). Uses the active session if start_browser was called, otherwise auto-inits with project defaults.',
+      inputSchema: { type: 'object', properties: {} },
     },
     {
       name: 'run_code',
-      description: 'Run arbitrary CodeceptJS code.',
+      description: 'Run arbitrary CodeceptJS code. Response includes `availableObjects` listing every symbol in scope (I, helpers, container, step, tryTo, within, etc.).',
       inputSchema: {
         type: 'object',
         properties: {
           code: { type: 'string' },
           timeout: { type: 'number' },
-          config: { type: 'string' },
           saveArtifacts: { type: 'boolean' },
+          settleMs: { type: 'number', description: 'Wait N ms after the code finishes before capturing artifacts. Default 300. Set higher (1000+) when actions trigger slow re-renders, or 0 to skip.' },
         },
         required: ['code'],
       },
     },
     {
       name: 'run_test',
-      description: 'Run a specific test. If the test calls pause() โ€” or if pauseAt is set and reached โ€” returns early with status "paused" so the agent can inspect via run_code and release with continue. Otherwise returns the json reporter result on completion. To learn step indices for pauseAt, run "list" with --steps or call run_step_by_step first.',
+      description: 'Run a specific test. Returns reporter JSON with one entry per scenario; each entry has a `traceFile` (file:// URL) pointing to the aiTrace markdown for that scenario โ€” Read it on failures to see the failing step\'s DOM/ARIA/screenshot. If aiTrace is disabled the response includes an `aiTraceHint`. If the test calls pause() โ€” or if pauseAt is set and reached โ€” returns early with status "paused" so the agent can inspect via run_code and release with continue. To learn step indices for pauseAt, call run_step_by_step first. Auto-inits with project defaults if no session is active โ€” call start_browser first to customize launch (e.g. plugins={ browser: { show: true } } to watch the run).',
       inputSchema: {
         type: 'object',
         properties: {
           test: { type: 'string' },
           timeout: { type: 'number' },
-          config: { type: 'string' },
+          grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
           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.' },
         },
         required: ['test'],
@@ -400,35 +519,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
     },
     {
       name: 'run_step_by_step',
-      description: 'Run a test interactively, pausing after every step. Returns paused payload after the first step (URL/title/contentSize, last step info, suggestions). Call continue to advance one step (and re-pause), or run_code/snapshot to inspect state. The test runs to completion when no more steps remain.',
+      description: 'Run a test interactively, pausing after every step. Returns paused payload after the first step (URL/title/contentSize, last step info, suggestions). Call continue to advance one step (and re-pause), or run_code/snapshot to inspect state. On completion each scenario in `reporterJson.tests[]` has a `traceFile` (file:// URL) for the per-step aiTrace markdown โ€” Read it for the full execution log. Much more useful when start_browser was called with plugins={ browser: { show: true } } so you can watch what happens between pauses.',
       inputSchema: {
         type: 'object',
         properties: {
           test: { type: 'string' },
           timeout: { type: 'number' },
-          config: { type: 'string' },
+          grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
         },
         required: ['test'],
       },
     },
     {
       name: 'start_browser',
-      description: 'Start the browser session.',
-      inputSchema: { type: 'object', properties: { config: { type: 'string' } } },
+      description: 'Start the session โ€” initializes the codeceptjs container, loads helpers, and applies any plugin overrides. This is the only tool that customizes initialization; every other tool either uses the active session or auto-inits with project defaults.\n\n' +
+        'MCP enforces two plugin defaults so the agent gets useful telemetry:\n' +
+        '  โ€ข aiTrace: { on: "step", enabled: true } โ€” per-step DOM/ARIA/console/screenshot traces for debugging\n' +
+        '  โ€ข browser: { show: false, enabled: true } โ€” headless by default\n' +
+        'Both can be overridden via the `plugins` arg. To watch the run live: plugins={ browser: { show: true } }. To skip per-step trace overhead on a re-run: plugins={ aiTrace: { enabled: false } } (or { on: "fail" } to only capture failures). To switch config or plugins mid-session, call stop_browser first.',
+      inputSchema: {
+        type: 'object',
+        properties: {
+          config: CONFIG_PROP,
+          plugins: PLUGINS_PROP,
+        },
+      },
     },
     {
       name: 'stop_browser',
-      description: 'Stop the browser session.',
+      description: 'Stop the session, close browsers, and tear down the container. Required before re-initing with different config or plugins.',
       inputSchema: { type: 'object', properties: {} },
     },
     {
       name: 'snapshot',
-      description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action.',
+      description: 'Capture current browser state (HTML, ARIA, screenshot, console, URL) without performing any action. Returns `traceFile` (file:// URL) to a markdown trace bundling the captured artifacts โ€” Read it for full context. Auto-inits with project defaults if no session is active.',
       inputSchema: {
         type: 'object',
         properties: {
-          config: { type: 'string' },
           fullPage: { type: 'boolean' },
+          settleMs: { type: 'number', description: 'Wait N ms before capturing. Default 300. Set higher when the previous action is still re-rendering, or 0 to skip.' },
         },
       },
     },
@@ -451,8 +580,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
   try {
     switch (name) {
       case 'list_tests': {
-        const configPath = args?.config
-        await initCodecept(configPath)
+        await initCodecept()
 
         codecept.loadTests()
         const tests = codecept.testFiles.map(testFile => {
@@ -467,8 +595,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
       }
 
       case 'list_actions': {
-        const configPath = args?.config
-        await initCodecept(configPath)
+        await initCodecept()
 
         const helpers = container.helpers()
         const supportI = container.support('I')
@@ -496,34 +623,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
       }
 
       case 'start_browser': {
-        const configPath = args?.config
+        const { config: configPath, plugins } = args || {}
         if (browserStarted) {
-          return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started' }, null, 2) }] }
+          return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser already started', plugins: plugins ?? null }, null, 2) }] }
         }
-        await initCodecept(configPath)
-        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully' }, null, 2) }] }
+        await initCodecept(configPath, { plugins })
+        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser started successfully', plugins: plugins ?? null }, 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)
+        const { fullPage = false, settleMs = 300 } = args || {}
+        await initCodecept()
 
         const helper = pickActingHelper(container.helpers())
         if (!helper) throw new Error('No supported acting helper available (Playwright, Puppeteer, WebDriver).')
@@ -531,6 +649,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         const dir = snapshotDirFor(outputBaseDir())
         mkdirp.sync(dir)
 
+        if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
         const captured = await captureSnapshot(helper, { dir, prefix: 'snapshot', fullPage })
         const traceFile = writeTraceMarkdown({
           dir,
@@ -549,6 +668,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
               dir,
               traceFile: pathToFileURL(traceFile).href,
               artifacts: artifactsToFileUrls(captured, dir),
+              aiTraceHint: aiTraceHint(),
             }, null, 2),
           }],
         }
@@ -586,19 +706,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
       }
 
       case 'run_code': {
-        const { code, timeout = 60000, config: configPath, saveArtifacts = true } = args
-        await initCodecept(configPath)
+        const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
+        await initCodecept()
 
-        const I = container.support('I')
-        if (!I) throw new Error('I object not available. Make sure helpers are configured.')
+        const support = container.supportObjects() || {}
+        if (!support.I) throw new Error('I object not available. Make sure helpers are configured.')
 
         const result = { status: 'unknown', output: '', error: null, commands: [], artifacts: {} }
 
         const commands = []
+        let lastStepValue
         const onStepAfter = step => {
           try { commands.push(step.toString()) } catch {}
         }
+        const onStepPassed = (step, val) => {
+          if (val !== undefined) lastStepValue = val
+        }
         event.dispatcher.on(event.step.after, onStepAfter)
+        event.dispatcher.on(event.step.passed, onStepPassed)
 
         const traceDir = traceDirFor(`mcp_${Date.now()}`, 'run_code', outputBaseDir())
         mkdirp.sync(traceDir)
@@ -623,13 +748,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           console[m] = captureLog(m)
         }
 
+        const scope = {
+          locate, within, session, secret, inject, pause, share: container.share,
+          tryTo, retryTo, hopeThat,
+          step, element, eachElement, expectElement, expectAnyElement, expectAllElements,
+          container, helpers: container.helpers(),
+          ...support,
+        }
+        const paramNames = ['I', ...Object.keys(scope).filter(k => k !== 'I').sort()]
+        const paramValues = paramNames.map(k => scope[k])
+
         let returnValue
         try {
-          const asyncFn = new Function('I', `return (async () => { ${code} })()`)
+          const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
           returnValue = await Promise.race([
-            asyncFn(I),
+            asyncFn(...paramValues),
             new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeout}ms`)), timeout)),
           ])
+          await recorder.promise()
 
           result.status = 'success'
           result.output = 'Code executed successfully'
@@ -640,11 +776,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         } finally {
           for (const m of consoleMethods) console[m] = origConsoleMethods[m]
           try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
+          try { event.dispatcher.removeListener(event.step.passed, onStepPassed) } catch {}
+          try { recorder.reset() } catch {}
         }
 
         result.commands = commands
         result.logs = consoleLogs
         if (consoleLogs.length === MAX_LOG_ENTRIES) result.logsTruncated = true
+        result.availableObjects = paramNames
+
+        if (returnValue === undefined) returnValue = await Promise.resolve(lastStepValue)
+        returnValue = await formatReturnValue(returnValue)
 
         if (returnValue !== undefined) {
           const json = typeof returnValue === 'string' ? returnValue : safeStringify(returnValue, [], 2)
@@ -658,6 +800,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           const helper = pickActingHelper(container.helpers())
           if (helper) {
             try {
+              if (settleMs > 0) await new Promise(r => setTimeout(r, settleMs))
               captured = await captureSnapshot(helper, { dir: traceDir, prefix: 'mcp' })
               result.artifacts = artifactsToFileUrls(captured, traceDir)
             } catch (e) {
@@ -677,6 +820,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         })
         result.dir = traceDir
         result.traceFile = pathToFileURL(traceFile).href
+        result.aiTraceHint = aiTraceHint()
 
         return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
       }
@@ -686,8 +830,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, pauseAt, grep } = args || {}
+          await initCodecept()
+          applyMochaGrep(grep)
 
           return await withSilencedIO(async () => {
             codecept.loadTests()
@@ -710,12 +855,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             let stepIndex = 0
 
             const onAfter = t => {
+              const aiTrace = t.artifacts?.aiTrace
               pendingRunResults.push({
                 title: t.title,
                 file: t.file,
                 status: t.err ? 'failed' : 'passed',
                 error: t.err?.message,
                 duration: t.duration,
+                traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
               })
             }
             const onStepAfter = step => {
@@ -769,6 +916,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             }
 
             const final = collectRunCompletion(runError?.message)
+            await establishSession()
             return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
           })
         })
@@ -779,8 +927,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, grep } = args || {}
+          await initCodecept()
+          applyMochaGrep(grep)
 
           return await withSilencedIO(async () => {
             codecept.loadTests()
@@ -803,12 +952,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             let stepIndex = 0
 
             const onAfter = t => {
+              const aiTrace = t.artifacts?.aiTrace
               pendingRunResults.push({
                 title: t.title,
                 file: t.file,
                 status: t.err ? 'failed' : 'passed',
                 error: t.err?.message,
                 duration: t.duration,
+                traceFile: aiTrace ? pathToFileURL(aiTrace).href : null,
               })
             }
             const onStepAfter = step => {
@@ -818,7 +969,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
               } catch {
                 pendingStepInfo = { index: stepIndex }
               }
-              // Pause after every step โ€” agent calls continue to advance.
               pauseNow()
             }
             event.dispatcher.on(event.test.after, onAfter)
@@ -860,8 +1010,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
               }
             }
 
-            // Test had zero steps (or finished before first pause) โ€” return completion
             const final = collectRunCompletion(runError?.message)
+            await establishSession()
             return { content: [{ type: 'text', text: JSON.stringify({ ...final, file: testFile }, null, 2) }] }
           })
         })
@@ -879,6 +1029,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
 })
 
 async function main() {
+  installShutdownHooks()
   const transport = new StdioServerTransport()
   await server.connect(transport)
 }
diff --git a/docs/continuous-integration.md b/docs/continuous-integration.md
index 4c8efff2e..36bb7b448 100644
--- a/docs/continuous-integration.md
+++ b/docs/continuous-integration.md
@@ -3,20 +3,495 @@ permalink: /continuous-integration
 title: Continuous Integration
 ---
 
-
-
 # Continuous Integration
 
-> Help us improve this article. [Write how did you set up CodeceptJS for CI](https://codecept.discourse.group/c/CodeceptJS-issues-in-general/ci/9) and see your post listed here!
+CodeceptJS runs in any CI system that can install Node.js. The work is in the surrounding environment: a headless browser, a driver server for WebDriver, failure artifacts to upload, and a parallelization strategy that keeps the wall-clock time reasonable. This guide covers each step and provides drop-in configs for the major CI systems.
+
+## Preparing tests for CI
+
+A CI-ready suite needs only a few things:
+
+- **Headless mode.** Playwright runs headless by default โ€” only act if you set `show: true` locally. To toggle it from CI, export `HEADLESS=true` and read it from your config.
+- **Colored logs.** Export `FORCE_COLOR=1` so CodeceptJS output renders correctly in CI log viewers.
+- **Failure artifacts.** Keep `screenshotOnFail` enabled (it is on by default). For Playwright, also enable `trace` and `video` in the helper config โ€” they make a remote failure diagnosable from a single artifact.
+- **Self-healing for flaky tests.** Use the [`heal` plugin](/heal) to recover from broken locators. The `retryFailedStep` plugin is already enabled by default โ€” you do not need to configure it.
+
+You do **not** need to set `CI=true`. Every CI provider exports it automatically, and CodeceptJS reads it to relax certain timeouts.
+
+## Installing browsers and drivers
+
+### Playwright
+
+Playwright needs browser binaries plus Linux system libraries. The recommended approach (per the [official Playwright CI docs](https://playwright.dev/docs/ci)) is:
+
+```bash
+npm ci
+npx playwright install --with-deps chromium
+```
+
+`--with-deps` pulls in `libnss`, fonts, and other OS packages. To install all engines, drop the `chromium` argument. Playwright explicitly recommends against caching browser binaries โ€” restoring the cache takes about as long as a fresh download.
+
+If you prefer the official Playwright Docker image, see the [Playwright Docker docs](https://playwright.dev/docs/docker). Pin the image tag to **the same version as your installed `playwright` package** โ€” a mismatched image will fail to find browser executables. The examples below use `node:20` + `npx playwright install --with-deps` to avoid this version-pin problem entirely.
+
+### WebDriver
+
+CodeceptJS's WebDriver helper talks to any WebDriver-protocol endpoint. In CI, the simplest setup is a [Selenium Docker container](https://github.com/SeleniumHQ/docker-selenium):
+
+```bash
+docker run -d --net=host --shm-size=2g selenium/standalone-chrome
+```
+
+Point the helper at it:
+
+```js
+helpers: {
+  WebDriver: {
+    url: 'http://localhost:8000',
+    browser: 'chrome',
+    host: process.env.SELENIUM_HOST || 'localhost',
+    port: parseInt(process.env.SELENIUM_PORT || '4444', 10),
+  }
+}
+```
+
+For an alternative without Selenium, see the [WebDriver helper docs](/webdriver) โ€” recent WebdriverIO versions can manage drivers (chromedriver, geckodriver) directly. Selenium is still the most portable choice for CI.
+
+`--shm-size=2g` matters. The default 64 MB causes Chrome tabs to crash on heavy pages.
+
+## Running tests
+
+A single process:
+
+```bash
+npx codeceptjs run
+```
+
+Parallel workers on one machine:
+
+```bash
+npx codeceptjs run-workers 4 --by pool
+```
+
+`--by pool` distributes tests dynamically: each worker grabs the next test as it finishes, so no worker sits idle. See [Parallel Execution](/parallel) for `--by test` and `--by suite`.
+
+Sharded across multiple machines (CI matrix):
+
+```bash
+npx codeceptjs run --shard 1/4
+npx codeceptjs run --shard 2/4
+npx codeceptjs run --shard 3/4
+npx codeceptjs run --shard 4/4
+```
+
+You can combine the two โ€” each shard runs on its own machine, and `run-workers` parallelizes within the shard.
+
+Filter by tag:
+
+```bash
+npx codeceptjs run --grep "@smoke"
+npx codeceptjs run --grep "@slow" --invert
+```
+
+## Reporting
+
+For CI test reporting, use [`@testomatio/reporter`](https://github.com/testomatio/reporter). It ships built-in **pipes** that publish results directly into the CI platform's UI โ€” no XML wrangling required.
+
+| CI | Recommended pipes | Result |
+|---|---|---|
+| GitHub Actions | `github` + `html` | PR check annotations + a self-contained HTML report |
+| GitLab CI | `gitlab` | Merge request widget with test results |
+| Bitbucket Pipelines | `bitbucket` | Pipeline test report |
+| Any | `html` | HTML report you can upload as an artifact |
+
+Install:
+
+```bash
+npm i --save-dev @testomatio/reporter
+```
+
+See the [reporter README](https://github.com/testomatio/reporter) for the per-pipe environment variables.
+
+Whatever reporter you use, also upload the `output/` directory as a build artifact. It contains failure screenshots and, with Playwright, traces and videos.
+
+For other reporter formats, see [Reports](/reports).
+
+## CI system examples
+
+The examples below use Playwright by default. A WebDriver-with-Selenium variant follows where it differs.
+
+### GitHub Actions โ€” Playwright
+
+`.github/workflows/tests.yml`:
+
+```yaml
+name: Tests
+on:
+  push:
+    branches: [main]
+  pull_request:
+
+env:
+  FORCE_COLOR: 1
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+          cache: npm
+      - run: npm ci
+      - run: npx playwright install --with-deps chromium
+      - run: npx codeceptjs run-workers 4 --by pool
+      - uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: codeceptjs-output
+          path: output/
+```
+
+### GitHub Actions โ€” WebDriver + Selenium
+
+```yaml
+name: WebDriver Tests
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    services:
+      selenium:
+        image: selenium/standalone-chrome
+        ports:
+          - 4444:4444
+        options: --shm-size=2g
+    env:
+      SELENIUM_HOST: localhost
+      SELENIUM_PORT: 4444
+      FORCE_COLOR: 1
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - run: npm ci
+      - run: npx codeceptjs run-workers 2 --by pool
+      - uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: codeceptjs-output
+          path: output/
+```
+
+### GitHub Actions โ€” Sharding matrix
+
+Each shard runs on its own runner in parallel:
+
+```yaml
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: ['1/4', '2/4', '3/4', '4/4']
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - run: npm ci
+      - run: npx playwright install --with-deps chromium
+      - run: npx codeceptjs run --shard ${{ matrix.shard }}
+      - uses: actions/upload-artifact@v4
+        if: failure()
+        with:
+          name: output-shard-${{ strategy.job-index }}
+          path: output/
+```
+
+### GitLab CI
+
+`.gitlab-ci.yml`:
+
+```yaml
+stages: [test]
+
+playwright:
+  stage: test
+  image: node:20
+  variables:
+    FORCE_COLOR: "1"
+  parallel: 4
+  before_script:
+    - npm ci
+    - npx playwright install --with-deps chromium
+  script:
+    - npx codeceptjs run --shard $CI_NODE_INDEX/$CI_NODE_TOTAL
+  artifacts:
+    when: on_failure
+    paths:
+      - output/
+    expire_in: 1 week
+
+webdriver:
+  stage: test
+  image: node:20
+  services:
+    - name: selenium/standalone-chrome
+      alias: selenium
+  variables:
+    SELENIUM_HOST: selenium
+    SELENIUM_PORT: "4444"
+  script:
+    - npm ci
+    - npx codeceptjs run-workers 2 --by pool
+  artifacts:
+    when: on_failure
+    paths: [output/]
+```
+
+`$CI_NODE_INDEX` is 1-based, which matches CodeceptJS's `--shard` syntax exactly.
+
+### Bitbucket Pipelines
+
+`bitbucket-pipelines.yml`:
+
+```yaml
+image: node:20
+
+definitions:
+  services:
+    selenium:
+      image: selenium/standalone-chrome
+      memory: 2048
+
+pipelines:
+  default:
+    - step:
+        name: Install
+        caches: [node]
+        script:
+          - npm ci
+          - npx playwright install --with-deps chromium
+    - parallel:
+        - step:
+            name: Shard 1/4
+            script:
+              - npx codeceptjs run --shard 1/4
+            artifacts:
+              - output/**
+        - step:
+            name: Shard 2/4
+            script:
+              - npx codeceptjs run --shard 2/4
+            artifacts:
+              - output/**
+        - step:
+            name: Shard 3/4
+            script:
+              - npx codeceptjs run --shard 3/4
+            artifacts:
+              - output/**
+        - step:
+            name: Shard 4/4
+            script:
+              - npx codeceptjs run --shard 4/4
+            artifacts:
+              - output/**
+```
+
+For WebDriver, attach the Selenium service to the step:
+
+```yaml
+pipelines:
+  default:
+    - step:
+        image: node:20
+        services: [selenium]
+        script:
+          - npm ci
+          - export SELENIUM_HOST=localhost SELENIUM_PORT=4444
+          - npx codeceptjs run-workers 2 --by pool
+        artifacts:
+          - output/**
+```
+
+### Jenkins
+
+`Jenkinsfile` (declarative pipeline):
+
+```groovy
+pipeline {
+  agent {
+    docker {
+      image 'node:20'
+      args '-u root'
+    }
+  }
+  environment {
+    FORCE_COLOR = '1'
+  }
+  stages {
+    stage('Install') {
+      steps {
+        sh 'npm ci'
+        sh 'npx playwright install --with-deps chromium'
+      }
+    }
+    stage('Test') {
+      parallel {
+        stage('Shard 1/4') { steps { sh 'npx codeceptjs run --shard 1/4' } }
+        stage('Shard 2/4') { steps { sh 'npx codeceptjs run --shard 2/4' } }
+        stage('Shard 3/4') { steps { sh 'npx codeceptjs run --shard 3/4' } }
+        stage('Shard 4/4') { steps { sh 'npx codeceptjs run --shard 4/4' } }
+      }
+    }
+  }
+  post {
+    failure {
+      archiveArtifacts artifacts: 'output/**', allowEmptyArchive: true
+    }
+  }
+}
+```
+
+For WebDriver, launch Selenium alongside the test container:
+
+```groovy
+stage('Test') {
+  steps {
+    script {
+      docker.image('selenium/standalone-chrome')
+            .withRun('--shm-size=2g -p 4444:4444') { c ->
+        sh '''
+          export SELENIUM_HOST=localhost SELENIUM_PORT=4444
+          npx codeceptjs run-workers 2 --by pool
+        '''
+      }
+    }
+  }
+}
+```
+
+### CircleCI
+
+`.circleci/config.yml`:
+
+```yaml
+version: 2.1
+
+jobs:
+  test:
+    docker:
+      - image: cimg/node:20.18-browsers
+    parallelism: 4
+    steps:
+      - checkout
+      - run: npm ci
+      - run: npx playwright install --with-deps chromium
+      - run:
+          name: Run shard
+          command: |
+            INDEX=$((CIRCLE_NODE_INDEX + 1))
+            npx codeceptjs run --shard ${INDEX}/${CIRCLE_NODE_TOTAL}
+      - store_artifacts:
+          path: output
+
+  webdriver:
+    docker:
+      - image: cimg/node:20.18
+      - image: selenium/standalone-chrome
+    environment:
+      SELENIUM_HOST: localhost
+      SELENIUM_PORT: 4444
+    steps:
+      - checkout
+      - run: npm ci
+      - run: npx codeceptjs run-workers 2 --by pool
+      - store_artifacts:
+          path: output
+
+workflows:
+  test:
+    jobs:
+      - test
+      - webdriver
+```
+
+`CIRCLE_NODE_INDEX` is 0-based, so add 1 to match CodeceptJS's 1-based `--shard` index.
+
+### Azure Pipelines
+
+`azure-pipelines.yml`:
+
+```yaml
+trigger: [main]
+
+pool:
+  vmImage: ubuntu-latest
+
+strategy:
+  parallel: 4
+
+steps:
+  - task: NodeTool@0
+    inputs:
+      versionSpec: '20.x'
+  - script: npm ci
+    displayName: Install dependencies
+  - script: npx playwright install --with-deps chromium
+    displayName: Install Playwright browsers
+  - script: |
+      npx codeceptjs run --shard $(System.JobPositionInPhase)/$(System.TotalJobsInPhase)
+    displayName: Run shard $(System.JobPositionInPhase)/$(System.TotalJobsInPhase)
+    env:
+      FORCE_COLOR: 1
+  - task: PublishBuildArtifacts@1
+    condition: failed()
+    inputs:
+      pathToPublish: output
+      artifactName: codeceptjs-output-$(System.JobPositionInPhase)
+```
+
+For WebDriver, run Selenium as a sidecar before tests:
+
+```yaml
+  - script: docker run -d --net=host --shm-size=2g selenium/standalone-chrome
+    displayName: Start Selenium
+  - script: |
+      export SELENIUM_HOST=localhost SELENIUM_PORT=4444
+      npx codeceptjs run-workers 2 --by pool
+    displayName: Run tests
+```
+
+## Docker
+
+The official `codeceptjs/codeceptjs` image runs Playwright, Puppeteer, and WebDriver suites without further setup. Pass runtime flags through `CODECEPT_ARGS` and the worker count through `NO_OF_WORKERS`. See [Docker](/docker) for the full reference and Compose examples.
+
+## Tips
+
+- **Raise per-test timeouts in CI.** CI machines are slower than your laptop. Bump `timeout` in `codecept.conf.js` when assertions race the page.
+- **Diagnose from logs.** Re-run with `--debug` or `DEBUG=codeceptjs:*` when a job fails and you cannot reproduce locally.
+- **Selenium Chrome: always `--shm-size=2g`.** The default 64 MB causes tab crashes on heavy pages.
+- **Custom Playwright images: install OS deps.** When you cannot use `mcr.microsoft.com/playwright`, run `npx playwright install --with-deps` to pull in `libnss`, fonts, and other system libraries.
+- **Upload `output/` only on failure.** Successful runs produce no useful artifacts.
+
+## See also
 
-Continuous Integration services allows you to delegate the control of running tests to external system.
-CodeceptJS plays well with all types of CI even when there is no documentation on this topic, it is still easy to set up with any kind of hosted or cloud CI.
-Our community prepared some valuable recipes for setting up CI systems with CodeceptJS.
+- [Playwright CI guide](https://playwright.dev/docs/ci) โ€” upstream notes on browser install, sharding, and per-platform config.
+- [Playwright Docker image](https://playwright.dev/docs/docker) โ€” image tags and the version-pinning rule.
+- [WebdriverIO Selenium Grid](https://webdriver.io/docs/seleniumgrid) โ€” connection options for `host`/`port`/`path`.
+- [Selenium Docker images](https://github.com/SeleniumHQ/docker-selenium) โ€” image variants (`standalone-chrome`, `standalone-firefox`, debug images with VNC).
 
-## Recipes
+## Community recipes
 
-* ### [CodeceptJS - Codefresh Integration](https://codecept.discourse.group/t/codeceptjs-codefresh-integration/)
-* ### [CodeceptJS GitLab Integration](https://codecept.discourse.group/t/codeceptjs-gitlab-integration/)
-* ### [CodeceptJS - Jenkins Integration](https://codecept.discourse.group/t/codeceptjs-jenkins-integration/)
-* ### [CodeceptJS Integration with TeamCity](https://codecept.discourse.group/t/codeceptjs-integration-with-teamcity/)
+- [CodeceptJS โ€” Codefresh Integration](https://codecept.discourse.group/t/codeceptjs-codefresh-integration/)
+- [CodeceptJS โ€” GitLab Integration](https://codecept.discourse.group/t/codeceptjs-gitlab-integration/)
+- [CodeceptJS โ€” Jenkins Integration](https://codecept.discourse.group/t/codeceptjs-jenkins-integration/)
+- [CodeceptJS โ€” TeamCity Integration](https://codecept.discourse.group/t/codeceptjs-integration-with-teamcity/)
 
+Got a setup that works for you? [Share your recipe](https://codecept.discourse.group/c/CodeceptJS-issues-in-general/ci/9) and we will list it here.
diff --git a/docs/migration-4.md b/docs/migration-4.md
index bda2baa7c..647284b9b 100644
--- a/docs/migration-4.md
+++ b/docs/migration-4.md
@@ -387,16 +387,16 @@ 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. |
+| `pause()`, `inject()`, `share()` | **Still global.** Always available (with or without `noGlobals`) โ€” they're the standard wiring/debugging entry points and run before any import would resolve. `pause` and `inject` are also exported from `codeceptjs` if you prefer explicit imports. |
 | `codecept_dir`, `output_dir` | **Still global** (kept for backward compatibility with external plugins). |
-| `pause`, `within`, `session`, `secret`, `locate`, `dataTable`, `actor`, `codeceptjs` | Import from `codeceptjs`. |
+| `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 { within, session, secret, locate, dataTable, actor } from 'codeceptjs'
 import Helper from '@codeceptjs/helper'
 ```
 
diff --git a/lib/command/dryRun.js b/lib/command/dryRun.js
index 566a68911..b44183b8b 100644
--- a/lib/command/dryRun.js
+++ b/lib/command/dryRun.js
@@ -10,6 +10,7 @@ import Container from '../container.js'
 export default async function (test, options) {
   if (options.grep) process.env.grep = options.grep
   if (options.ansi === false) chalk.level = 0
+  store.dryRun = true
   const configFile = options.config
   let codecept
 
@@ -20,9 +21,15 @@ export default async function (test, options) {
   }
 
   if (config.plugins) {
-    // disable all plugins by default, they can be enabled with -p option
+    // Disable plugins that block (interactive) or perform external I/O (AI/network).
+    // Leave the rest enabled so they can register support objects (e.g. auth registers
+    // `login`); helper calls inside those support fns are already no-op'd by HelperStep
+    // when store.dryRun is true.
+    const disableInDryRun = new Set(['pause', 'pauseOnFail', 'analyze', 'aiTrace', 'pageInfo', 'heal'])
     for (const plugin in config.plugins) {
-      config.plugins[plugin].enabled = false
+      if (disableInDryRun.has(plugin)) {
+        config.plugins[plugin].enabled = false
+      }
     }
   }
 
@@ -33,7 +40,6 @@ export default async function (test, options) {
     if (options.bootstrap) await codecept.bootstrap()
 
     codecept.loadTests()
-    store.dryRun = true
 
     if (!options.steps && !options.verbose && !options.debug) {
       await printTests(codecept.testFiles)
diff --git a/lib/command/init.js b/lib/command/init.js
index 9e6295d10..6cb80e9f8 100644
--- a/lib/command/init.js
+++ b/lib/command/init.js
@@ -15,7 +15,7 @@ import { test as generateTest } from './generate.js'
 const isLocal = installedLocally()
 
 const defaultConfig = {
-  tests: './*_test.js',
+  tests: './tests/*_test.js',
   output: '',
   helpers: {},
   include: {},
@@ -25,14 +25,6 @@ const defaultConfig = {
 }
 
 const helpers = ['Playwright', 'WebDriver', 'Puppeteer', 'REST', 'GraphQL', 'Appium']
-const noTranslation = 'English (no localization)'
-
-async function getTranslations() {
-  const translationsModule = await import('../../translations/index.js')
-  const translations = Object.keys(translationsModule.default || translationsModule)
-  translations.unshift(noTranslation)
-  return translations
-}
 
 const packages = []
 let isTypeScript = false
@@ -51,6 +43,7 @@ setCommonPlugins();
 `
 
 const defaultActor = `// in this file you can append custom step methods to 'I' object
+import { actor } from 'codeceptjs';
 
 export default function() {
   return actor({
@@ -63,6 +56,7 @@ export default function() {
 `
 
 const defaultActorTs = `// in this file you can append custom step methods to 'I' object
+import { actor } from 'codeceptjs';
 
 export = function() {
   return actor({
@@ -74,9 +68,9 @@ export = function() {
 }
 `
 
-export default async function (initPath) {
+export default async function (initPath, options = {}) {
   const testsPath = getTestRoot(initPath)
-  const translations = await getTranslations()
+  const skipPrompts = !!options.yes
 
   print()
   print(`  Welcome to ${colors.magenta.bold('CodeceptJS')} initialization tool`)
@@ -114,251 +108,265 @@ export default async function (initPath) {
     return
   }
 
-  inquirer
-    .prompt([
-      {
-        name: 'typescript',
-        type: 'confirm',
-        default: false,
-        message: 'Do you plan to write tests in TypeScript?',
-      },
-      {
-        name: 'tests',
-        type: 'input',
-        default: answers => `./*_test.${answers.typescript ? 'ts' : 'js'}`,
-        message: 'Where are your tests located?',
-      },
-      {
-        name: 'helper',
-        type: 'list',
-        choices: helpers,
-        default: 'Playwright',
-        message: 'What helpers do you want to use?',
-      },
-      {
-        name: 'jsonResponse',
-        type: 'confirm',
-        default: true,
-        message: 'Do you want to use JSONResponse helper for assertions on JSON responses? http://bit.ly/3ASVPy9',
-        when: answers => ['GraphQL', 'REST'].includes(answers.helper) === true,
-      },
-      {
-        name: 'output',
-        default: './output',
-        message: 'Where should logs, screenshots, and reports to be stored?',
-      },
-      {
-        name: 'translation',
-        type: 'list',
-        message: 'Do you want to enable localization for tests? http://bit.ly/3GNUBbh',
-        choices: translations,
-      },
-    ])
-    .then(async result => {
-      if (result.typescript === true) {
-        isTypeScript = true
-        extension = isTypeScript === true ? 'ts' : 'js'
-        packages.push('typescript')
-        packages.push('tsx')  // Add tsx for TypeScript support
-        packages.push('@types/node')
+  const result = skipPrompts
+    ? {
+        typescript: false,
+        tests: './tests/*_test.js',
+        helper: 'Playwright',
+        jsonResponse: false,
+        output: './output',
       }
+    : await inquirer.prompt([
+        {
+          name: 'typescript',
+          type: 'confirm',
+          default: false,
+          message: 'Do you plan to write tests in TypeScript?',
+        },
+        {
+          name: 'tests',
+          type: 'input',
+          default: answers => `./tests/*_test.${answers.typescript ? 'ts' : 'js'}`,
+          message: 'Where are your tests located?',
+        },
+        {
+          name: 'helper',
+          type: 'list',
+          choices: helpers,
+          default: 'Playwright',
+          message: 'What helpers do you want to use?',
+        },
+        {
+          name: 'jsonResponse',
+          type: 'confirm',
+          default: true,
+          message: 'Do you want to use JSONResponse helper for assertions on JSON responses? http://bit.ly/3ASVPy9',
+          when: answers => ['GraphQL', 'REST'].includes(answers.helper) === true,
+        },
+        {
+          name: 'output',
+          default: './output',
+          message: 'Where should logs, screenshots, and reports to be stored?',
+        },
+      ])
+
+  if (result.typescript === true) {
+    isTypeScript = true
+    extension = isTypeScript === true ? 'ts' : 'js'
+    packages.push('typescript')
+    packages.push('tsx')
+    packages.push('@types/node')
+  }
 
-      const config = defaultConfig
-      config.name = testsPath.split(path.sep).pop()
-      config.output = result.output
+  const config = defaultConfig
+  config.name = testsPath.split(path.sep).pop()
+  config.output = result.output
 
-      config.tests = result.tests
-      if (isTypeScript) {
-        config.tests = `${config.tests.replace(/\.js$/, `.${extension}`)}`
-        config.require = ['tsx/cjs']  // Add tsx/cjs loader for TypeScript tests
-      }
+  config.tests = result.tests
+  if (isTypeScript) {
+    config.tests = `${config.tests.replace(/\.js$/, `.${extension}`)}`
+    config.require = ['tsx/cjs']
+  }
+
+  const matchResults = config.tests.match(/[^*.]+/)
+  if (matchResults) {
+    mkdirp.sync(path.join(testsPath, matchResults[0]))
+  }
+
+  const helperName = result.helper
+  config.helpers[helperName] = {}
 
-      // create a directory tests if it is included in tests path
-      const matchResults = config.tests.match(/[^*.]+/)
-      if (matchResults) {
-        mkdirp.sync(path.join(testsPath, matchResults[0]))
+  if (result.jsonResponse === true) {
+    config.helpers.JSONResponse = {}
+  }
+
+  let helperConfigs = []
+
+  try {
+    const HelperModule = await import(`../helper/${helperName}.js`)
+    const Helper = HelperModule.default || HelperModule
+    if (Helper._checkRequirements) {
+      packages.concat(Helper._checkRequirements())
+    }
+
+    if (!Helper._config()) return
+    helperConfigs = helperConfigs.concat(
+      Helper._config().map(config => {
+        config.message = `[${helperName}] ${config.message}`
+        config.name = `${helperName}_${config.name}`
+        config.type = config.type || 'input'
+        return config
+      }),
+    )
+  } catch (err) {
+    error(err)
+  }
+
+  const finish = async () => {
+    const stepFile = `./steps_file.${extension}`
+    fs.writeFileSync(path.join(testsPath, stepFile), extension === 'ts' ? defaultActorTs : defaultActor)
+
+    config.include = { I: isTypeScript ? './steps_file' : stepFile }
+
+    print(`Steps file created at ${stepFile}`)
+
+    let configSource
+    const hasConfigure = isLocal && !initPath
+
+    if (isTypeScript) {
+      configSource = beautify(`export const config : CodeceptJS.MainConfig = ${inspect(config, false, 4, false)}`)
+
+      if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource
+
+      fs.writeFileSync(typeScriptconfigFile, configSource, 'utf-8')
+      print(`Config created at ${typeScriptconfigFile}`)
+    } else {
+      configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexport const config = ${inspect(config, false, 4, false)}`)
+
+      if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource
+
+      fs.writeFileSync(configFile, configSource, 'utf-8')
+      print(`Config created at ${configFile}`)
+    }
+
+    if (config.output) {
+      if (!fileExists(config.output)) {
+        mkdirp.sync(path.join(testsPath, config.output))
+        print(`Directory for temporary output files created at '${config.output}'`)
+      } else {
+        print(`Directory for temporary output files is already created at '${config.output}'`)
       }
+    }
 
-      if (result.translation !== noTranslation) config.translation = result.translation
+    const jsconfig = {
+      compilerOptions: {
+        allowJs: true,
+      },
+    }
 
-      const helperName = result.helper
-      config.helpers[helperName] = {}
+    const tsconfig = {
+      'ts-node': {
+        files: true,
+      },
+      compilerOptions: {
+        target: 'es2018',
+        lib: ['es2018', 'DOM'],
+        esModuleInterop: true,
+        module: 'commonjs',
+        strictNullChecks: false,
+        types: ['codeceptjs', 'node'],
+        declaration: true,
+        skipLibCheck: true,
+      },
+      exclude: ['node_modules'],
+    }
 
-      if (result.jsonResponse === true) {
-        config.helpers.JSONResponse = {}
+    if (isTypeScript) {
+      const tsconfigJson = beautify(JSON.stringify(tsconfig))
+      const tsconfigFile = path.join(testsPath, 'tsconfig.json')
+      if (fileExists(tsconfigFile)) {
+        print(`tsconfig.json already exists at ${tsconfigFile}`)
+      } else {
+        fs.writeFileSync(tsconfigFile, tsconfigJson)
+      }
+    } else {
+      const jsconfigJson = beautify(JSON.stringify(jsconfig))
+      const jsconfigFile = path.join(testsPath, 'jsconfig.json')
+      if (fileExists(jsconfigFile)) {
+        print(`jsconfig.json already exists at ${jsconfigFile}`)
+      } else {
+        fs.writeFileSync(jsconfigFile, jsconfigJson)
+        print(`Intellisense enabled in ${jsconfigFile}`)
       }
+    }
 
-      let helperConfigs = []
+    const generateDefinitionsManually = colors.bold(`To get auto-completion support, please generate type definitions: ${colors.green('npx codeceptjs def')}`)
 
+    if (packages) {
       try {
-        const HelperModule = await import(`../helper/${helperName}.js`)
-        const Helper = HelperModule.default || HelperModule
-        if (Helper._checkRequirements) {
-          packages.concat(Helper._checkRequirements())
-        }
-
-        if (!Helper._config()) return
-        helperConfigs = helperConfigs.concat(
-          Helper._config().map(config => {
-            config.message = `[${helperName}] ${config.message}`
-            config.name = `${helperName}_${config.name}`
-            config.type = config.type || 'input'
-            return config
-          }),
-        )
+        install(packages)
       } catch (err) {
-        error(err)
+        print(colors.bold.red(err.toString()))
+        print()
+        print(colors.bold.red('Please install next packages manually:'))
+        print(`npm i ${packages.join(' ')} --save-dev`)
+        print()
+        print('Things to do after missing packages installed:')
+        print('โ˜‘', generateDefinitionsManually)
+        print('โ˜‘ Create first test:', colors.green('npx codeceptjs gt'))
+        print(colors.bold.magenta('Find more information at https://codecept.io'))
+        return
       }
+    }
 
-      const finish = async () => {
-        // create steps file by default
-        // no extra step file for typescript (as it doesn't match TS conventions)
-        const stepFile = `./steps_file.${extension}`
-        fs.writeFileSync(path.join(testsPath, stepFile), extension === 'ts' ? defaultActorTs : defaultActor)
-
-        if (isTypeScript) {
-          config.include = await _actorTranslation('./steps_file', config.translation, translations)
-        } else {
-          config.include = await _actorTranslation(stepFile, config.translation, translations)
-        }
-
-        print(`Steps file created at ${stepFile}`)
-
-        let configSource
-        const hasConfigure = isLocal && !initPath
-
-        if (isTypeScript) {
-          configSource = beautify(`export const config : CodeceptJS.MainConfig = ${inspect(config, false, 4, false)}`)
-
-          if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource
-
-          fs.writeFileSync(typeScriptconfigFile, configSource, 'utf-8')
-          print(`Config created at ${typeScriptconfigFile}`)
-        } else {
-          configSource = beautify(`/** @type {CodeceptJS.MainConfig} */\nexport const config = ${inspect(config, false, 4, false)}`)
-
-          if (hasConfigure) configSource = importCodeceptConfigure + configHeader + configSource
-
-          fs.writeFileSync(configFile, configSource, 'utf-8')
-          print(`Config created at ${configFile}`)
-        }
-
-        if (config.output) {
-          if (!fileExists(config.output)) {
-            mkdirp.sync(path.join(testsPath, config.output))
-            print(`Directory for temporary output files created at '${config.output}'`)
-          } else {
-            print(`Directory for temporary output files is already created at '${config.output}'`)
-          }
-        }
-
-        const jsconfig = {
-          compilerOptions: {
-            allowJs: true,
-          },
-        }
-
-        const tsconfig = {
-          'ts-node': {
-            files: true,
-          },
-          compilerOptions: {
-            target: 'es2018',
-            lib: ['es2018', 'DOM'],
-            esModuleInterop: true,
-            module: 'commonjs',
-            strictNullChecks: false,
-            types: ['codeceptjs', 'node'],
-            declaration: true,
-            skipLibCheck: true,
-          },
-          exclude: ['node_modules'],
-        }
-
-        if (isTypeScript) {
-          const tsconfigJson = beautify(JSON.stringify(tsconfig))
-          const tsconfigFile = path.join(testsPath, 'tsconfig.json')
-          if (fileExists(tsconfigFile)) {
-            print(`tsconfig.json already exists at ${tsconfigFile}`)
-          } else {
-            fs.writeFileSync(tsconfigFile, tsconfigJson)
-          }
-        } else {
-          const jsconfigJson = beautify(JSON.stringify(jsconfig))
-          const jsconfigFile = path.join(testsPath, 'jsconfig.json')
-          if (fileExists(jsconfigFile)) {
-            print(`jsconfig.json already exists at ${jsconfigFile}`)
-          } else {
-            fs.writeFileSync(jsconfigFile, jsconfigJson)
-            print(`Intellisense enabled in ${jsconfigFile}`)
-          }
-        }
-
-        const generateDefinitionsManually = colors.bold(`To get auto-completion support, please generate type definitions: ${colors.green('npx codeceptjs def')}`)
-
-        if (packages) {
-          try {
-            install(packages)
-          } catch (err) {
-            print(colors.bold.red(err.toString()))
-            print()
-            print(colors.bold.red('Please install next packages manually:'))
-            print(`npm i ${packages.join(' ')} --save-dev`)
-            print()
-            print('Things to do after missing packages installed:')
-            print('โ˜‘', generateDefinitionsManually)
-            print('โ˜‘ Create first test:', colors.green('npx codeceptjs gt'))
-            print(colors.bold.magenta('Find more information at https://codecept.io'))
-            return
-          }
-        }
-
-        try {
-          generateDefinitions(testsPath, {})
-        } catch (err) {
-          print(colors.bold.red("Couldn't generate type definitions"))
-          print(colors.red(err.toString()))
-          print('Skipping type definitions...')
-          print(generateDefinitionsManually)
-        }
-
-        print('')
-        success(' Almost ready... Next step:')
-
-        const generatedTest = generateTest(testsPath)
-        if (!generatedTest) return
-        generatedTest.then(() => {
-          print('\n--')
-          print(colors.bold.green('CodeceptJS Installed! Enjoy supercharged testing! ๐Ÿคฉ'))
-          print(colors.bold.magenta('Find more information at https://codecept.io'))
-          print()
-        })
-      }
+    try {
+      generateDefinitions(testsPath, {})
+    } catch (err) {
+      print(colors.bold.red("Couldn't generate type definitions"))
+      print(colors.red(err.toString()))
+      print('Skipping type definitions...')
+      print(generateDefinitionsManually)
+    }
+
+    print('')
+    success(' Almost ready... Next step:')
+
+    if (skipPrompts) {
+      print('\n--')
+      print(colors.bold.green('CodeceptJS Installed! Enjoy supercharged testing! ๐Ÿคฉ'))
+      print(colors.bold.magenta('Find more information at https://codecept.io'))
+      print()
+      return
+    }
 
-      print('Configure helpers...')
-      inquirer.prompt(helperConfigs).then(async helperResult => {
-        if (helperResult.Playwright_browser === 'electron') {
-          delete helperResult.Playwright_url
-          delete helperResult.Playwright_show
-
-          helperResult.Playwright_electron = {
-            executablePath: '// require("electron") or require("electron-forge")',
-            args: ['path/to/your/main.js'],
-          }
-        }
-
-        Object.keys(helperResult).forEach(key => {
-          const parts = key.split('_')
-          const helperName = parts[0]
-          const configName = parts[1]
-          if (!configName) return
-          config.helpers[helperName][configName] = helperResult[key]
-        })
-
-        print('')
-        await finish()
-      })
+    const generatedTest = generateTest(testsPath)
+    if (!generatedTest) return
+    generatedTest.then(() => {
+      print('\n--')
+      print(colors.bold.green('CodeceptJS Installed! Enjoy supercharged testing! ๐Ÿคฉ'))
+      print(colors.bold.magenta('Find more information at https://codecept.io'))
+      print()
     })
+  }
+
+  const helperResult = skipPrompts
+    ? defaultHelperAnswers(helperName)
+    : await (async () => {
+        print('Configure helpers...')
+        return inquirer.prompt(helperConfigs)
+      })()
+
+  if (helperResult.Playwright_browser === 'electron') {
+    delete helperResult.Playwright_url
+    delete helperResult.Playwright_show
+
+    helperResult.Playwright_electron = {
+      executablePath: '// require("electron") or require("electron-forge")',
+      args: ['path/to/your/main.js'],
+    }
+  }
+
+  Object.keys(helperResult).forEach(key => {
+    const parts = key.split('_')
+    const hName = parts[0]
+    const configName = parts[1]
+    if (!configName) return
+    config.helpers[hName][configName] = helperResult[key]
+  })
+
+  print('')
+  await finish()
+}
+
+function defaultHelperAnswers(helperName) {
+  if (helperName === 'Playwright') {
+    return {
+      Playwright_browser: 'chromium',
+      Playwright_url: process.env.BASE_URL || 'http://localhost',
+      Playwright_show: false,
+    }
+  }
+  return {}
 }
 
 function install(dependencies) {
@@ -399,30 +407,3 @@ function install(dependencies) {
   }
   return true
 }
-
-async function _actorTranslation(stepFile, translationSelected, translations) {
-  let actor
-
-  for (const translationAvailable of translations) {
-    if (actor) {
-      break
-    }
-
-    if (translationSelected === translationAvailable) {
-      const translationsModule = await import('../../translations/index.js')
-      const nameOfActor = (translationsModule.default || translationsModule)[translationAvailable].I
-
-      actor = {
-        [nameOfActor]: stepFile,
-      }
-    }
-  }
-
-  if (!actor) {
-    actor = {
-      I: stepFile,
-    }
-  }
-
-  return actor
-}
diff --git a/lib/config.js b/lib/config.js
index 7779c0cd3..6365dd5ff 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -214,6 +214,15 @@ async function loadConfigFile(configFile) {
   const require = createRequire(import.meta.url)
   const extensionName = path.extname(configFile)
 
+  // Populate the in-process registry that packages like @codeceptjs/configure
+  // look up at config-import time (their proxies throw if `globalThis.codeceptjs`
+  // is missing). initCodeceptGlobals sets this too, but only later during
+  // bootstrap โ€” config files are imported here first.
+  if (!globalThis.codeceptjs) {
+    const indexModule = await import('./index.js')
+    globalThis.codeceptjs = indexModule.default || indexModule
+  }
+
   // .conf.js config file
   if (extensionName === '.js' || extensionName === '.ts' || extensionName === '.cjs') {
     let configModule
diff --git a/lib/element/WebElement.js b/lib/element/WebElement.js
index d40d312a9..79c8793f2 100644
--- a/lib/element/WebElement.js
+++ b/lib/element/WebElement.js
@@ -513,6 +513,43 @@ class WebElement {
     return simplifyHtmlElement(outerHTML, maxLength)
   }
 
+  /**
+   * Plain-object snapshot of the element โ€” text, simplified HTML, visibility,
+   * enabled state, and a curated set of attributes. Each underlying call is
+   * isolated so a single failure (e.g. detached element) doesn't poison the
+   * rest. Suitable for JSON.stringify, log output, MCP tool responses.
+   *
+   * @param {object}   [opts]
+   * @param {number}   [opts.maxHtmlLength=300]   passed through to toSimplifiedHTML
+   * @param {string[]} [opts.attrs]               attribute names to surface
+   * @returns {Promise<{text?: string, html?: string, visible?: boolean, enabled?: boolean, attrs?: object}>}
+   */
+  async describe({ maxHtmlLength = 300, attrs = ['id', 'class', 'name', 'role', 'type', 'href', 'value', 'aria-label', 'placeholder', 'data-testid'] } = {}) {
+    const out = {}
+    await Promise.all([
+      this.toSimplifiedHTML(maxHtmlLength).then(v => { if (v) out.html = v }, () => {}),
+      this.getText().then(v => { const t = v?.trim(); if (t) out.text = t }, () => {}),
+      this.isVisible().then(v => { out.visible = v }, () => {}),
+      this.isEnabled().then(v => { out.enabled = v }, () => {}),
+    ])
+    const collected = {}
+    await Promise.all(attrs.map(async name => {
+      try {
+        const v = await this.getAttribute(name)
+        if (v != null && v !== '') collected[name] = v
+      } catch {}
+    }))
+    if (Object.keys(collected).length) out.attrs = collected
+    return out
+  }
+
+  // Make accidental JSON.stringify (e.g. returning a WebElement from MCP run_code)
+  // produce a usable hint instead of `{}` โ€” the underlying handle isn't
+  // serializable. Use .describe() for the real plain-object snapshot.
+  toJSON() {
+    return `[WebElement ${this.helperType} โ€” call .describe() for a plain-object snapshot or .toSimplifiedHTML() for HTML]`
+  }
+
   _normalizeLocator(locator) {
     if (typeof locator === 'string') {
       return locator
diff --git a/lib/globals.js b/lib/globals.js
index 3285eb523..4ebdd4bf7 100644
--- a/lib/globals.js
+++ b/lib/globals.js
@@ -27,9 +27,19 @@ export async function initCodeceptGlobals(dir, config, container) {
   global.codecept_dir = dir
   global.output_dir = fsPath.resolve(dir, config.output)
 
+  // pause/inject/share stay global even under noGlobals โ€” they're the everyday
+  // debugging/wiring entry points and have no useful import alternative for
+  // page-object code that runs before the container is available.
+  global.pause = async (...args) => {
+    const pauseModule = await import('./pause.js')
+    return (pauseModule.default || pauseModule)(...args)
+  }
+  global.inject = () => container.support()
+  global.share = container.share
+
   if (config.noGlobals) return;
 
-  output.print(output.styles.debug('Global functions are deprecated. Use `import { Helper, pause, within, session } from "codeceptjs"` instead. Set `noGlobals: true` in config to disable globals.'));
+  output.print(output.styles.debug('Global functions are deprecated. Use `import { Helper, within, session } from "codeceptjs"` instead. Set `noGlobals: true` in config to disable globals.'));
 
   const HelperModule = await import('@codeceptjs/helper')
   global.Helper = global.codecept_helper = HelperModule.default || HelperModule
@@ -40,12 +50,6 @@ export async function initCodeceptGlobals(dir, config, container) {
   }
   global.Actor = global.actor
 
-  // Use dynamic imports for modules to avoid circular dependencies
-  global.pause = async (...args) => {
-    const pauseModule = await import('./pause.js')
-    return (pauseModule.default || pauseModule)(...args)
-  }
-
   global.within = async (...args) => {
     return (await import('./effects.js')).within(...args)
   }
@@ -62,9 +66,6 @@ export async function initCodeceptGlobals(dir, config, container) {
     return locator.build(locatorQuery)
   }
 
-  global.inject = () => container.support()
-  global.share = container.share
-
   const secretModule = await import('./secret.js')
   global.secret = secretModule.secret || (secretModule.default && secretModule.default.secret)
 
diff --git a/lib/helper/Playwright.js b/lib/helper/Playwright.js
index 0ea5b3cbb..3fdd6f822 100644
--- a/lib/helper/Playwright.js
+++ b/lib/helper/Playwright.js
@@ -2674,8 +2674,11 @@ class Playwright extends Helper {
    * @returns {Promise}
    */
   async executeScript(fn, arg) {
+    if (arg && typeof arg.getNativeElement === 'function') arg = arg.getNativeElement()
+    if (arg && typeof arg.evaluate === 'function' && typeof arg.locator === 'function') {
+      return arg.evaluate(fn)
+    }
     if (this.context && this.context.constructor.name === 'FrameLocator') {
-      // switching to iframe context
       return this.context.locator(':root').evaluate(fn, arg)
     }
     return this.page.evaluate.apply(this.page, [fn, arg])
diff --git a/lib/index.js b/lib/index.js
index 6d427892a..aaa09e76f 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -23,6 +23,10 @@ import heal from './heal.js'
 import ai from './ai.js'
 import Workers from './workers.js'
 import Secret, { secret } from './secret.js'
+import session from './session.js'
+
+const inject = (name) => container.support(name)
+const locate = (query) => locator.build(query)
 
 export default {
   /** @type {typeof CodeceptJS.Codecept} */
@@ -67,7 +71,11 @@ export default {
   Secret,
   /** @type {typeof CodeceptJS.secret} */
   secret,
+
+  session,
+  inject,
+  locate,
 }
 
 // Named exports for ESM compatibility
-export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret }
+export { codecept, output, container, event, recorder, config, actor, helper, pause, within, dataTable, dataTableArgument, store, locator, heal, ai, Workers, Secret, secret, session, inject, locate }
diff --git a/lib/mocha/factory.js b/lib/mocha/factory.js
index 2b8359b8d..308ecf08c 100644
--- a/lib/mocha/factory.js
+++ b/lib/mocha/factory.js
@@ -17,7 +17,12 @@ let mocha
 
 class MochaFactory {
   static create(config, opts) {
-    mocha = new Mocha(Object.assign(config, opts))
+    // cleanReferencesAfterRun defaults to true in Mocha 10+, which disposes the
+    // instance after the first .run(). The MCP server reuses one container
+    // across many tool calls, so disposal breaks every subsequent run_test /
+    // run_step_by_step. Allow callers to override; default to keeping refs.
+    const merged = Object.assign({ cleanReferencesAfterRun: false }, config, opts)
+    mocha = new Mocha(merged)
     output.process(opts.child)
     mocha.ui(scenarioUiFunction)
 
diff --git a/lib/mocha/inject.js b/lib/mocha/inject.js
index 3266a6841..ecbd266b1 100644
--- a/lib/mocha/inject.js
+++ b/lib/mocha/inject.js
@@ -5,7 +5,7 @@ const getInjectedArguments = async (fn, test, suite) => {
   const container = containerModule.default || containerModule
 
   const testArgs = {}
-  const params = getParams(fn) || []
+  const params = getParams(fn, { warnOnLegacyFormat: true }) || []
   const objects = container.support()
 
   for (const key of params) {
diff --git a/lib/parser.js b/lib/parser.js
index a529d6764..ea74ad0fc 100644
--- a/lib/parser.js
+++ b/lib/parser.js
@@ -14,11 +14,11 @@ export const getParamsToString = function (fn) {
   return getParams(newFn).join(', ')
 }
 
-function getParams(fn) {
+function getParams(fn, { warnOnLegacyFormat = false } = {}) {
   if (fn.isSinonProxy) return []
   try {
     const reflected = parser.parse(fn)
-    if (reflected.args.length > 1 || reflected.args[0] === 'I') {
+    if (warnOnLegacyFormat && (reflected.args.length > 1 || reflected.args[0] === 'I')) {
       output.error('Error: old CodeceptJS v2 format detected. Upgrade your project to the new format -> https://bit.ly/codecept3Up')
     }
     if (reflected.destructuredArgs.length > 0) reflected.args = [...reflected.destructuredArgs]
diff --git a/lib/plugin/browser.js b/lib/plugin/browser.js
index 6ab2e7230..006411b4c 100644
--- a/lib/plugin/browser.js
+++ b/lib/plugin/browser.js
@@ -31,7 +31,8 @@ import output from '../output.js'
  * logs a hint and skips the override.
  */
 export default async function (config = {}) {
-  const opts = parseArgs(config._args || [])
+  const { _args, enabled, ...rest } = config
+  const opts = { ...rest, ...parseArgs(_args || []) }
   if (Object.keys(opts).length === 0) return
 
   const configure = await tryImportConfigure()
diff --git a/lib/workers.js b/lib/workers.js
index d97304a69..332def0b3 100644
--- a/lib/workers.js
+++ b/lib/workers.js
@@ -521,22 +521,8 @@ class Workers extends EventEmitter {
       // Workers are already running, this is just a placeholder step
     })
 
-    // Add overall timeout to prevent infinite hanging
-    const overallTimeout = setTimeout(() => {
-      console.error('[Main] Overall timeout reached (10 minutes). Force terminating remaining workers...')
-      workerThreads.forEach(w => {
-        try {
-          w.terminate()
-        } catch (e) {
-          // ignore
-        }
-      })
-      this._finishRun()
-    }, 600000) // 10 minutes
-
     return new Promise(resolve => {
       this.on('end', () => {
-        clearTimeout(overallTimeout)
         resolve()
       })
     })
@@ -565,7 +551,7 @@ class Workers extends EventEmitter {
     // Track last activity time to detect hanging workers
     let lastActivity = Date.now()
     let currentTest = null
-    const workerTimeout = 300000 // 5 minutes
+    const workerTimeout = process.env.CODECEPT_WORKER_TIMEOUT ? ms(process.env.CODECEPT_WORKER_TIMEOUT) : ms('5m')
     
     const timeoutChecker = setInterval(() => {
       const elapsed = Date.now() - lastActivity
diff --git a/package.json b/package.json
index 606a25798..b56bf6ae8 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
     "lib",
     "translations",
     "typings/**/*.d.ts",
-    "docs/webapi/**"
+    "docs/*.md",
+    "docs/helpers/**"
   ],
   "main": "lib/index.js",
   "module": "lib/index.js",

From 25e772c4564fad621ab2ef910073adb9ca7061c5 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Mon, 4 May 2026 12:01:35 +0300
Subject: [PATCH 4/6] test(dry-run): allow other plugins alongside the explicit
 -p one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The new dry-run no longer disables every plugin (only the interactive
and external-I/O ones), so the customLocator test now sees `Plugins:
screenshot, customLocator` and the strict `toContain('Plugins:
customLocator')` assertion fails. Match the plugin list with a regex
instead โ€” what we care about is that customLocator was activated.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 test/runner/dry_run_test.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/runner/dry_run_test.js b/test/runner/dry_run_test.js
index 300969cb9..56b80a127 100644
--- a/test/runner/dry_run_test.js
+++ b/test/runner/dry_run_test.js
@@ -188,7 +188,7 @@ describe('dry-run command', () => {
 
   it('should enable a particular plugin in dry-mode when passing it to -p', done => {
     exec(`${codecept_run_config('codecept.customLocator.js')} --verbose -p customLocator`, (err, stdout) => {
-      expect(stdout).toContain('Plugins: customLocator')
+      expect(stdout).toMatch(/Plugins:[^\n]*customLocator/)
       expect(stdout).toContain("I see element {xpath: .//*[@data-testid='COURSE']//a}")
       expect(stdout).toContain('OK  | 1 passed')
       expect(stdout).toContain('--- DRY MODE: No tests were executed ---')

From 4dd1ad2fbcf7d44cea518f52dedfe3a9193a3c72 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Mon, 4 May 2026 15:48:37 +0300
Subject: [PATCH 5/6] Fixed MCP live interaction

---
 bin/mcp-server.js    | 123 ++++++++++++++++++++++++++++++++-----------
 lib/mocha/factory.js |   9 ++--
 2 files changed, 97 insertions(+), 35 deletions(-)

diff --git a/bin/mcp-server.js b/bin/mcp-server.js
index c03ef2e64..d3d7cb53b 100755
--- a/bin/mcp-server.js
+++ b/bin/mcp-server.js
@@ -60,7 +60,20 @@ function aiTraceHint() {
 }
 
 function applyMochaGrep(grep) {
-  if (grep && typeof container.mocha?.grep === 'function') container.mocha.grep(grep)
+  if (!grep) return
+  const mocha = typeof container.mocha === 'function' ? container.mocha() : container.mocha
+  if (mocha && typeof mocha.grep === 'function') mocha.grep(grep)
+}
+
+function pauseAtMatcher(pauseAt) {
+  if (pauseAt == null) return () => false
+  if (typeof pauseAt === 'number') return (idx) => idx === pauseAt
+  if (typeof pauseAt === 'string') {
+    const m = pauseAt.match(/^\/(.+)\/([gimsuy]*)$/)
+    const re = m ? new RegExp(m[1], m[2]) : new RegExp(pauseAt.replace(/[.+?^${}()|[\]\\]/g, '\\$&'), 'i')
+    return (_idx, name) => re.test(name)
+  }
+  return () => false
 }
 
 async function ensureBootstrap() {
@@ -124,12 +137,7 @@ function pluginsSignature(plugins) {
 async function teardownContainer() {
   if (!containerInitialized) return
   try {
-    await endShellSession()
-    const helpers = container.helpers()
-    for (const helperName in helpers) {
-      const helper = helpers[helperName]
-      try { if (helper._finish) await helper._finish() } catch {}
-    }
+    await closeBrowser()
     try { if (codecept?.teardown) await codecept.teardown() } catch {}
   } finally {
     containerInitialized = false
@@ -365,15 +373,17 @@ function outputBaseDir() {
 // pause(), the handler registered via setPauseHandler resolves a "paused"
 // promise that run_test is racing against test completion. The "pause" tool
 // then drives the REPL by mutating next/abort and resolving the controller.
-let pausedController = null   // { resolveContinue, registeredVariables }
-let pendingRunPromise = null  // run_test's run() promise while paused
-let pendingRunResults = null  // results array being collected while paused
-let pendingRunCleanup = null  // cleanup callback to detach test.after / step.after listeners
-let pendingTestFile = null    // file path of the test currently running
-let pendingStepInfo = null    // { index, name, status } of the last step that fired step.after
+let pausedController = null
+let pendingRunPromise = null
+let pendingRunResults = null
+let pendingRunCleanup = null
+let pendingTestFile = null
+let pendingStepInfo = null
+let abortRun = false
 const pauseEvents = new EventEmitter()
 
 setPauseHandler(({ registeredVariables }) => {
+  if (abortRun) return Promise.reject(new Error('MCP session aborted'))
   return new Promise(resolve => {
     pausedController = {
       registeredVariables,
@@ -386,6 +396,33 @@ setPauseHandler(({ registeredVariables }) => {
   })
 })
 
+async function cancelRun() {
+  if (!pendingRunPromise && !pausedController) return false
+  abortRun = true
+  if (typeof pendingRunCleanup === 'function') { try { pendingRunCleanup() } catch {} }
+  if (pausedController) { try { pausedController.resolveContinue() } catch {} ; pausedController = null }
+  if (pendingRunPromise) {
+    try { await Promise.race([pendingRunPromise.catch(() => {}), new Promise(r => setTimeout(r, 5000))]) } catch {}
+  }
+  pendingRunPromise = null
+  pendingRunResults = null
+  pendingTestFile = null
+  pendingStepInfo = null
+  abortRun = false
+  return true
+}
+
+async function closeBrowser() {
+  if (!containerInitialized) return
+  await cancelRun()
+  await endShellSession()
+  for (const helper of Object.values(container.helpers() || {})) {
+    try { if (helper._cleanup) await helper._cleanup() } catch {}
+    try { if (helper._finishTest) await helper._finishTest() } catch {}
+  }
+  browserStarted = false
+}
+
 async function captureLiveArtifacts(prefix = 'pause') {
   const helper = pickActingHelper(container.helpers())
   if (!helper) return {}
@@ -558,7 +595,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
           test: { type: 'string' },
           timeout: { type: 'number' },
           grep: { type: 'string', description: 'Filter scenarios by title (passed to mocha.grep). Mirrors --grep on the CLI.' },
-          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.' },
+          pauseAt: {
+            description: 'Programmatic breakpoint. Either a 1-based step index (number) or a step-name match (string โ€” substring case-insensitive, or `/regex/i` literal). Examples: 5 / "fill field" / "/grab.*url/i".',
+            oneOf: [{ type: 'number' }, { type: 'string' }],
+          },
           plugins: PLUGINS_PROP,
         },
         required: ['test'],
@@ -619,6 +659,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
         },
       },
     },
+    {
+      name: 'cancel',
+      description: 'Abort the currently paused or in-progress test run without closing the browser. Use when you want to bail out of a paused test and start something else without going through stop_browser/start_browser. The browser session and Mocha state stay alive.',
+      inputSchema: { type: 'object', properties: {} },
+    },
   ],
 }))
 
@@ -676,6 +721,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session already active', plugins: plugins ?? null }, null, 2) }] }
         }
         await initCodecept(configPath, plugins)
+        if (containerInitialized && !browserStarted) {
+          for (const helper of Object.values(container.helpers() || {})) {
+            try { if (helper._beforeSuite) await helper._beforeSuite() } catch {}
+          }
+          browserStarted = true
+        }
         await startShellSession()
         return { content: [{ type: 'text', text: JSON.stringify({ status: 'Session started โ€” run_code and snapshot are now available', plugins: plugins ?? null }, null, 2) }] }
       }
@@ -684,8 +735,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         if (!containerInitialized) {
           return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser not initialized' }, null, 2) }] }
         }
-        await teardownContainer()
-        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped successfully' }, null, 2) }] }
+        await closeBrowser()
+        return { content: [{ type: 'text', text: JSON.stringify({ status: 'Browser stopped โ€” Mocha and config preserved; call start_browser to reopen' }, null, 2) }] }
       }
 
       case 'snapshot': {
@@ -755,6 +806,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         })
       }
 
+      case 'cancel': {
+        const cancelled = await cancelRun()
+        await ensureSession()
+        return { content: [{ type: 'text', text: JSON.stringify({ status: cancelled ? 'Run cancelled โ€” browser kept open' : 'No run in progress' }, null, 2) }] }
+      }
+
       case 'run_code': {
         const { code, timeout = 60000, saveArtifacts = true, settleMs = 300 } = args
         await initCodecept()
@@ -814,6 +871,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
         const paramNames = ['I', ...Object.keys(scope).filter(k => k !== 'I').sort()]
         const paramValues = paramNames.map(k => scope[k])
 
+        const wasPaused = !!pausedController
+        if (wasPaused) recorder.session.start('mcp_run_code')
+
         let returnValue
         try {
           const asyncFn = new Function(...paramNames, `return (async () => { ${code} })()`)
@@ -833,7 +893,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
           for (const m of consoleMethods) console[m] = origConsoleMethods[m]
           try { event.dispatcher.removeListener(event.step.after, onStepAfter) } catch {}
           try { event.dispatcher.removeListener(event.step.passed, onStepPassed) } catch {}
-          try { recorder.reset() } catch {}
+          if (wasPaused) {
+            try { recorder.session.restore('mcp_run_code') } catch {}
+          } else {
+            try { recorder.reset() } catch {}
+          }
         }
 
         result.commands = commands
@@ -918,6 +982,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             pendingTestFile = testFile
             pendingStepInfo = null
             let stepIndex = 0
+            const matchPauseAt = pauseAtMatcher(pauseAt)
 
             const onAfter = t => {
               const aiTrace = t.artifacts?.aiTrace
@@ -932,14 +997,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             }
             const onStepAfter = step => {
               stepIndex += 1
-              try {
-                pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
-              } catch {
-                pendingStepInfo = { index: stepIndex }
-              }
-              if (typeof pauseAt === 'number' && stepIndex === pauseAt) {
-                pauseNow()
-              }
+              const idx = stepIndex
+              const name = (() => { try { return step.toString() } catch { return '' } })()
+              recorder.add('mcp pause info', () => {
+                pendingStepInfo = { index: idx, name, status: step.status }
+              })
+              if (matchPauseAt(idx, name)) pauseNow()
             }
             event.dispatcher.on(event.test.after, onAfter)
             event.dispatcher.on(event.step.after, onStepAfter)
@@ -1030,11 +1093,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
             }
             const onStepAfter = step => {
               stepIndex += 1
-              try {
-                pendingStepInfo = { index: stepIndex, name: step.toString(), status: step.status }
-              } catch {
-                pendingStepInfo = { index: stepIndex }
-              }
+              const idx = stepIndex
+              const name = (() => { try { return step.toString() } catch { return '' } })()
+              recorder.add('mcp pause info', () => {
+                pendingStepInfo = { index: idx, name, status: step.status }
+              })
               pauseNow()
             }
             event.dispatcher.on(event.test.after, onAfter)
diff --git a/lib/mocha/factory.js b/lib/mocha/factory.js
index 308ecf08c..6e288a2ce 100644
--- a/lib/mocha/factory.js
+++ b/lib/mocha/factory.js
@@ -17,12 +17,11 @@ let mocha
 
 class MochaFactory {
   static create(config, opts) {
-    // cleanReferencesAfterRun defaults to true in Mocha 10+, which disposes the
-    // instance after the first .run(). The MCP server reuses one container
-    // across many tool calls, so disposal breaks every subsequent run_test /
-    // run_step_by_step. Allow callers to override; default to keeping refs.
-    const merged = Object.assign({ cleanReferencesAfterRun: false }, config, opts)
+    const merged = Object.assign({}, config, opts)
     mocha = new Mocha(merged)
+    if (merged.cleanReferencesAfterRun !== true) {
+      mocha.cleanReferencesAfterRun(false)
+    }
     output.process(opts.child)
     mocha.ui(scenarioUiFunction)
 

From 6bb55c4beb85676175396f7201e75c0f0a30eabf Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Mon, 4 May 2026 15:59:25 +0300
Subject: [PATCH 6/6] docs(mcp): rewrite for current API + add per-project
 install warning

Update mcp.md to match the current tool surface (cancel, polymorphic
pauseAt, settleMs, plugins-only-on-start_browser, availableObjects,
WebElement auto-describe, traceFile per scenario) and warn against
running the MCP server from a global codeceptjs install.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 docs/mcp.md | 652 +++++++++++++++++++---------------------------------
 1 file changed, 241 insertions(+), 411 deletions(-)

diff --git a/docs/mcp.md b/docs/mcp.md
index 02edd3bc1..b3a3333f1 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -1,158 +1,186 @@
 # CodeceptJS MCP Server
 
-Model Context Protocol (MCP) server for CodeceptJS enables AI agents (like Claude) to interact with and control CodeceptJS tests programmatically.
+Model Context Protocol (MCP) server for CodeceptJS. Lets AI agents drive a CodeceptJS browser session โ€” list tests, run arbitrary `I.*` code, pause-and-poke through a scenario, capture artifacts, and read aiTrace markdown โ€” all in-process, sharing one browser and one container.
 
 ## Overview
 
-The MCP server provides AI agents with tools to:
-- List all tests in a CodeceptJS project
-- List all available CodeceptJS actions (I.* methods)
-- Run arbitrary CodeceptJS code with artifacts capture, return value, and `console.log` capture
-- Run specific tests with detailed output
-- Run tests step by step for detailed analysis
-- Capture a point-in-time snapshot of the browser without any action
-- Start and stop browser sessions
-- Capture screenshots, ARIA snapshots, formatted HTML, browser console logs, and storage state (cookies + localStorage)
+The MCP server exposes the following tools:
 
-## Installation
+- `list_tests` / `list_actions` โ€” enumerate tests and `I.*` methods
+- `start_browser` / `stop_browser` โ€” open / close the session (only place plugin overrides go)
+- `run_code` โ€” run arbitrary JS with `I` and the full CodeceptJS scope; captures steps, console, return value, and a settled-state snapshot
+- `snapshot` โ€” capture URL/HTML/ARIA/screenshot/console/storage at any moment
+- `run_test` โ€” run a specific scenario; supports `pauseAt` for programmatic breakpoints
+- `run_step_by_step` โ€” pause after every step
+- `continue` โ€” release a paused test (run-to-end, run-to-next-pause, or run-to-finish)
+- `cancel` โ€” abort the in-progress / paused run without closing the browser
 
-Install the MCP SDK dependency:
+## Invocation
 
-```bash
-npm install @modelcontextprotocol/sdk
-```
+Two ways to launch the server:
+
+- `npx codeceptjs-mcp` โ€” the published bin
+- `node node_modules/codeceptjs/bin/mcp-server.js` โ€” direct path, useful for editor / agent configs
 
-The MCP server binary is available at `bin/mcp-server.js`.
+> โš ๏ธ **Run from the project's local `codeceptjs`, never a global install.**
+> The MCP server resolves helpers, plugins, page objects, and custom support from the project's `node_modules`. A globally installed `codeceptjs` won't see project-local helpers (`@codeceptjs/helper`, `@codeceptjs/configure`, custom plugins) or your `include:` support objects, and per-project versions can drift from the global one. Always invoke via `npx codeceptjs-mcp` from inside the project directory, or point your MCP client config at `/node_modules/codeceptjs/bin/mcp-server.js` directly.
 
 ## Configuration
 
-Configure the MCP server in your Claude Desktop or MCP-compatible client configuration:
+Set up the MCP server in your client (Claude Desktop, Cursor, Continue, etc.):
 
-### Basic Configuration
+### Basic
 
 ```json
 {
   "mcpServers": {
     "codeceptjs": {
-      "command": "node",
-      "args": ["path/to/codeceptjs/bin/mcp-server.js"]
+      "command": "npx",
+      "args": ["codeceptjs-mcp"]
     }
   }
 }
 ```
 
-With basic configuration, the server looks for `codecept.conf.js` in the current working directory.
-
-### Configuration with Environment Variables
+The server looks for `codecept.conf.js` (then `.cjs`) in the current working directory.
 
-Use environment variables to specify the CodeceptJS project directory and config file:
+### With env vars
 
 ```json
 {
   "mcpServers": {
     "codeceptjs": {
-      "command": "node",
-      "args": ["path/to/codeceptjs/bin/mcp-server.js"],
+      "command": "npx",
+      "args": ["codeceptjs-mcp"],
       "env": {
-        "CODECEPTJS_CONFIG": "/path/to/your/codecept.conf.js",
-        "CODECEPTJS_PROJECT_DIR": "/path/to/your/project"
+        "CODECEPTJS_CONFIG": "/absolute/path/to/codecept.conf.js",
+        "CODECEPTJS_PROJECT_DIR": "/absolute/path/to/project"
       }
     }
   }
 }
 ```
 
-**Environment Variables:**
-
 | Variable | Description |
 |----------|-------------|
-| `CODECEPTJS_CONFIG` | Absolute path to the CodeceptJS configuration file |
-| `CODECEPTJS_PROJECT_DIR` | Absolute path to the project root directory |
+| `CODECEPTJS_CONFIG` | Absolute path to `codecept.conf.js`. Overrides cwd lookup. |
+| `CODECEPTJS_PROJECT_DIR` | Absolute path to the project root. Used as the resolution base for the config file. |
 
-### Example: Full Claude Desktop Configuration
+## Session Defaults
 
-```json
-{
-  "mcpServers": {
-    "codeceptjs-mcp": {
-      "command": "node",
-      "args": ["D:/projects/my-project/node_modules/codeceptjs/bin/mcp-server.js"],
-      "env": {
-        "CODECEPTJS_CONFIG": "D:/projects/my-project/codecept.conf.js",
-        "CODECEPTJS_PROJECT_DIR": "D:/projects/my-project"
-      }
-    }
-  }
-}
-```
+When the session starts, the MCP server enforces two plugin defaults so the agent gets useful telemetry out of the box:
+
+- **`aiTrace: { enabled: true, on: 'step' }`** โ€” every step persists DOM/ARIA/screenshot/console artifacts to `output/trace__/`. Each scenario's `traceFile` is returned in run results so the agent can `Read` the markdown directly.
+- **`browser: { enabled: true, show: false }`** โ€” headless. Switch to headed via `start_browser` `plugins` arg.
+
+Both can be overridden (or disabled) via `start_browser`'s `plugins` argument. The `codecept.conf.js`'s own plugin config still merges in for keys the user explicitly set there.
 
 ## Available Tools
 
-### list_tests
+### `start_browser`
 
-List all tests in the CodeceptJS project.
+Initializes the session โ€” loads config, builds the container, opens the browser, kicks off the synthetic test scope so `run_code` and `snapshot` work. This is the only tool that customizes initialization; every other tool either uses the active session or auto-inits with project defaults.
 
 **Parameters:**
-- `config` (optional): Path to codecept.conf.js (default: codecept.conf.js)
+- `config` (string, optional) โ€” absolute path to `codecept.conf.js`. Defaults to `$CODECEPTJS_CONFIG`, then `./codecept.conf.js` in `$CODECEPTJS_PROJECT_DIR` or cwd.
+- `plugins` (object, optional) โ€” plugin configs keyed by name. Same shape as `plugins` in `codecept.conf.js`; `enabled: true` is added automatically. Most useful entries:
+  - `{ browser: { show: true } }` โ€” visible browser
+  - `{ browser: { browser: "firefox", windowSize: "1280x720" } }` โ€” switch browser + viewport
+  - `{ aiTrace: { enabled: false } }` โ€” disable per-step trace overhead on a re-run
+  - `{ pause: { on: "fail" } }` / `{ screenshot: { on: "step" } }` โ€” any other plugin works the same way
 
 **Returns:**
 ```json
 {
-  "count": 5,
-  "tests": [
-    {
-      "file": "/full/path/to/test/file.js",
-      "relativePath": "tests/example_test.js"
-    }
-  ]
+  "status": "Session started โ€” run_code and snapshot are now available",
+  "plugins": { "browser": { "show": false } }
 }
 ```
 
-**Example:**
+### `stop_browser`
+
+Closes the browser handles, drops the synthetic test scope, but **keeps the container, codecept, and Mocha alive**. Subsequent `start_browser` reopens the browser without rebuilding everything โ€” important because ESM-loaded test files don't re-execute their top-level `Scenario(...)` on reload, so a fresh Mocha would have no suites.
+
+**Parameters:** none
+
+**Returns:**
+```json
+{ "status": "Browser stopped โ€” Mocha and config preserved; call start_browser to reopen" }
+```
+
+### `cancel`
+
+Aborts the currently paused or in-progress test run **without closing the browser**. Use when you want to bail out of a paused test and start something else. Mocha + container stay alive; the next `run_test` / `run_step_by_step` works immediately.
+
+**Parameters:** none
+
+**Returns:**
+```json
+{ "status": "Run cancelled โ€” browser kept open" }
+```
+
+### `list_tests`
+
+Lists all tests resolved from the project's `tests:` glob.
+
+**Parameters:** none
+
+**Returns:**
 ```json
 {
-  "name": "list_tests",
-  "arguments": {
-    "config": "/path/to/codecept.conf.js"
-  }
+  "count": 5,
+  "tests": [
+    { "file": "/abs/path/to/work_orders_test.js", "relativePath": "work_orders_test.js" }
+  ]
 }
 ```
 
-### list_actions
+### `list_actions`
 
-List all available CodeceptJS actions (I.* methods) from enabled helpers and support objects.
+Lists every `I.*` method from enabled helpers and support objects.
 
-**Parameters:**
-- `config` (optional): Path to codecept.conf.js
+**Parameters:** none
 
 **Returns:**
 ```json
 {
   "count": 120,
   "actions": [
-    {
-      "helper": "Playwright",
-      "action": "amOnPage",
-      "signature": "I.amOnPage(url)"
-    },
-    {
-      "helper": "Playwright",
-      "action": "click",
-      "signature": "I.click(locator, context)"
-    }
+    { "helper": "Playwright", "action": "amOnPage", "signature": "I.amOnPage(url)" },
+    { "helper": "SupportObject", "action": "loginAsAdmin", "signature": "I.loginAsAdmin()" }
   ]
 }
 ```
 
-### run_code
+### `run_code`
 
-Run arbitrary CodeceptJS code. The tool captures the value the code returns, every `I.*` step it runs, anything written to `console.log` / `console.info` / `console.warn` / `console.error` / `console.debug`, plus a final-state snapshot of the page.
+Run arbitrary JavaScript inside the live test scope. Captures steps, console output, return value, and a final-state snapshot.
 
 **Parameters:**
-- `code` (required): CodeceptJS code to execute. May `return` a value and use `console.*` for debugging.
-- `timeout` (optional): Timeout in milliseconds (default: 60000)
-- `config` (optional): Path to codecept.conf.js
-- `saveArtifacts` (optional): Save final-state artifacts to disk (default: true)
+- `code` (string, required) โ€” JS source. Use `await` on `I.*` calls.
+- `timeout` (number, optional) โ€” ms (default `60000`).
+- `saveArtifacts` (boolean, optional) โ€” capture final-state artifacts (default `true`).
+- `settleMs` (number, optional) โ€” wait this many ms after the code finishes before capturing artifacts (default `300`). Bump to `1000`+ for slow re-renders, `0` to skip.
+
+**Scope (everything reachable as a bare identifier in `code`):**
+
+| Symbol | Source |
+|--------|--------|
+| `I` | The actor (with all helper methods) |
+| Custom support objects | `include:` in `codecept.conf.js` (e.g. page objects, `login` from `auth` plugin) |
+| `locate`, `within`, `session`, `secret`, `inject`, `pause`, `share` | from `codeceptjs` |
+| `tryTo`, `retryTo`, `hopeThat` | from `codeceptjs/effects` |
+| `step` | from `codeceptjs/steps` |
+| `element`, `eachElement`, `expectElement`, `expectAnyElement`, `expectAllElements` | from `codeceptjs/els` |
+| `container` | the DI container |
+| `helpers` | live helpers map (e.g. `helpers.Playwright.page` for raw Playwright access) |
+
+The full live list is returned in every response under `availableObjects`.
+
+**Return-value handling:**
+- An explicit `return X` is JSON-stringified (with circular-ref handling). Capped at 20 KB.
+- If you forget `return`, the last grabbed step value is returned automatically (`await I.grabTitle()` on the last line works).
+- A returned `WebElement` (or array of them, from `I.grabWebElement(s)`) is auto-described to a plain object: `{ text, html, visible, enabled, attrs }`.
 
 **Returns:**
 ```json
@@ -160,14 +188,10 @@ Run arbitrary CodeceptJS code. The tool captures the value the code returns, eve
   "status": "success",
   "output": "Code executed successfully",
   "error": null,
-  "commands": [
-    "I.amOnPage(\"/\")",
-    "I.grabTextFrom(\"h1\")"
-  ],
-  "logs": [
-    { "level": "log", "message": "headline Welcome", "t": 47 }
-  ],
+  "commands": ["I am on page \"/\"", "I grab text from \"h1\""],
+  "logs": [{ "level": "log", "message": "headline Welcome", "t": 47 }],
   "returnValue": "{\n  \"url\": \"http://localhost:8000/\",\n  \"text\": \"Welcome\"\n}",
+  "availableObjects": ["I", "container", "eachElement", "element", "expectAllElements", "expectAnyElement", "expectElement", "helpers", "hopeThat", "inject", "locate", "login", "pause", "retryTo", "secret", "session", "share", "step", "tryTo", "within"],
   "artifacts": {
     "url": "http://localhost:8000/",
     "html": "file:///output/trace_run_code_.../mcp_page.html",
@@ -178,35 +202,33 @@ Run arbitrary CodeceptJS code. The tool captures the value the code returns, eve
     "cookieCount": 3,
     "localStorageCount": 5
   },
+  "ariaDiff": "...",
   "dir": "/output/trace_run_code_...",
   "traceFile": "file:///output/trace_run_code_.../trace.md"
 }
 ```
 
-**Notes:**
-- `returnValue` is the value the code's last `return` statement produced, JSON-stringified with circular-ref handling. Capped at 20 KB; `returnValueTruncated: true` is set if it was cut.
-- `logs` is an in-order list of console output captured during execution. Each entry has `{ level, message, t }` where `t` is ms since the code started. Capped at 100 entries ร— 2 KB per message; `logsTruncated: true` is set if hit. `console.*` writes do not pollute MCP stdio โ€” they're captured in-memory only.
-- `commands` is the list of `I.*` calls observed during execution (via the recorder).
-- `artifacts.storage` is omitted when both cookies and localStorage are empty.
+- `traceFile` โ€” markdown summary of this call. `Read` it for full context.
+- `ariaDiff` โ€” present when the call mutated the page; diff between the previous aiTrace ARIA snapshot and the new one.
+- `aiTraceHint` โ€” appears when aiTrace is disabled, suggesting how to re-enable it.
 
 **Example:**
 ```json
 {
   "name": "run_code",
   "arguments": {
-    "code": "await I.amOnPage('/'); const t = await I.grabTextFrom('h1'); console.log('headline', t); return { url: await I.grabCurrentUrl(), text: t };",
-    "timeout": 30000
+    "code": "await I.amOnPage('/'); const t = await I.grabTextFrom('h1'); return { url: await I.grabCurrentUrl(), text: t };"
   }
 }
 ```
 
-### snapshot
+### `snapshot`
 
-Capture the current state of the browser without performing any action. Useful for inspecting what's on the page right now (URL, cookies, localStorage, formatted HTML, ARIA, screenshot, browser console logs) when reasoning between actions.
+Capture the current browser state without performing any action.
 
 **Parameters:**
-- `config` (optional): Path to codecept.conf.js
-- `fullPage` (optional): Take a full-page screenshot (default: false)
+- `fullPage` (boolean, optional) โ€” full-page screenshot (default `false`).
+- `settleMs` (number, optional) โ€” wait before capture (default `300`).
 
 **Returns:**
 ```json
@@ -227,265 +249,146 @@ Capture the current state of the browser without performing any action. Useful f
 }
 ```
 
-**Example:**
-```json
-{
-  "name": "snapshot",
-  "arguments": { "fullPage": true }
-}
-```
-
-### continue
+### `run_test`
 
-Release a paused test (one that called `pause()` during `run_test`) and let it run to completion. Returns the final reporter result.
+Run a specific scenario. Returns reporter JSON with one entry per scenario; each entry has a `traceFile` (file:// URL) pointing to the per-scenario aiTrace markdown โ€” `Read` it on failures to see the failing step's DOM/ARIA/screenshot.
 
-To inspect or manipulate state while the test is paused, use [`run_code`](#run_code) โ€” it operates on the same container the test is using.
+If the test calls `pause()` โ€” or if `pauseAt` matches a step โ€” returns early with `status: "paused"` so the agent can inspect via `run_code` and release with `continue` (or abort with `cancel`).
 
 **Parameters:**
-- `timeout` (optional): ms to wait for the test to finish after continuing (default 60000).
-
-**Returns:**
-```json
-{
-  "status": "completed",
-  "reporterJson": { "stats": { "tests": 1, "passes": 1, "failures": 0 }, "tests": [...] },
-  "error": null
-}
-```
-
-**Example flow:**
-
-```json
-{ "name": "run_test", "arguments": { "test": "checkout_test" } }
-// โ†’ { "status": "paused", "file": "...", "note": "..." }
-
-{ "name": "run_code", "arguments": { "code": "return await I.grabCurrentUrl()" } }
-// โ†’ { "status": "success", "returnValue": "http://...", "artifacts": { ... } }
-
-{ "name": "run_code", "arguments": { "code": "await I.click('Save')" } }
-// โ†’ { "status": "success", "artifacts": { ... } }
-
-{ "name": "continue", "arguments": {} }
-// โ†’ { "status": "completed", "reporterJson": { ... } }
-```
-
-**Notes:**
-- Pause runs in-process: `run_code` and the test share the same `I` / browser. There's no subprocess, no IPC.
-- `run_test` and `continue` wrap test execution in the same `withSilencedIO` helper that `run_step_by_step` uses, so step output doesn't interleave with the MCP JSON-RPC stream. Stdout/stderr are restored before each tool call returns.
-- TTY behaviour (`npx codeceptjs run --debug` at a terminal) is unchanged โ€” `pause()` opens the readline REPL whenever `process.stdin.isTTY` is true.
-
-### run_test
-
-Run a specific test by name or file path. Runs in-process so it shares the same `I` / browser as `run_code` and `snapshot`. If the test calls `pause()` โ€” or if `pauseAt` is set and the Nth step completes โ€” this tool returns early and the agent drives the session through `run_code` and `continue`.
-
-**Parameters:**
-- `test` (required): Test name or file path
-- `timeout` (optional): Timeout in milliseconds (default: 60000)
-- `config` (optional): Path to codecept.conf.js
-- `pauseAt` (optional): 1-based step index. The test pauses after the Nth step completes. Use this as a programmatic breakpoint without editing the test. Discover step indices via the `list` CLI (`--steps`) or via `run_step_by_step`.
-
-**Returns (test completed normally):**
+- `test` (string, required) โ€” file path or partial test name; resolved to a single test file.
+- `timeout` (number, optional) โ€” overall ms (default `60000`).
+- `grep` (string, optional) โ€” filter scenarios by title; passed to `mocha.grep`. Mirrors `--grep` on the CLI.
+- `pauseAt` (number | string, optional) โ€” programmatic breakpoint. Either:
+  - `number` โ€” 1-based step index (test pauses after the Nth step completes)
+  - `string` โ€” case-insensitive substring match against step name
+  - `"/regex/i"` โ€” regex literal (the `/.../i` form is honored verbatim)
+
+**Returns (completed normally):**
 ```json
 {
   "status": "completed",
   "file": "/path/to/test.js",
-  "reporterJson": { "stats": { "tests": 1, "passes": 1, "failures": 0 }, "tests": [...] },
+  "reporterJson": {
+    "stats": { "tests": 1, "passes": 1, "failures": 0 },
+    "tests": [
+      {
+        "title": "lists materials",
+        "file": "/path/to/materials_test.js",
+        "status": "passed",
+        "duration": 4123,
+        "traceFile": "file:///output/trace_materials__lists_materials_xxxx/trace.md"
+      }
+    ]
+  },
   "error": null
 }
 ```
 
-**Returns (test reached `pause()` or `pauseAt`):**
+**Returns (paused):**
 ```json
 {
   "status": "paused",
   "file": "/path/to/test.js",
-  "pausedAfter": { "index": 3, "name": "I.click(\"Save\")", "status": "passed" },
-  "page": { "url": "https://example.com/checkout", "title": "Checkout", "contentSize": 18432 },
+  "pausedAfter": { "index": 7, "name": "I select option {\"css\":\"main select\"}, \"Flux\"", "status": "success" },
+  "page": { "url": "https://app.example.com/materials", "title": "Materials", "contentSize": 18432 },
   "suggestions": [
     "Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point",
     "Call run_code to inspect or manipulate state (e.g. return await I.grabText(\"h1\"))",
-    "Call continue to release the pause and let the test finish"
+    "Call continue to release the pause and let the test run the next step (or finish)"
   ]
 }
 ```
 
-**Features:**
-- Automatically resolves test names to file paths
-- Supports partial test name matching
-- Runs in-process; results assembled from CodeceptJS test events
-- Yields on `pause()` (or `pauseAt`) so the agent can inspect via `run_code` and release with `continue`
-
-**Example:**
+**Examples:**
 ```json
-{
-  "name": "run_test",
-  "arguments": {
-    "test": "basic_navigation_test",
-    "timeout": 60000
-  }
-}
+{ "name": "run_test", "arguments": { "test": "checkout_test", "pauseAt": 5 } }
+{ "name": "run_test", "arguments": { "test": "checkout_test", "pauseAt": "fill field" } }
+{ "name": "run_test", "arguments": { "test": "checkout_test", "pauseAt": "/grab.*url/i" } }
 ```
 
-### run_step_by_step
+### `run_step_by_step`
 
-Run a test interactively, pausing after every step. Returns a paused payload after the first step completes โ€” the agent then calls `continue` to advance one step at a time, or `run_code` / `snapshot` to inspect state at any pause.
+Run a test interactively, pausing after every step. The agent advances with `continue` or inspects with `run_code` / `snapshot`.
 
 **Parameters:**
-- `test` (required): Test name or file path
-- `timeout` (optional): per-call timeout in milliseconds (default: 60000)
-- `config` (optional): Path to codecept.conf.js
+- `test` (string, required)
+- `timeout` (number, optional)
+- `grep` (string, optional)
+- `plugins` (object, optional) โ€” same as `start_browser`. Most useful is `{ browser: { show: true } }` so you can watch the run between pauses.
 
 **Returns (after each step):**
 ```json
 {
   "status": "paused",
   "file": "/path/to/test.js",
-  "pausedAfter": { "index": 1, "name": "I.amOnPage(\"/\")", "status": "passed" },
+  "pausedAfter": { "index": 1, "name": "I am on page \"/\"", "status": "success" },
   "page": { "url": "http://localhost:8000/", "title": "Test App", "contentSize": 1832 },
-  "suggestions": [
-    "Call snapshot to capture URL/HTML/ARIA/screenshot/console/storage at this point",
-    "Call run_code to inspect or manipulate state ...",
-    "Call continue to release the pause and let the test run the next step (or finish)"
-  ]
+  "suggestions": [...]
 }
 ```
 
-**Returns (after the last step):**
-```json
-{ "status": "completed", "file": "...", "reporterJson": { "stats": {...}, "tests": [...] } }
-```
-
-**Flow:**
-```json
-{ "name": "run_step_by_step", "arguments": { "test": "checkout_test" } }
-// โ†’ { "status": "paused", "pausedAfter": { "index": 1, ... } }
-
-{ "name": "snapshot", "arguments": {} }
-// โ†’ full artifact bundle for step 1
-
-{ "name": "continue", "arguments": {} }
-// โ†’ { "status": "paused", "pausedAfter": { "index": 2, ... } }
-
-{ "name": "continue", "arguments": {} }
-// โ†’ ... and so on, until { "status": "completed", "reporterJson": {...} }
-```
-
-For a one-shot breakpoint (pause once at a specific step rather than every step), use `run_test` with `pauseAt: N` instead.
-
-For per-step trace artifacts written to disk (HTML / ARIA / screenshot / console / storage per step) without the interactive flow, enable the `aiTrace` plugin.
+**Returns (after the last step):** same shape as `run_test`'s completed response โ€” every scenario carries its `traceFile`.
 
-### start_browser
+### `continue`
 
-Start the browser session (initializes CodeceptJS container).
+Release a paused test. The test runs until the next pause (`run_step_by_step`), the next `pause()` call, or completion.
 
 **Parameters:**
-- `config` (optional): Path to codecept.conf.js
+- `timeout` (number, optional) โ€” ms to wait for the next pause / completion (default `60000`).
 
-**Returns:**
-```json
-{
-  "status": "Browser started successfully"
-}
-```
-
-**Note:** Browser is automatically started on first code execution. This tool is useful for pre-initialization.
+**Returns (re-paused):** same shape as `run_test`'s paused response, with the new `pausedAfter` index.
 
-### stop_browser
+**Returns (completed):** same shape as `run_test`'s completed response.
 
-Stop the browser session and cleanup resources.
+## Pause-and-poke flow
 
-**Parameters:**
-- None
-
-**Returns:**
 ```json
-{
-  "status": "Browser stopped successfully"
-}
-```
-
-**Note:** Useful for releasing resources between long-running sessions.
-
-## Testing
-
-### Run MCP Server Tests
-
-The MCP server includes a comprehensive test suite:
-
-```bash
-node test/mcp/mcp_server_test.js
-```
-
-Tests cover:
-- Tool listing and schema validation
-- Test enumeration
-- Action listing
-- Code execution with artifacts
-- Test execution (run_test)
-- Step-by-step execution
-- Browser lifecycle
-- Error handling
-
-### Run Demo Tests with MCP
-
-**Important: Start the test web server first!**
-
-The MCP test scenarios require a web server running on port 8000. Start it in a separate terminal:
-
-```bash
-# Option 1: Using http-server (recommended)
-cd test/mcp
-npx http-server -p 8000
-
-# Option 2: Using Python
-cd test/mcp
-python -m http.server 8000
+{ "name": "run_step_by_step", "arguments": { "test": "checkout_test" } }
+// โ†’ { "status": "paused", "pausedAfter": { "index": 1, ... } }
 
-# Option 3: Using PHP
-cd test/mcp
-php -S localhost:8000
-```
+{ "name": "snapshot", "arguments": {} }
+// โ†’ full artifact bundle for step 1
 
-The server will start at http://127.0.0.1:8000
+{ "name": "run_code", "arguments": { "code": "return await I.grabCurrentUrl()" } }
+// โ†’ { "status": "success", "returnValue": "http://...", "artifacts": { ... } }
 
-**Keep this terminal open** while running tests through MCP/Claude.
+{ "name": "run_code", "arguments": { "code": "await I.click('Save')" } }
+// โ†’ { "status": "success", ... } โ€” actually mutates the live page
 
-Once the server is running, you can use Claude to run tests:
+{ "name": "continue", "arguments": {} }
+// โ†’ { "status": "paused", "pausedAfter": { "index": 2, ... } }
 
+// ... or bail out:
+{ "name": "cancel", "arguments": {} }
+// โ†’ { "status": "Run cancelled โ€” browser kept open" }
 ```
-"List all tests"
-"Run basic navigation test"
-"Run form interaction test step by step"
-```
-
-**Note:** If tests fail with ERR_CONNECTION_REFUSED, make sure the web server is running on port 8000.
 
-## Trace Files for AI Debugging
+Notes:
+- Pause runs in-process: `run_code` and the test share the same `I` / browser. There's no subprocess, no IPC.
+- `run_test` / `run_step_by_step` / `continue` silence stdout/stderr while running so step output doesn't interleave with the MCP JSON-RPC stream.
+- TTY behaviour is unchanged โ€” `npx codeceptjs run --debug` at a terminal still opens the readline REPL when `process.stdin.isTTY` is true. The MCP server only intercepts pause when its handler is registered.
 
-When using `run_step_by_step`, the server generates trace files that provide rich context for AI agents:
+## Trace files (aiTrace)
 
-### Trace File Structure
+When `aiTrace` is on (the default for MCP sessions), every step in a scenario produces:
 
 ```
 output/
-โ””โ”€โ”€ trace_Test_Name_abc123/
-    โ”œโ”€โ”€ 0000__screenshot.png   # Screenshot after step 0
-    โ”œโ”€โ”€ 0000__page.html        # Formatted HTML (minified -> trash classes/scripts/styles stripped -> beautified)
-    โ”œโ”€โ”€ 0000__aria.txt         # ARIA snapshot after step 0 (Playwright only)
-    โ”œโ”€โ”€ 0000__console.json     # Browser console logs (normalized to {type, text})
+โ””โ”€โ”€ trace_Materials__lists_materials_/
     โ”œโ”€โ”€ 0001__screenshot.png
-    โ”œโ”€โ”€ 0001__page.html
-    โ”œโ”€โ”€ 0001__aria.txt
+    โ”œโ”€โ”€ 0001__page.html       # minified โ†’ trash classes/scripts/styles stripped โ†’ beautified
+    โ”œโ”€โ”€ 0001__aria.txt        # Playwright only
     โ”œโ”€โ”€ 0001__console.json
-    โ”œโ”€โ”€ final_storage.json           # Cookies + localStorage at test end (run_step_by_step fallback)
-    โ””โ”€โ”€ trace.md                     # AI-friendly summary with links to all of the above
+    โ”œโ”€โ”€ 0002_...
+    โ””โ”€โ”€ trace.md                    # AI-friendly markdown index
 ```
 
-For ad-hoc `run_code` and `snapshot()` runs, only a single set of artifacts is produced (`mcp_*` / `snapshot_*` prefix), since there are no per-step iterations.
+`run_test` / `run_step_by_step` results expose the `trace.md` URL per scenario (`reporterJson.tests[].traceFile`) โ€” `Read` it on failure to see exactly what the failing step saw.
 
-### Using Trace Files with AI
+For ad-hoc `run_code` / `snapshot` runs, only a single set of artifacts is produced (`mcp_*` / `snapshot_*` prefix), packaged with their own `trace.md`.
 
-The `trace.md` file provides structured information perfect for AI analysis:
+### `trace.md` shape
 
 ```markdown
 # Test: Login functionality
@@ -495,11 +398,10 @@ The `trace.md` file provides structured information perfect for AI analysis:
 
 ## Steps
 
-1. **I.amOnPage("/login")** - passed (150ms)
-2. **I.fillField("#username", "user")** - passed (80ms)
-3. **I.fillField("#password", "pass")** - passed (75ms)
-4. **I.click("#login")** - passed (100ms)
-5. **I.see("Welcome")** - failed (50ms)
+1. **I.amOnPage("/login")** โ€” passed (150ms)
+2. **I.fillField("#username", "user")** โ€” passed (80ms)
+3. **I.click("#login")** โ€” passed (100ms)
+4. **I.see("Welcome")** โ€” failed (50ms)
 
 ## Error
 
@@ -507,148 +409,76 @@ Element "Welcome" not found
 
 ## Artifacts
 
-- Screenshot: 0005_screenshot.png
-- HTML: 0005_page.html
-- ARIA: 0005_aria.txt
+- Screenshot: 0004_screenshot.png
+- HTML: 0004_page.html
+- ARIA: 0004_aria.txt
 ```
 
-AI agents can use these artifacts to:
-- Visualize what the test saw at each step
-- Analyze page structure via ARIA
-- Debug issues using HTML snapshots
-- Identify errors from console logs
-
-## HTML Formatting
+## HTML formatting
 
-Every HTML snapshot saved by the MCP server (and the aiTrace / pageInfo plugins, since they share the same `captureSnapshot` funnel in `lib/utils/captureSnapshot.js`) is processed through a three-stage pipeline before being written to disk:
+Every HTML snapshot saved by the MCP server (and the `aiTrace` / `pageInfo` plugins, since they all funnel through `captureSnapshot` in `lib/utils/trace.js`) goes through:
 
-1. **Minify** (via `html-minifier-terser`) โ€” strips comments, collapses whitespace, removes redundant attributes.
-2. **Clean** โ€” drops `