From f572b8e79ad2c722e17045cf2133503f7c624dc4 Mon Sep 17 00:00:00 2001 From: DavertMik Date: Sun, 26 Apr 2026 22:27:29 +0300 Subject: [PATCH 1/4] 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/4] 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 810bd1d992c759bfa6770b9266efe72ace6e06d1 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Thu, 30 Apr 2026 23:51:32 +0300
Subject: [PATCH 3/4] feat(dry-run): add step numbering and --no-ansi flag

When --debug, --steps, or --verbose is passed, prefix each leaf step
with a per-test counter so the output is a clean numbered list (handy
for AI consumption). MetaSteps stay unprefixed.

Adds --no-ansi to disable colored / ANSI-styled output by setting
chalk.level = 0.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 bin/codecept.js       |  1 +
 lib/command/dryRun.js | 14 ++++++++++++++
 2 files changed, 15 insertions(+)

diff --git a/bin/codecept.js b/bin/codecept.js
index 221546148..6e8936b98 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -258,6 +258,7 @@ program
   .option(commandFlags.steps.flag, commandFlags.steps.description)
   .option(commandFlags.verbose.flag, commandFlags.verbose.description)
   .option(commandFlags.debug.flag, commandFlags.debug.description)
+  .option('--no-ansi', 'disable colored / ANSI-styled output')
   .action(commandHandler('../lib/command/dryRun.js'))
 
 program
diff --git a/lib/command/dryRun.js b/lib/command/dryRun.js
index 499c39fd5..2b1c9a040 100644
--- a/lib/command/dryRun.js
+++ b/lib/command/dryRun.js
@@ -1,3 +1,4 @@
+import chalk from 'chalk'
 import { getConfig, getTestRoot } from './utils.js'
 import Config from '../config.js'
 import Codecept from '../codecept.js'
@@ -8,6 +9,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
   const configFile = options.config
   let codecept
 
@@ -37,6 +39,7 @@ export default async function (test, options) {
       await printTests(codecept.testFiles)
       return
     }
+    numberSteps()
     event.dispatcher.on(event.all.result, printFooter)
     await codecept.run(test)
   } catch (err) {
@@ -45,6 +48,17 @@ export default async function (test, options) {
   }
 }
 
+function numberSteps() {
+  let stepIndex = 0
+  event.dispatcher.on(event.test.before, () => {
+    stepIndex = 0
+  })
+  event.dispatcher.prependListener(event.step.before, step => {
+    stepIndex++
+    step.prefix = `${stepIndex}. ${step.prefix || ''}`
+  })
+}
+
 async function printTests(files) {
   const { default: figures } = await import('figures')
   const { default: colors } = await import('chalk')

From dadc5cb321fdb2e3101f94752176484ba4b6be80 Mon Sep 17 00:00:00 2001
From: DavertMik 
Date: Fri, 1 May 2026 00:19:30 +0300
Subject: [PATCH 4/4] feat(dry-run): make step numbering opt-in via --numbers

Step numbering is now off by default. Pass --numbers (alongside
--debug/--steps/--verbose) to prefix each leaf step with a per-test
index.

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 bin/codecept.js       | 1 +
 lib/command/dryRun.js | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/bin/codecept.js b/bin/codecept.js
index 6e8936b98..212441579 100755
--- a/bin/codecept.js
+++ b/bin/codecept.js
@@ -259,6 +259,7 @@ program
   .option(commandFlags.verbose.flag, commandFlags.verbose.description)
   .option(commandFlags.debug.flag, commandFlags.debug.description)
   .option('--no-ansi', 'disable colored / ANSI-styled output')
+  .option('--numbers', 'prefix each step with a per-test index number')
   .action(commandHandler('../lib/command/dryRun.js'))
 
 program
diff --git a/lib/command/dryRun.js b/lib/command/dryRun.js
index 2b1c9a040..566a68911 100644
--- a/lib/command/dryRun.js
+++ b/lib/command/dryRun.js
@@ -39,7 +39,7 @@ export default async function (test, options) {
       await printTests(codecept.testFiles)
       return
     }
-    numberSteps()
+    if (options.numbers) numberSteps()
     event.dispatcher.on(event.all.result, printFooter)
     await codecept.run(test)
   } catch (err) {