Migrate Cypress → CodeceptJS 4
Cypress and CodeceptJS share a goal — browser end-to-end testing — but differ in three foundational ways:
- Step queueing vs command chains. CodeceptJS auto-queues every
I.* call onto an internal recorder; tests look synchronous and await is only needed for grabs (await I.grabTextFrom(...)). There is no .then() chain to thread state through.
- Helpers, not a bundled browser.
I.* dispatches to a configured helper. Cypress is single-browser by design; CodeceptJS lets you pick Playwright (recommended for Cypress migrators — Chromium parity plus cross-browser), Puppeteer, or WebDriver, and the test code stays the same.
- First-class abstractions. Page objects, multi-user
session(...), the auth plugin, and custom helpers are built in. Cypress projects accumulate ad-hoc versions of these; the migration consolidates them onto the framework's idioms.
Authoritative reference: node_modules/codeceptjs/docs/ (basics, locators, playwright, custom-helpers, pageobjects).
When to trigger
Any of:
cypress.config.{js,ts,mjs} at the repo root.
- A
cypress/ directory with e2e/, support/, fixtures/, plugins/, or component/ subdirs.
cypress listed in devDependencies.
- Test code calls
cy.* (cy.visit, cy.get, cy.contains, cy.session, cy.intercept, cy.request, cy.task, cy.fixture, cy.origin, cy.mount), uses Cypress.Commands.add(...), or reads Cypress.env(...).
- The user says "migrate / port / convert from Cypress".
What does not migrate
Be honest up-front:
- Component tests (
cy.mount, cypress/component/) — CodeceptJS is E2E only. Keep Cypress for components, or move them to Playwright Component Testing / Vitest + Testing Library.
cy.intercept('POST', '/api').as('save') → cy.wait('@save') — the closest equivalent is Playwright's I.mockRoute() (no alias, no cy.wait('@x')). Anchor waits on UI outcomes (I.waitForText('Saved')) instead of network events.
- Cypress Cloud / time-travel debugger — replaced by the
aiTrace plugin's per-step artifacts and @testomatio/reporter for dashboards.
cy.origin() multi-origin flows — limited support; document the gap and plan around it.
Workflow
Run phases in order. Commit at each boundary so any regression is bisectable.
1. Inventory the Cypress project
Before touching anything, build a picture. Two passes.
Shape of the project — grep / wc -l for cost predictors:
cypress.config.{js,ts,mjs} — which keys are in use
cypress/e2e/**/*.cy.{js,ts} — spec count
cypress/fixtures/ — count + filenames
cypress/support/{e2e,commands}.{js,ts} — these always exist; read in full
cypress/plugins/ — legacy preprocessor / task wiring
cypress/component/ + cy.mount( — flag for the user (out of scope)
- count occurrences of
cy.intercept(, cy.task(, cy.session(, cy.origin(, Cypress.Commands.add(, cy.fixture( — each maps to a known replacement pattern
Shared logic and shared locators — Cypress has no built-in page objects, but suites accumulate shared abstractions anyway. Find them before touching test files:
- Custom commands — every
Cypress.Commands.add('<name>', fn) in cypress/support/commands.{js,ts}. List name → arguments → body. Almost every suite has them (cy.login, cy.seedData, cy.dragRowTo, …).
- Page-object-style modules — look in
cypress/support/, cypress/pages/, cypress/page-objects/, cypress/helpers/, cypress/objects/, cypress/po/, and any pages/ / pageObjects/ outside the cypress directory. Recognise: modules exporting selector bundles ({ usernameField: '#user', submitBtn: '[data-cy=submit]' }), modules exporting methods that call cy.* (login(user, pwd), goToProfile()), classes with selectors as fields.
- Shared selector constants — files named
selectors.{js,ts} / locators.{js,ts}, or modules exporting only strings. Grep specs for repeated cy.get('[data-cy=...]') strings — duplicates are abstraction candidates.
- Utility helpers — date formatters, URL builders, API wrappers (
api.js, helpers.js, utils.js).
- Global hooks —
cypress/support/e2e.{js,ts} beforeEach blocks, Cypress.on('uncaught:exception', ...), etc.
Produce a short inventory: every shared abstraction with its current Cypress location and planned CodeceptJS destination (see phase 4's destination table). The user reviews before any code is written.
2. Install CodeceptJS alongside Cypress
npx codeceptjs init and pick the Playwright helper. Do not remove Cypress yet — both run in parallel through the migration, so a half-converted suite still has green coverage.
3. Port the config
Map cypress.config.{js,ts} keys → codecept.conf.{js,ts}:
| Cypress |
CodeceptJS 4 (Playwright helper) |
e2e.baseUrl |
helpers.Playwright.url |
viewportWidth / viewportHeight |
helpers.Playwright.windowSize: '1280x720' |
defaultCommandTimeout |
helpers.Playwright.waitForTimeout |
video |
helpers.Playwright.video: true |
screenshotOnRunFailure |
plugin screenshot with on: 'fail' |
retries |
top-level retry: N |
env.* / Cypress.env('X') |
process.env.X |
setupNodeEvents / cy.task |
custom helper or bootstrap / teardown |
4. Port shared abstractions
This is the bedrock. Do it before any spec rewrite — every spec rewrite shrinks because the verbs it needs (I.doSmth(...)) already exist.
Hard rule for Cypress custom commands. Every Cypress.Commands.add('<name>', fn) becomes a method on a custom helper. Split commands across two helpers by the kind of operation — they have different access patterns and different correct APIs:
WebExtra (lib/helpers/WebExtra.js) for browser-driven commands — anything that needs the open page, DOM, evaluate, init scripts, storage, network-response waits. Reaches this.helpers['Playwright'].page / .browserContext.
ApiExtras (lib/helpers/ApiExtras.js) for pure HTTP commands — programmatic login, seed/teardown data, CRUD against an API. Reaches this.helpers['REST'] (or GraphQL). See node_modules/codeceptjs/docs/api.md for REST helper configuration.
One async method per Cypress command, named identically, so cy.doSmth(arg) → I.doSmth(arg). Register both helpers under helpers in codecept.conf.{js,ts}.
Never call this.helpers['Playwright'].browserContext.request.* for API work. That bypasses the REST + JSONResponse stack — no step logging, no I.seeResponseCodeIsSuccessful assertions, no shared headers, and the same verb ends up split between helpers. If the API needs the same auth as the browser, share cookies once at the top of the config:
import { setSharedCookies } from '@codeceptjs/configure'
setSharedCookies()
…or set defaultHeaders on the REST helper for token-based auth, or use I.amBearerAuthenticated(secret(token)) per test. All three patterns are covered in api.md.
WebExtra example — browser-driven commands (here login drives the UI form; the API-driven variant goes to ApiExtras below):
import Helper from '@codeceptjs/helper'
import fs from 'node:fs/promises'
export default class WebExtra extends Helper {
async login(user, password) {
const { page } = this.helpers['Playwright']
await page.goto('/login')
await page.getByLabel('Email').fill(user)
await page.getByLabel('Password').fill(password)
await page.getByRole('button', { name: 'Sign In' }).click()
await page.waitForURL(/\/dashboard/)
}
async setLocalStorage(key, value) {
const { page } = this.helpers['Playwright']
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value])
}
async stubWindowOpen() {
const { page } = this.helpers['Playwright']
await page.addInitScript(() => {
window.__lastOpenUrl = null
const orig = window.open
window.open = (url, ...rest) => {
window.__lastOpenUrl = url
return orig ? orig.call(window, 'about:blank', ...rest) : null
}
})
}
async writeJsonFile(filePath, data) {
await fs.writeFile(filePath, JSON.stringify(data, null, 2))
}
}
ApiExtras example — pure HTTP commands routed through the REST helper:
import Helper from '@codeceptjs/helper'
export default class ApiExtras extends Helper {
async loginViaApi(email, password) {
const REST = this.helpers['REST']
await REST.sendPostRequest('/login_ajax', { email, password, remember: false })
}
async seedCourse(courseData) {
const REST = this.helpers['REST']
const { data } = await REST.sendPostRequest('/course', courseData)
return data
}
}
Helper code style — applies to both:
- All
import statements at the top of the file. Never const fs = await import('node:fs/promises') inside a method.
- Use built-in assertions (
I.seeResponseCodeIsSuccessful for API, I.seeElement for browser), ExpectHelper, or factories from codeceptjs/assertions — never if (cond) throw new Error('...'). Failures must render as proper assertion errors. See node_modules/codeceptjs/docs/assertions.md.
- If your
WebExtra is growing a session-cache map keyed by user name, you are reimplementing the auth plugin — stop and let the auth plugin (phase 8) handle session reuse. The helper should expose loginViaApi / login; the plugin handles caching.
Cypress code that called cy.window().then(...), cy.wrap(...), or imperative DOM tricks translates cleanly into page.evaluate(...) inside WebExtra. Cypress code that called cy.request(...) translates to REST.sendXxxRequest(...) inside ApiExtras.
Other destinations from the phase 1 inventory:
Cypress page-object-style module → CodeceptJS page object class under pages/. Port conservatively — keep only the methods the original module had; do not invent new wrappers during migration. Selector bundles become this.fields = { ... }; methods rewrite with const { I } = inject() at the top, calling I.fillField, I.click, and any I.* verb the WebExtra / ApiExtras helpers now contribute. Register under include in codecept.conf.{js,ts} so the page object auto-injects into Scenarios.
Page-object anti-patterns to avoid (unless the original Cypress code already had them):
- Assertion methods (
checkTitle() { I.seeElement(...) }) — page objects are action verbs (fillForm, submitOrder); let assertions live in the test.
- One-liner wrappers around a single
I.click / I.see* / I.grabTextFrom — the wrapper buys nothing over calling I.* from the test.
- Methods used by only one test — leave the steps in the test. Page objects exist for reuse.
if (cond) throw new Error(...) in any method — use I.see*, I.seeNumberOfElements, ExpectHelper, or codeceptjs/assertions factories instead.
Shared selector constants → fields on the relevant page object. No free-floating selectors.js.
Pure utility modules that don't touch the browser → plain ES modules, imported where needed.
Global hooks → CodeceptJS Before / BeforeSuite in tests, or bootstrap / teardown in config for one-off setup.
Sanity-check before moving on: npx codeceptjs check -c <config> must pass, and npx codeceptjs list -c <config> must show every Cypress command name as an I.* action contributed by WebExtra or ApiExtras — whichever owns it.
5. Convert spec files
One file at a time, leaning on the abstractions from phase 4. Hand off the per-spec work to the writing-codeceptjs-tests skill — it drives the live browser via MCP and verifies each step before committing.
| Cypress |
CodeceptJS 4 |
File *.cy.{js,ts} |
*_test.{js,ts} |
describe('X', () => { ... }) |
Feature('X') at top, one Feature per file |
it('Y', () => { ... }) |
Scenario('Y', ({ I }) => { ... }) |
beforeEach(() => { ... }) |
Before(({ I }) => { ... }) |
afterEach(() => { ... }) |
After(({ I }) => { ... }) |
before(...) / after(...) |
BeforeSuite(...) / AfterSuite(...) |
cy.visit('/x') |
I.amOnPage('/x') |
cy.login(u, p) (custom command) |
I.login(u, p) (from WebExtra) |
Iteration — in tests, page objects, and helpers, use for...of for any loop containing I.* calls. Never Array.prototype.forEach. .forEach swallows the iteration callback's return — an await inside it does not block the outer function, and the CodeceptJS recorder may queue steps out of order or finish the Scenario before the loop is done. for...of keeps the loop sequential and lets you add await later without rewriting:
for (const sort of testSort) {
I.click(locate(this.filterFormLabel).withText(sort))
}
for (const row of await I.grabWebElements('.row')) {
const text = await row.getText()
I.expectNotEmpty(text)
}
Per batch: npx codeceptjs dry-run --steps -c <config> — loads every Scenario, resolves every I.* call, no browser. Surfaces typos, missing imports, page objects not under include, and nonexistent verbs in seconds. Fix before anything real.
Then run the batch: npx codeceptjs run --steps -c <config>.
- First real runs almost always fail — locator drift, timing the source framework hid behind its own retry, auth/session differences, data assumptions. Expected; fixing it is part of the migration.
- Every failure → invoke
debugging-codeceptjs-tests and fix on the fly (breakpoint, live-page inspection, verified fix). No blind rewrites, no retry masking.
- A batch is done when it runs green, not when it dry-runs clean.
6. Locators
Scope every locator with a context. The last argument of every action narrows the lookup to a region — I.click('Save', '.toolbar'), I.fillField('Email', 'u@t.com', '#login-form'), I.click({ role: 'button', name: 'Delete' }, '.modal'). A short semantic or ARIA locator plus a context beats one long unscoped locator: it reads like the page, disambiguates duplicate labels without growing, and survives markup churn. Apply this to every row of the tables below — the source framework's chain usually splits cleanly into region + what the user sees.
cy.get(sel).within(() => ...) and cy.get(parent).find(child) both collapse onto the context argument — that is where a Cypress chain's parent selector belongs.
CodeceptJS priority — pick the highest that fits, then add the context:
- Semantic strings — button text, label, placeholder, link text:
I.click('Save', '.toolbar'), I.fillField('Email', 'u@t.com', '#login-form'). Replaces most cy.contains(...) calls.
A plain string already matches aria-label, so an icon-only control with aria-label="Save" is I.click('Save', <context>) — never 'aria-label=Save' or { css: '[aria-label="Save"]' }.
- ARIA roles —
I.click({ role: 'button', name: 'Sign In' }, '#login-form').
$name via the customLocator plugin — Cypress users often default to [data-cy=...]. Keep those attributes, but enable the plugin so they read as I.click('$submit', '.checkout') instead of { css: '[data-cy=submit]' }.
locate() builder — I.click(locate('button').withText('Edit').inside('tr').withText('Acme')); often better split as I.click('Edit', locate('tr').withText('Acme')).
- CSS / XPath — fallback only.
Full guidance in writing-codeceptjs-tests § Locators.
7. Actions, assertions, grabs
| Cypress |
CodeceptJS 4 |
cy.get(sel).click() |
I.click(sel) |
cy.get(sel).type('x') |
I.fillField(sel, 'x') |
cy.get(sel).clear() |
I.clearField(sel) |
cy.get(sel).check() / .uncheck() |
I.checkOption(sel) / I.uncheckOption(sel) |
cy.get(sel).select('A') |
I.selectOption(sel, 'A') |
cy.get(sel).should('be.visible') |
I.seeElement(sel) |
cy.get(sel).should('have.text', 'X') |
I.see('X', sel) |
cy.get(sel).should('have.value', 'X') |
I.seeInField(sel, 'X') |
cy.get(sel).should('have.length', 5) |
I.seeNumberOfElements(sel, 5) |
cy.url().should('include', '/x') |
I.seeInCurrentUrl('/x') |
cy.get(sel).invoke('text').then(t => ...) |
const t = await I.grabTextFrom(sel) |
cy.getCookie('s') |
const c = await I.grabCookie('s') |
await only on grabs. Plain actions queue automatically.
8. Sessions and auth
cy.session(id, setup, { validate }) and cy.request-based programmatic login → the auth plugin. Hand off to codeceptjs-auth for the setup walk-through. If phase 4 already ported cy.login into WebExtra as I.login(...), the auth plugin's role definition just calls I.login(...). For multi-user scenarios (Cypress has no native equivalent) use session(...) from codeceptjs/effects.
9. Fixtures, requests, tasks
| Cypress |
CodeceptJS 4 |
cy.fixture('users.json') |
import users from './fixtures/users.json' with { type: 'json' } |
cy.request('POST', '/api/x', body) |
await I.sendPostRequest('/api/x', body) via the REST helper; for reusable flows wrap in the ApiExtras helper from phase 4 |
cy.task('seedDB') |
method on ApiExtras (if HTTP), a dedicated helper, or bootstrap / teardown |
REST helper auth: setSharedCookies() from @codeceptjs/configure shares the browser session with REST so the same user is logged in on both sides; alternatively set defaultHeaders for static tokens or I.amBearerAuthenticated(secret(token)) per test. See node_modules/codeceptjs/docs/api.md for the full configuration surface, including JSONResponse assertions (I.seeResponseCodeIsSuccessful, I.seeResponseContainsKeys, I.seeResponseMatchesJsonSchema with Zod).
10. Network mocking
cy.intercept(url, handler) → I.mockRoute(url, route => route.fulfill({ ... })) (Playwright). Disable with I.stopMockingRoute(url). There is no cy.wait('@alias') equivalent — anchor waits on UI outcomes (I.waitForText, I.seeElement) instead of network events.
11. Decommission Cypress
Only after every spec is ported and CI is green: delete cypress/, cypress.config.*, drop cypress from devDependencies, remove the Cypress CI jobs.
Verify
npx codeceptjs check -c <config> — config + helper + plugin sanity.
npx codeceptjs list -c <config> — every ported Cypress command appears as an I.* action from WebExtra or ApiExtras; every page object's methods appear.
npx codeceptjs dry-run --steps -c <config> — every Scenario loads.
- Full run:
npx codeceptjs run --steps -c <config>. Failures are expected on first runs — drive each to a fix via the debugging-codeceptjs-tests skill (not retry, not blind rewrites). The migration is complete only when the whole converted suite is green.
- Hand off to
codeceptjs-run-analysis to inspect output/trace_*/ artifacts (requires the aiTrace plugin enabled).
grep -r "cy\." cypress/ — empty before deleting cypress/.
Related skills
writing-codeceptjs-tests — per-spec rewrite playbook (MCP-driven, verified steps)
debugging-codeceptjs-tests — use on every failing test from the first full run
codeceptjs-auth — replaces cy.session() and programmatic login
codeceptjs-fundamentals — run after migration to confirm wiring
- Reference docs:
node_modules/codeceptjs/docs/ (basics, playwright, locators, custom-helpers, api, assertions, pageobjects, data, sessions, effects)
1---2name: migrate-cypress-to-codeceptjs3description: Port a Cypress test suite to CodeceptJS 4. Trigger when the project contains `cypress.config.{js,ts,mjs}`, a `cypress/` directory (`cypress/e2e/`, `cypress/support/{commands,e2e}.*`, `cypress/fixtures/`), `cypress` in `devDependencies`, or test code calling `cy.*` (`cy.visit`, `cy.get`, `cy.contains`, `cy.session`, `cy.intercept`, `cy.request`, `cy.task`, `cy.fixture`, `cy.origin`, `cy.mount`), `Cypress.Commands.add(...)`, or `Cypress.env(...)`.4---56# Migrate Cypress → CodeceptJS 478Cypress and CodeceptJS share a goal — browser end-to-end testing — but differ in three foundational ways:9101. **Step queueing vs command chains.** CodeceptJS auto-queues every `I.*` call onto an internal recorder; tests look synchronous and `await` is only needed for grabs (`await I.grabTextFrom(...)`). There is no `.then()` chain to thread state through.112. **Helpers, not a bundled browser.** `I.*` dispatches to a configured helper. Cypress is single-browser by design; CodeceptJS lets you pick **Playwright** (recommended for Cypress migrators — Chromium parity plus cross-browser), Puppeteer, or WebDriver, and the test code stays the same.123. **First-class abstractions.** Page objects, multi-user `session(...)`, the `auth` plugin, and custom helpers are built in. Cypress projects accumulate ad-hoc versions of these; the migration consolidates them onto the framework's idioms.1314Authoritative reference: `node_modules/codeceptjs/docs/` (basics, locators, playwright, custom-helpers, pageobjects).1516## When to trigger1718Any of:1920- `cypress.config.{js,ts,mjs}` at the repo root.21- A `cypress/` directory with `e2e/`, `support/`, `fixtures/`, `plugins/`, or `component/` subdirs.22- `cypress` listed in `devDependencies`.23- Test code calls `cy.*` (`cy.visit`, `cy.get`, `cy.contains`, `cy.session`, `cy.intercept`, `cy.request`, `cy.task`, `cy.fixture`, `cy.origin`, `cy.mount`), uses `Cypress.Commands.add(...)`, or reads `Cypress.env(...)`.24- The user says "migrate / port / convert from Cypress".2526## What does not migrate2728Be honest up-front:2930- **Component tests** (`cy.mount`, `cypress/component/`) — CodeceptJS is E2E only. Keep Cypress for components, or move them to Playwright Component Testing / Vitest + Testing Library.31- **`cy.intercept('POST', '/api').as('save')` → `cy.wait('@save')`** — the closest equivalent is Playwright's `I.mockRoute()` (no alias, no `cy.wait('@x')`). Anchor waits on UI outcomes (`I.waitForText('Saved')`) instead of network events.32- **Cypress Cloud / time-travel debugger** — replaced by the `aiTrace` plugin's per-step artifacts and `@testomatio/reporter` for dashboards.33- **`cy.origin()` multi-origin flows** — limited support; document the gap and plan around it.3435## Workflow3637Run phases in order. Commit at each boundary so any regression is bisectable.3839### 1. Inventory the Cypress project4041Before touching anything, build a picture. Two passes.4243**Shape of the project** — grep / `wc -l` for cost predictors:4445- `cypress.config.{js,ts,mjs}` — which keys are in use46- `cypress/e2e/**/*.cy.{js,ts}` — spec count47- `cypress/fixtures/` — count + filenames48- `cypress/support/{e2e,commands}.{js,ts}` — these always exist; **read in full**49- `cypress/plugins/` — legacy preprocessor / task wiring50- `cypress/component/` + `cy.mount(` — flag for the user (out of scope)51- count occurrences of `cy.intercept(`, `cy.task(`, `cy.session(`, `cy.origin(`, `Cypress.Commands.add(`, `cy.fixture(` — each maps to a known replacement pattern5253**Shared logic and shared locators** — Cypress has no built-in page objects, but suites accumulate shared abstractions anyway. Find them before touching test files:5455- **Custom commands** — every `Cypress.Commands.add('<name>', fn)` in `cypress/support/commands.{js,ts}`. List name → arguments → body. Almost every suite has them (`cy.login`, `cy.seedData`, `cy.dragRowTo`, …).56- **Page-object-style modules** — look in `cypress/support/`, `cypress/pages/`, `cypress/page-objects/`, `cypress/helpers/`, `cypress/objects/`, `cypress/po/`, and any `pages/` / `pageObjects/` outside the cypress directory. Recognise: modules exporting selector bundles (`{ usernameField: '#user', submitBtn: '[data-cy=submit]' }`), modules exporting methods that call `cy.*` (`login(user, pwd)`, `goToProfile()`), classes with selectors as fields.57- **Shared selector constants** — files named `selectors.{js,ts}` / `locators.{js,ts}`, or modules exporting only strings. Grep specs for repeated `cy.get('[data-cy=...]')` strings — duplicates are abstraction candidates.58- **Utility helpers** — date formatters, URL builders, API wrappers (`api.js`, `helpers.js`, `utils.js`).59- **Global hooks** — `cypress/support/e2e.{js,ts}` `beforeEach` blocks, `Cypress.on('uncaught:exception', ...)`, etc.6061Produce a short inventory: every shared abstraction with its current Cypress location and planned CodeceptJS destination (see phase 4's destination table). The user reviews before any code is written.6263### 2. Install CodeceptJS alongside Cypress6465`npx codeceptjs init` and pick the **Playwright** helper. Do not remove Cypress yet — both run in parallel through the migration, so a half-converted suite still has green coverage.6667### 3. Port the config6869Map `cypress.config.{js,ts}` keys → `codecept.conf.{js,ts}`:7071| Cypress | CodeceptJS 4 (`Playwright` helper) |72|---|---|73| `e2e.baseUrl` | `helpers.Playwright.url` |74| `viewportWidth` / `viewportHeight` | `helpers.Playwright.windowSize: '1280x720'` |75| `defaultCommandTimeout` | `helpers.Playwright.waitForTimeout` |76| `video` | `helpers.Playwright.video: true` |77| `screenshotOnRunFailure` | plugin `screenshot` with `on: 'fail'` |78| `retries` | top-level `retry: N` |79| `env.*` / `Cypress.env('X')` | `process.env.X` |80| `setupNodeEvents` / `cy.task` | custom helper or `bootstrap` / `teardown` |8182### 4. Port shared abstractions8384This is the bedrock. Do it before any spec rewrite — every spec rewrite shrinks because the verbs it needs (`I.doSmth(...)`) already exist.8586**Hard rule for Cypress custom commands.** Every `Cypress.Commands.add('<name>', fn)` becomes a method on a custom helper. **Split commands across two helpers by the kind of operation** — they have different access patterns and different correct APIs:8788- **`WebExtra`** (`lib/helpers/WebExtra.js`) for **browser-driven** commands — anything that needs the open page, DOM, `evaluate`, init scripts, storage, network-response waits. Reaches `this.helpers['Playwright'].page` / `.browserContext`.89- **`ApiExtras`** (`lib/helpers/ApiExtras.js`) for **pure HTTP** commands — programmatic login, seed/teardown data, CRUD against an API. Reaches `this.helpers['REST']` (or `GraphQL`). See `node_modules/codeceptjs/docs/api.md` for REST helper configuration.9091One async method per Cypress command, named identically, so `cy.doSmth(arg)` → `I.doSmth(arg)`. Register both helpers under `helpers` in `codecept.conf.{js,ts}`.9293**Never call `this.helpers['Playwright'].browserContext.request.*` for API work.** That bypasses the REST + `JSONResponse` stack — no step logging, no `I.seeResponseCodeIsSuccessful` assertions, no shared headers, and the same verb ends up split between helpers. If the API needs the same auth as the browser, share cookies once at the top of the config:9495```js96import { setSharedCookies } from '@codeceptjs/configure'97setSharedCookies()98```99100…or set `defaultHeaders` on the REST helper for token-based auth, or use `I.amBearerAuthenticated(secret(token))` per test. All three patterns are covered in `api.md`.101102**WebExtra example** — browser-driven commands (here `login` drives the UI form; the API-driven variant goes to `ApiExtras` below):103104```js105import Helper from '@codeceptjs/helper'106import fs from 'node:fs/promises'107108export default class WebExtra extends Helper {109 async login(user, password) {110 const { page } = this.helpers['Playwright']111 await page.goto('/login')112 await page.getByLabel('Email').fill(user)113 await page.getByLabel('Password').fill(password)114 await page.getByRole('button', { name: 'Sign In' }).click()115 await page.waitForURL(/\/dashboard/)116 }117118 async setLocalStorage(key, value) {119 const { page } = this.helpers['Playwright']120 await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value])121 }122123 async stubWindowOpen() {124 const { page } = this.helpers['Playwright']125 await page.addInitScript(() => {126 window.__lastOpenUrl = null127 const orig = window.open128 window.open = (url, ...rest) => {129 window.__lastOpenUrl = url130 return orig ? orig.call(window, 'about:blank', ...rest) : null131 }132 })133 }134135 async writeJsonFile(filePath, data) {136 await fs.writeFile(filePath, JSON.stringify(data, null, 2))137 }138}139```140141**ApiExtras example** — pure HTTP commands routed through the REST helper:142143```js144import Helper from '@codeceptjs/helper'145146export default class ApiExtras extends Helper {147 async loginViaApi(email, password) {148 const REST = this.helpers['REST']149 await REST.sendPostRequest('/login_ajax', { email, password, remember: false })150 }151152 async seedCourse(courseData) {153 const REST = this.helpers['REST']154 const { data } = await REST.sendPostRequest('/course', courseData)155 return data156 }157}158```159160**Helper code style** — applies to both:161162- All `import` statements at the **top of the file**. Never `const fs = await import('node:fs/promises')` inside a method.163- Use built-in assertions (`I.seeResponseCodeIsSuccessful` for API, `I.seeElement` for browser), `ExpectHelper`, or factories from `codeceptjs/assertions` — **never** `if (cond) throw new Error('...')`. Failures must render as proper assertion errors. See `node_modules/codeceptjs/docs/assertions.md`.164- If your `WebExtra` is growing a session-cache map keyed by user name, you are reimplementing the `auth` plugin — stop and let the `auth` plugin (phase 8) handle session reuse. The helper should expose `loginViaApi` / `login`; the plugin handles caching.165166Cypress code that called `cy.window().then(...)`, `cy.wrap(...)`, or imperative DOM tricks translates cleanly into `page.evaluate(...)` inside `WebExtra`. Cypress code that called `cy.request(...)` translates to `REST.sendXxxRequest(...)` inside `ApiExtras`.167168**Other destinations** from the phase 1 inventory:169170- **Cypress page-object-style module** → CodeceptJS **page object class** under `pages/`. **Port conservatively** — keep only the methods the original module had; do not invent new wrappers during migration. Selector bundles become `this.fields = { ... }`; methods rewrite with `const { I } = inject()` at the top, calling `I.fillField`, `I.click`, and any `I.*` verb the `WebExtra` / `ApiExtras` helpers now contribute. Register under `include` in `codecept.conf.{js,ts}` so the page object auto-injects into Scenarios.171172 Page-object anti-patterns to avoid (unless the original Cypress code already had them):173 - **Assertion methods** (`checkTitle() { I.seeElement(...) }`) — page objects are action verbs (`fillForm`, `submitOrder`); let assertions live in the test.174 - **One-liner wrappers** around a single `I.click` / `I.see*` / `I.grabTextFrom` — the wrapper buys nothing over calling `I.*` from the test.175 - **Methods used by only one test** — leave the steps in the test. Page objects exist for reuse.176 - **`if (cond) throw new Error(...)`** in any method — use `I.see*`, `I.seeNumberOfElements`, `ExpectHelper`, or `codeceptjs/assertions` factories instead.177178- **Shared selector constants** → fields on the relevant page object. No free-floating `selectors.js`.179- **Pure utility modules** that don't touch the browser → plain ES modules, imported where needed.180- **Global hooks** → CodeceptJS `Before` / `BeforeSuite` in tests, or `bootstrap` / `teardown` in config for one-off setup.181182Sanity-check before moving on: `npx codeceptjs check -c <config>` must pass, and `npx codeceptjs list -c <config>` must show every Cypress command name as an `I.*` action contributed by `WebExtra` or `ApiExtras` — whichever owns it.183184### 5. Convert spec files185186One file at a time, leaning on the abstractions from phase 4. Hand off the per-spec work to the **`writing-codeceptjs-tests`** skill — it drives the live browser via MCP and verifies each step before committing.187188| Cypress | CodeceptJS 4 |189|---|---|190| File `*.cy.{js,ts}` | `*_test.{js,ts}` |191| `describe('X', () => { ... })` | `Feature('X')` at top, one Feature per file |192| `it('Y', () => { ... })` | `Scenario('Y', ({ I }) => { ... })` |193| `beforeEach(() => { ... })` | `Before(({ I }) => { ... })` |194| `afterEach(() => { ... })` | `After(({ I }) => { ... })` |195| `before(...)` / `after(...)` | `BeforeSuite(...)` / `AfterSuite(...)` |196| `cy.visit('/x')` | `I.amOnPage('/x')` |197| `cy.login(u, p)` (custom command) | `I.login(u, p)` (from `WebExtra`) |198199**Iteration** — in tests, page objects, and helpers, use **`for...of`** for any loop containing `I.*` calls. Never `Array.prototype.forEach`. `.forEach` swallows the iteration callback's return — an `await` inside it does not block the outer function, and the CodeceptJS recorder may queue steps out of order or finish the Scenario before the loop is done. `for...of` keeps the loop sequential and lets you add `await` later without rewriting:200201```js202for (const sort of testSort) {203 I.click(locate(this.filterFormLabel).withText(sort))204}205```206207```js208for (const row of await I.grabWebElements('.row')) {209 const text = await row.getText()210 I.expectNotEmpty(text)211}212```213214**Per batch**: `npx codeceptjs dry-run --steps -c <config>` — loads every Scenario, resolves every `I.*` call, no browser. Surfaces typos, missing imports, page objects not under `include`, and nonexistent verbs in seconds. Fix before anything real.215216Then run the batch: `npx codeceptjs run --steps -c <config>`.217218- First real runs almost always fail — locator drift, timing the source framework hid behind its own retry, auth/session differences, data assumptions. **Expected; fixing it is part of the migration.**219- Every failure → invoke `debugging-codeceptjs-tests` and fix on the fly (breakpoint, live-page inspection, verified fix). No blind rewrites, no `retry` masking.220- A batch is done when it runs green, not when it dry-runs clean.221222### 6. Locators223224**Scope every locator with a context.** The last argument of every action narrows the lookup to a region — `I.click('Save', '.toolbar')`, `I.fillField('Email', 'u@t.com', '#login-form')`, `I.click({ role: 'button', name: 'Delete' }, '.modal')`. A short semantic or ARIA locator plus a context beats one long unscoped locator: it reads like the page, disambiguates duplicate labels without growing, and survives markup churn. Apply this to every row of the tables below — the source framework's chain usually splits cleanly into *region* + *what the user sees*.225226`cy.get(sel).within(() => ...)` and `cy.get(parent).find(child)` both collapse onto the context argument — that is where a Cypress chain's parent selector belongs.227228CodeceptJS priority — pick the highest that fits, then add the context:2292301. **Semantic strings** — button text, label, placeholder, link text: `I.click('Save', '.toolbar')`, `I.fillField('Email', 'u@t.com', '#login-form')`. Replaces most `cy.contains(...)` calls.231A plain string already matches `aria-label`, so an icon-only control with `aria-label="Save"` is `I.click('Save', <context>)` — never `'aria-label=Save'` or `{ css: '[aria-label="Save"]' }`.2322. **ARIA roles** — `I.click({ role: 'button', name: 'Sign In' }, '#login-form')`.2333. **`$name` via the `customLocator` plugin** — Cypress users often default to `[data-cy=...]`. Keep those attributes, but enable the plugin so they read as `I.click('$submit', '.checkout')` instead of `{ css: '[data-cy=submit]' }`.2344. **`locate()` builder** — `I.click(locate('button').withText('Edit').inside('tr').withText('Acme'))`; often better split as `I.click('Edit', locate('tr').withText('Acme'))`.2355. **CSS / XPath** — fallback only.236237Full guidance in **`writing-codeceptjs-tests`** § Locators.238239### 7. Actions, assertions, grabs240241| Cypress | CodeceptJS 4 |242|---|---|243| `cy.get(sel).click()` | `I.click(sel)` |244| `cy.get(sel).type('x')` | `I.fillField(sel, 'x')` |245| `cy.get(sel).clear()` | `I.clearField(sel)` |246| `cy.get(sel).check()` / `.uncheck()` | `I.checkOption(sel)` / `I.uncheckOption(sel)` |247| `cy.get(sel).select('A')` | `I.selectOption(sel, 'A')` |248| `cy.get(sel).should('be.visible')` | `I.seeElement(sel)` |249| `cy.get(sel).should('have.text', 'X')` | `I.see('X', sel)` |250| `cy.get(sel).should('have.value', 'X')` | `I.seeInField(sel, 'X')` |251| `cy.get(sel).should('have.length', 5)` | `I.seeNumberOfElements(sel, 5)` |252| `cy.url().should('include', '/x')` | `I.seeInCurrentUrl('/x')` |253| `cy.get(sel).invoke('text').then(t => ...)` | `const t = await I.grabTextFrom(sel)` |254| `cy.getCookie('s')` | `const c = await I.grabCookie('s')` |255256`await` only on grabs. Plain actions queue automatically.257258### 8. Sessions and auth259260`cy.session(id, setup, { validate })` and `cy.request`-based programmatic login → the **`auth` plugin**. Hand off to **`codeceptjs-auth`** for the setup walk-through. If phase 4 already ported `cy.login` into `WebExtra` as `I.login(...)`, the `auth` plugin's role definition just calls `I.login(...)`. For multi-user scenarios (Cypress has no native equivalent) use `session(...)` from `codeceptjs/effects`.261262### 9. Fixtures, requests, tasks263264| Cypress | CodeceptJS 4 |265|---|---|266| `cy.fixture('users.json')` | `import users from './fixtures/users.json' with { type: 'json' }` |267| `cy.request('POST', '/api/x', body)` | `await I.sendPostRequest('/api/x', body)` via the **REST helper**; for reusable flows wrap in the `ApiExtras` helper from phase 4 |268| `cy.task('seedDB')` | method on `ApiExtras` (if HTTP), a dedicated helper, or `bootstrap` / `teardown` |269270REST helper auth: `setSharedCookies()` from `@codeceptjs/configure` shares the browser session with REST so the same user is logged in on both sides; alternatively set `defaultHeaders` for static tokens or `I.amBearerAuthenticated(secret(token))` per test. See `node_modules/codeceptjs/docs/api.md` for the full configuration surface, including `JSONResponse` assertions (`I.seeResponseCodeIsSuccessful`, `I.seeResponseContainsKeys`, `I.seeResponseMatchesJsonSchema` with Zod).271272### 10. Network mocking273274`cy.intercept(url, handler)` → `I.mockRoute(url, route => route.fulfill({ ... }))` (Playwright). Disable with `I.stopMockingRoute(url)`. There is no `cy.wait('@alias')` equivalent — anchor waits on UI outcomes (`I.waitForText`, `I.seeElement`) instead of network events.275276### 11. Decommission Cypress277278Only after every spec is ported and CI is green: delete `cypress/`, `cypress.config.*`, drop `cypress` from `devDependencies`, remove the Cypress CI jobs.279280## Verify2812821. `npx codeceptjs check -c <config>` — config + helper + plugin sanity.2832. `npx codeceptjs list -c <config>` — every ported Cypress command appears as an `I.*` action from `WebExtra` or `ApiExtras`; every page object's methods appear.2843. `npx codeceptjs dry-run --steps -c <config>` — every Scenario loads.2854. Full run: `npx codeceptjs run --steps -c <config>`. Failures are expected on first runs — drive each to a fix via the **`debugging-codeceptjs-tests`** skill (not `retry`, not blind rewrites). The migration is complete only when the whole converted suite is green.2865. Hand off to **`codeceptjs-run-analysis`** to inspect `output/trace_*/` artifacts (requires the `aiTrace` plugin enabled).2876. `grep -r "cy\." cypress/` — empty before deleting `cypress/`.288289## Related skills290291- `writing-codeceptjs-tests` — per-spec rewrite playbook (MCP-driven, verified steps)292- `debugging-codeceptjs-tests` — use on every failing test from the first full run293- `codeceptjs-auth` — replaces `cy.session()` and programmatic login294- `codeceptjs-fundamentals` — run after migration to confirm wiring295- Reference docs: `node_modules/codeceptjs/docs/` (basics, playwright, locators, custom-helpers, api, assertions, pageobjects, data, sessions, effects)