Migrate TestCafe → CodeceptJS 4
TestCafe and CodeceptJS share a lot at the surface — both expose a single test-controller verb-set (t.* / I.*), both have lazy chainable selectors, both ship with role-based auth and screenshot/video support. The migration is mostly mechanical, but three foundational differences drive the work:
- CodeceptJS does not need
await on actions. The recorder auto-queues every I.* call. TestCafe forces await on every action (await t.click(...)); CodeceptJS forbids it on actions and reserves it for grabs (await I.grabTextFrom(...)). This is the single biggest mechanical edit during spec conversion — strip every await from before I.click, I.fillField, I.see*, I.waitFor*, and page-object method calls that return void.
- Helpers, not a bundled proxy. TestCafe runs as an HTTP/HTTPS proxy that injects automation into pages; CodeceptJS dispatches
I.* to a configured helper. Playwright recommended — closest feel, fastest, supports all three engines (Chromium / Firefox / WebKit) the same way TestCafe did.
- First-class abstractions. Page objects, multi-user
session(...), the auth plugin, custom helpers, and the customLocator plugin are built in. TestCafe projects accumulate ad-hoc versions of these (Selector-property classes, Role factories, ClientFunction factories) — the migration consolidates them onto framework idioms.
Authoritative reference: node_modules/codeceptjs/docs/ (basics, locators, playwright, custom-helpers, pageobjects).
When to trigger
Any of:
.testcaferc.{json,js,ts,cjs} at the repo root.
testcafe listed in devDependencies.
- Imports from
testcafe (Selector, ClientFunction, Role, RequestMock, RequestHook, RequestLogger).
- Test files with top-level
fixture('X').page(...) + test('y', async t => { ... }).
- Code uses
Selector(...).withText(...) / .withAttribute(...) / .nth(...) / .find(...) / .filter(...) chains, t.useRole(...), t.addRequestHooks(...), t.eval(...), or ClientFunction(...).
- A
tests/ directory of *.test.{js,ts} whose contents start with fixture(...).
- The user says "migrate / port / convert from TestCafe".
What does not migrate
Be honest up-front:
- Proxy-based architecture — TestCafe runs as a man-in-the-middle proxy and rewrites pages to inject its driver. CodeceptJS uses Playwright (CDP) or WebDriver. The trade-off: lose driverless setup, gain Playwright's speed and ergonomics. Rare TLS / CORS tricks that relied on the proxy will need rethinking.
- TestCafe Studio recordings — UI-recorded tests must be re-authored. Use the
writing-codeceptjs-tests MCP scaffold-and-pause mode to recreate them against the live browser.
- TestCafe
RequestHook / RequestLogger — replaced piecewise: hooks → I.mockRoute(); loggers → page.on('request' | 'response') inside WebExtra if you really need a transcript, or anchor on UI outcomes instead.
disablePageCaching, quarantineMode finer tuning — Playwright handles caching per context; quarantine maps roughly to retry: N but lacks the same heuristics.
- Mobile testing via
testcafe-browser-provider-* packages — use Playwright's mobile emulation (devices['iPhone 13']) or the Appium helper for real devices.
- TestCafe Cloud / Dashboard — replaced by
@testomatio/reporter or another CodeceptJS-compatible reporter.
Workflow
Run phases in order. Commit at each boundary so any regression is bisectable.
1. Inventory the TestCafe project
Before touching anything, build a picture. Two passes.
Shape of the project — grep / wc -l for cost predictors:
.testcaferc.{json,js,ts,cjs} — which keys are in use (browsers, src, concurrency, selectorTimeout, assertionTimeout, pageLoadTimeout, screenshots, videoPath, clientScripts, quarantineMode, stopOnFirstFail, reporter)
- test file count + glob (TestCafe has no required suffix; commonly
*.test.{js,ts} or anything under tests/)
- count occurrences of
Selector(, ClientFunction(, Role(, RequestMock(, t.useRole(, t.eval(, t.addRequestHooks(, .withText(, .withAttribute(, .nth(, .find(, .filter(, .parent(, .child(, .sibling( — each maps to a known replacement pattern
Shared logic — TestCafe projects accumulate four kinds of shared abstractions even without framework support:
- Page-object-style modules — classes whose properties are
Selector(...) references and whose methods drive t.*. Usually under tests/page-objects/, tests/pages/, or <feature>.po.{js,ts}. Port directly to CodeceptJS page objects.
Role definitions — every const admin = Role('https://x/login', async t => { ... }). These are TestCafe's session-cached login flows; their replacement is the auth plugin (phase 8).
ClientFunction factories — const getURL = ClientFunction(() => window.location.href). Each becomes a method on WebExtra using page.evaluate.
RequestMock factories / hook files — RequestMock().onRequestTo('/api/x').respond(...). Each becomes an I.mockRoute(...) call, either inline in tests or wrapped on WebExtra if reused widely.
- Fixture hooks —
fixture(...).beforeEach(...) / .before(...) / .after(...). Become CodeceptJS Before / BeforeSuite hooks in the corresponding test file.
- Custom Test Controller methods — projects sometimes extend
t via mixins; treat them as helper methods and split UI vs HTTP into WebExtra / ApiExtras.
Produce a short inventory: every shared abstraction with its current location and planned CodeceptJS destination. The user reviews before any code is written.
2. Install CodeceptJS alongside TestCafe
npx codeceptjs init and pick the Playwright helper. Playwright covers the same three engines TestCafe supported (Chromium / Firefox / WebKit) with one config. Do not remove TestCafe yet — both run in parallel through the migration.
3. Port the config
Map .testcaferc.{json,js} keys → codecept.conf.{js,ts}:
| TestCafe |
CodeceptJS 4 (Playwright helper) |
browsers: ['chrome'] / ['firefox'] / ['safari'] |
helpers.Playwright.browser: 'chromium' / 'firefox' / 'webkit' |
browsers: ['chrome:headless'] |
helpers.Playwright.show: false (or rely on setHeadlessWhen(CI)) |
src: ['tests/**/*.test.js'] |
tests: './tests/**/*_test.{js,ts}' |
fixture .page('https://x') |
helpers.Playwright.url: 'https://x' |
selectorTimeout / assertionTimeout |
helpers.Playwright.waitForTimeout |
pageLoadTimeout |
helpers.Playwright.timeout |
concurrency: N |
CLI: npx codeceptjs run-workers N |
screenshots.path / videoPath |
top-level output: './output' |
screenshots.takeOnFails: true |
plugin screenshot with on: 'fail' |
videoPath set |
helpers.Playwright.video: true |
clientScripts: ['inject.js'] |
WebExtra method using page.addInitScript, or bootstrap() |
quarantineMode |
top-level retry: N |
stopOnFirstFail: true |
CLI: --bail |
reporter: 'spec' |
drop (Mocha default) or plugin |
hostname / port (proxy) |
drop — Playwright manages |
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 shared helper code. Every reusable browser / HTTP function becomes a method on a custom CodeceptJS helper. Split 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 operations — anything that needs the open page, DOM, init scripts, storage, network-response waits. This is where every ClientFunction and t.eval body lands, as page.evaluate(...). Reaches this.helpers['Playwright'].page / .browserContext.
ApiExtras (lib/helpers/ApiExtras.js) for pure HTTP operations — 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.
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. 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 — ClientFunction ports here:
import Helper from '@codeceptjs/helper'
export default class WebExtra extends Helper {
async grabLocationHref() {
const { page } = this.helpers['Playwright']
return page.evaluate(() => window.location.href)
}
async setLocalStorage(key, value) {
const { page } = this.helpers['Playwright']
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value])
}
async injectClientScript(path) {
const { page } = this.helpers['Playwright']
await page.addInitScript({ path })
}
}
ApiExtras example — RequestMock for real HTTP calls (seeding test data, not mocking responses) goes here:
import Helper from '@codeceptjs/helper'
export default class ApiExtras extends Helper {
async loginViaApi(email, password) {
const REST = this.helpers['REST']
await REST.sendPostRequest('/api/auth/login', { email, password })
}
async seedUser(user) {
const REST = this.helpers['REST']
const { data } = await REST.sendPostRequest('/api/users', user)
return data
}
}
For request mocking (RequestMock().onRequestTo(...).respond(...)), see phase 10 — that uses I.mockRoute (Playwright), not ApiExtras.
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.
Other destinations from the phase 1 inventory:
TestCafe 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(...) properties (this.usernameField = Selector('#user')) become locator-string fields (fields = { usernameField: '#user' }); methods rewrite with const { I } = inject() at the top, calling I.fillField, I.click, and any I.* verb the WebExtra / ApiExtras helpers now contribute. Strip the async t => plumbing — methods receive their args directly. 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 TestCafe module 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.
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,ts}.
Role definitions → auth plugin role definitions (phase 8). If the Role body called the UI to log in, port it as a WebExtra method first; if it hit the API, port it as ApiExtras. The auth plugin then calls that method.
Pure utility modules that don't touch the browser → plain ES modules, imported where needed.
Sanity-check before moving on: npx codeceptjs check -c <config> must pass, and npx codeceptjs list -c <config> must show every ported helper method as an I.* action contributed by WebExtra or ApiExtras.
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.
| TestCafe |
CodeceptJS 4 |
File *.test.{js,ts} |
*_test.{js,ts} |
fixture('X').page('/x') |
Feature('X') at top + Before(({ I }) => I.amOnPage('/x')) |
test('y', async t => { ... }) |
Scenario('y', ({ I }) => { ... }) — drop async t =>, take ({ I, … }) from the test signature |
.beforeEach(async t => { ... }) |
Before(({ I }) => { ... }) |
.afterEach(async t => { ... }) |
After(({ I }) => { ... }) |
.before(...) / .after(...) |
BeforeSuite(...) / AfterSuite(...) |
t.navigateTo('/x') |
I.amOnPage('/x') |
await loginPage.login(u, p) |
loginPage.login(u, p) — no await on void page-object methods |
Strip excess await — this is the single biggest mechanical edit. TestCafe required await on every action; CodeceptJS forbids it on actions and reserves it for grabs. Convention:
I.click('Save') // no await
I.fillField('Email', 'u@t.com') // no await
I.see('Saved') // no await
I.waitForElement('.toast', 3) // no await
const text = await I.grabTextFrom('h1') // await — grabs return data
const ok = await tryTo(() => I.click('Accept')) // await — effects can return values
If a step in the original used t.ctx.foo = ... to thread state through one test, store it in a plain let declared in the Scenario callback. t.fixtureCtx (suite-wide state) becomes a module-level variable, or a BeforeSuite-populated object.
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 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. Locator preference
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.
CodeceptJS priority — pick the highest that fits, then add the context. TestCafe's lazy chainable Selector lines up well with CodeceptJS's locate() builder, but most chains shrink considerably because a semantic string plus a context covers what .withText + .find were doing — Selector('.row').withText('Acme').find('.btn') becomes I.click('Edit', locate('.row').withText('Acme')).
- Semantic strings — button text, label, placeholder, link text:
I.click('Save', '.toolbar'), I.fillField('Email', 'u@t.com', '#login-form'). Covers Selector('button').withText('Save') cleanly.
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'). Strong default for modern apps.
$name via the customLocator plugin — when the suite uses data-test / data-qa attributes.
locate() builder — I.click(locate('.row').withText('Acme').inside('table')). Direct equivalent of TestCafe Selector(...) chains that don't reduce to locator + context.
- CSS / XPath / attribute objects —
{ id: 'foo' }, { name: 'email' }, { css: '[data-test=submit]' }, { xpath: '//div[@id="x"]' }. Fallback.
| TestCafe Selector chain |
CodeceptJS 4 |
Selector('.btn') |
'.btn' |
Selector('button').withText('Submit') |
'Submit' (semantic) or locate('button').withText('Submit') |
Selector('button').withExactText('Submit') |
locate('button').withTextEquals('Submit') — or assert via I.seeTextEquals('Submit', 'button') |
Selector('input').withAttribute('name', 'email') |
{ name: 'email' } |
Selector('input').withAttribute('data-test', /^submit/) |
{ css: 'input[data-test^="submit"]' } |
Selector('.row').nth(0) |
step.opts({ elementIndex: 1 }) |
Selector('.row').nth(-1) |
step.opts({ elementIndex: 'last' }) |
Selector('.row').find('.btn') |
locate('.btn').inside('.row') — or context arg I.click('.btn', '.row') |
Selector('.parent').child('.kid') |
locate('.kid').inside('.parent') |
Selector('.row').filter('.active') |
locate('.row').withClass('active') |
Selector('button').parent('.toolbar') |
n/a one-liner; restructure as I.click('button', '.toolbar') |
Selector(t => t.foo) (function selectors) |
method on WebExtra using page.locator / page.evaluate |
custom t.fixtureCtx.selector = Selector(...) |
page-object field |
step.opts(...) comes from import step from 'codeceptjs/steps'.
7. Actions, assertions, grabs
TestCafe's Test Controller (t) and CodeceptJS's actor (I) line up closely — most actions are a verb rename. The big edits are dropping await from actions, collapsing t.expect(sel.X).Y(...) chains into single I.see* calls, and rewriting t.eval / ClientFunction into helper methods.
| TestCafe |
CodeceptJS 4 |
await t.click(sel) |
I.click(sel) |
await t.typeText(sel, 'x') |
I.fillField(sel, 'x') (clears by default — same as TestCafe with replace: true) |
await t.typeText(sel, 'x', { replace: false }) |
I.appendField(sel, 'x') |
await t.pressKey('enter') |
I.pressKey('Enter') |
await t.hover(sel) |
I.moveCursorTo(sel) |
await t.dragToElement(sel, target) |
I.dragAndDrop(sel, target) |
await t.takeScreenshot('x.png') |
I.saveScreenshot('x.png') |
await t.takeElementScreenshot(sel, 'x.png') |
I.saveElementScreenshot(sel, 'x.png') |
await t.resizeWindow(W, H) |
I.resizeWindow(W, H) |
await t.maximizeWindow() |
I.resizeWindow('maximize') |
await t.setNativeDialogHandler(fn) |
I.acceptPopup() / I.cancelPopup() per dialog |
await t.switchToIframe(sel) |
within({ frame: sel }, () => { ... }) |
await t.switchToMainWindow() |
(end of within block) |
await t.openWindow(url) |
session('w2', () => I.amOnPage(url)) |
await t.eval(() => document.title) |
await I.executeScript(() => document.title) — or method on WebExtra |
ClientFunction(() => window.location.href)() |
await I.grabCurrentUrl() (or webExtra.grabLocationHref() from phase 4) |
await t.wait(N) (N ms) |
I.wait(N / 1000) — CodeceptJS uses seconds; avoid in committed tests |
await t.getBrowserConsoleMessages() |
await I.grabBrowserLogs() |
await t.expect(sel.innerText).eql('X') |
I.seeTextEquals('X', sel) — or I.see('X', sel) for "contains" |
await t.expect(sel.innerText).contains('X') |
I.see('X', sel) |
await t.expect(sel.value).eql('X') |
I.seeInField(sel, 'X') |
await t.expect(sel.checked).ok() |
I.seeCheckboxIsChecked(sel) |
await t.expect(sel.classNames).contains('active') |
I.seeElementHasClass(sel, 'active') |
await t.expect(sel.exists).ok() |
I.seeElementInDOM(sel) |
await t.expect(sel.exists).notOk() |
I.dontSeeElementInDOM(sel) |
await t.expect(sel.visible).ok() |
I.seeElement(sel) |
await t.expect(sel.visible).notOk() |
I.dontSeeElement(sel) |
await t.expect(sel.count).eql(N) |
I.seeNumberOfElements(sel, N) |
await t.expect(value).eql(expected) |
const v = await I.grabXxxFrom(...); I.expectEqual(v, expected) (ExpectHelper) |
await sel.innerText (grab) |
await I.grabTextFrom(sel) |
await sel.getAttribute('data-id') |
await I.grabAttributeFrom(sel, 'data-id') |
await sel.count (grab) |
await I.grabNumberOfVisibleElements(sel) |
await only on grabs. Plain actions queue automatically.
8. Sessions and auth
Role(url, async t => { ... }) + t.useRole(role) → the auth plugin. Hand off to codeceptjs-auth for the setup walk-through. The plugin caches the post-login cookie/storage state and replays it per test, which is exactly what Role does in TestCafe. If phase 4 already ported the Role body into ApiExtras as I.loginViaApi(...) or into WebExtra as I.login(...), the auth plugin's role definition just calls it.
For multi-user scenarios (TestCafe handled this via multiple roles + t.useRole swaps), use session(...) from codeceptjs/effects.
9. Fixtures, requests, tasks
| TestCafe |
CodeceptJS 4 |
import users from './fixtures/users.json' |
import users from './fixtures/users.json' with { type: 'json' } |
t.request(...) (TestCafe 1.20+) |
await I.sendPostRequest(...) via the REST helper; wrap reusable flows in the ApiExtras helper from phase 4 |
clientScripts (inject JS per page) |
helpers.Playwright.bootstrap (per-context init script) or WebExtra method using page.addInitScript |
Test data via fixture('X').meta(...) |
Scenario(..., { tag: '@x' }) plus a constants module |
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.
10. Network mocking
RequestMock().onRequestTo(url).respond(body, status, headers) → I.mockRoute(url, route => route.fulfill({ status, headers, body })) (Playwright). RequestHook subclasses become route handlers too. Disable with I.stopMockingRoute(url). For request logging (RequestLogger), there is no direct equivalent — anchor assertions on UI outcomes (I.waitForText, I.see) or, if you really need a request transcript for the test, attach page.on('request' | 'response') inside a WebExtra method. See node_modules/codeceptjs/docs/playwright.md § Mocking Network Requests.
11. Decommission TestCafe
Only after every spec is ported and CI is green: delete .testcaferc.{json,js,ts,cjs}, drop testcafe from devDependencies (plus any testcafe-browser-provider-*, testcafe-reporter-*, testcafe-react-selectors, testcafe-vue-selectors add-ons), remove the TestCafe CI jobs, uninstall any standalone TestCafe binary.
Verify
npx codeceptjs check -c <config> — config + helper + plugin sanity.
npx codeceptjs list -c <config> — every ported helper method 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 -rE "\\bfixture\\(|\\btest\\(|Selector\\(|ClientFunction\\(|\\bRole\\(|t\\.click\\(|t\\.typeText\\(" tests/ — empty before deleting the original TestCafe directory.
Related skills
writing-codeceptjs-tests — per-spec rewrite playbook (MCP-driven, verified steps); also the path to re-author Studio recordings
debugging-codeceptjs-tests — use on every failing test from the first full run
codeceptjs-auth — replaces Role + t.useRole
codeceptjs-fundamentals — run after migration to confirm wiring; effects (tryTo, within) replace t.switchToIframe
- Reference docs:
node_modules/codeceptjs/docs/ (basics, playwright, locators, custom-helpers, api, assertions, pageobjects, sessions, auth)
1---2name: migrate-testcafe-to-codeceptjs3description: Port a TestCafe test suite to CodeceptJS 4. Trigger when the project contains `.testcaferc.{json,js,ts,cjs}`, `testcafe` in `devDependencies`, test files importing from `testcafe` (`Selector`, `ClientFunction`, `Role`, `RequestMock`, `RequestHook`, `RequestLogger`), top-level `fixture('X').page(...)` + `test('y', async t => { ... })` blocks, `Selector(...)` chains (`.withText`, `.nth`, `.find`, `.filter`), `await t.click(...)` patterns, `t.useRole(...)`, `t.eval(...)` / `ClientFunction(...)`, or TestCafe Studio recordings.4---56# Migrate TestCafe → CodeceptJS 478TestCafe and CodeceptJS share a lot at the surface — both expose a single test-controller verb-set (`t.*` / `I.*`), both have lazy chainable selectors, both ship with role-based auth and screenshot/video support. The migration is mostly mechanical, but three foundational differences drive the work:9101. **CodeceptJS does not need `await` on actions.** The recorder auto-queues every `I.*` call. TestCafe forces `await` on every action (`await t.click(...)`); CodeceptJS forbids it on actions and reserves it for grabs (`await I.grabTextFrom(...)`). **This is the single biggest mechanical edit during spec conversion** — strip every `await` from before `I.click`, `I.fillField`, `I.see*`, `I.waitFor*`, and page-object method calls that return void.112. **Helpers, not a bundled proxy.** TestCafe runs as an HTTP/HTTPS proxy that injects automation into pages; CodeceptJS dispatches `I.*` to a configured helper. **Playwright recommended** — closest feel, fastest, supports all three engines (Chromium / Firefox / WebKit) the same way TestCafe did.123. **First-class abstractions.** Page objects, multi-user `session(...)`, the `auth` plugin, custom helpers, and the `customLocator` plugin are built in. TestCafe projects accumulate ad-hoc versions of these (Selector-property classes, `Role` factories, `ClientFunction` factories) — the migration consolidates them onto framework idioms.1314Authoritative reference: `node_modules/codeceptjs/docs/` (basics, locators, playwright, custom-helpers, pageobjects).1516## When to trigger1718Any of:1920- `.testcaferc.{json,js,ts,cjs}` at the repo root.21- `testcafe` listed in `devDependencies`.22- Imports from `testcafe` (`Selector`, `ClientFunction`, `Role`, `RequestMock`, `RequestHook`, `RequestLogger`).23- Test files with top-level `fixture('X').page(...)` + `test('y', async t => { ... })`.24- Code uses `Selector(...).withText(...)` / `.withAttribute(...)` / `.nth(...)` / `.find(...)` / `.filter(...)` chains, `t.useRole(...)`, `t.addRequestHooks(...)`, `t.eval(...)`, or `ClientFunction(...)`.25- A `tests/` directory of `*.test.{js,ts}` whose contents start with `fixture(...)`.26- The user says "migrate / port / convert from TestCafe".2728## What does not migrate2930Be honest up-front:3132- **Proxy-based architecture** — TestCafe runs as a man-in-the-middle proxy and rewrites pages to inject its driver. CodeceptJS uses Playwright (CDP) or WebDriver. The trade-off: lose driverless setup, gain Playwright's speed and ergonomics. Rare TLS / CORS tricks that relied on the proxy will need rethinking.33- **TestCafe Studio recordings** — UI-recorded tests must be re-authored. Use the `writing-codeceptjs-tests` MCP scaffold-and-pause mode to recreate them against the live browser.34- **TestCafe `RequestHook` / `RequestLogger`** — replaced piecewise: hooks → `I.mockRoute()`; loggers → `page.on('request' | 'response')` inside `WebExtra` if you really need a transcript, or anchor on UI outcomes instead.35- **`disablePageCaching`, `quarantineMode` finer tuning** — Playwright handles caching per context; quarantine maps roughly to `retry: N` but lacks the same heuristics.36- **Mobile testing via `testcafe-browser-provider-*` packages** — use Playwright's mobile emulation (`devices['iPhone 13']`) or the `Appium` helper for real devices.37- **TestCafe Cloud / Dashboard** — replaced by `@testomatio/reporter` or another CodeceptJS-compatible reporter.3839## Workflow4041Run phases in order. Commit at each boundary so any regression is bisectable.4243### 1. Inventory the TestCafe project4445Before touching anything, build a picture. Two passes.4647**Shape of the project** — grep / `wc -l` for cost predictors:4849- `.testcaferc.{json,js,ts,cjs}` — which keys are in use (`browsers`, `src`, `concurrency`, `selectorTimeout`, `assertionTimeout`, `pageLoadTimeout`, `screenshots`, `videoPath`, `clientScripts`, `quarantineMode`, `stopOnFirstFail`, `reporter`)50- test file count + glob (TestCafe has no required suffix; commonly `*.test.{js,ts}` or anything under `tests/`)51- count occurrences of `Selector(`, `ClientFunction(`, `Role(`, `RequestMock(`, `t.useRole(`, `t.eval(`, `t.addRequestHooks(`, `.withText(`, `.withAttribute(`, `.nth(`, `.find(`, `.filter(`, `.parent(`, `.child(`, `.sibling(` — each maps to a known replacement pattern5253**Shared logic** — TestCafe projects accumulate four kinds of shared abstractions even without framework support:5455- **Page-object-style modules** — classes whose properties are `Selector(...)` references and whose methods drive `t.*`. Usually under `tests/page-objects/`, `tests/pages/`, or `<feature>.po.{js,ts}`. Port directly to CodeceptJS page objects.56- **`Role` definitions** — every `const admin = Role('https://x/login', async t => { ... })`. These are TestCafe's session-cached login flows; their replacement is the **`auth` plugin** (phase 8).57- **`ClientFunction` factories** — `const getURL = ClientFunction(() => window.location.href)`. Each becomes a method on `WebExtra` using `page.evaluate`.58- **`RequestMock` factories / hook files** — `RequestMock().onRequestTo('/api/x').respond(...)`. Each becomes an `I.mockRoute(...)` call, either inline in tests or wrapped on `WebExtra` if reused widely.59- **Fixture hooks** — `fixture(...).beforeEach(...)` / `.before(...)` / `.after(...)`. Become CodeceptJS `Before` / `BeforeSuite` hooks in the corresponding test file.60- **Custom Test Controller methods** — projects sometimes extend `t` via mixins; treat them as helper methods and split UI vs HTTP into `WebExtra` / `ApiExtras`.6162Produce a short inventory: every shared abstraction with its current location and planned CodeceptJS destination. The user reviews before any code is written.6364### 2. Install CodeceptJS alongside TestCafe6566`npx codeceptjs init` and pick the **Playwright** helper. Playwright covers the same three engines TestCafe supported (Chromium / Firefox / WebKit) with one config. Do not remove TestCafe yet — both run in parallel through the migration.6768### 3. Port the config6970Map `.testcaferc.{json,js}` keys → `codecept.conf.{js,ts}`:7172| TestCafe | CodeceptJS 4 (`Playwright` helper) |73|---|---|74| `browsers: ['chrome']` / `['firefox']` / `['safari']` | `helpers.Playwright.browser: 'chromium'` / `'firefox'` / `'webkit'` |75| `browsers: ['chrome:headless']` | `helpers.Playwright.show: false` (or rely on `setHeadlessWhen(CI)`) |76| `src: ['tests/**/*.test.js']` | `tests: './tests/**/*_test.{js,ts}'` |77| fixture `.page('https://x')` | `helpers.Playwright.url: 'https://x'` |78| `selectorTimeout` / `assertionTimeout` | `helpers.Playwright.waitForTimeout` |79| `pageLoadTimeout` | `helpers.Playwright.timeout` |80| `concurrency: N` | CLI: `npx codeceptjs run-workers N` |81| `screenshots.path` / `videoPath` | top-level `output: './output'` |82| `screenshots.takeOnFails: true` | plugin `screenshot` with `on: 'fail'` |83| `videoPath` set | `helpers.Playwright.video: true` |84| `clientScripts: ['inject.js']` | `WebExtra` method using `page.addInitScript`, or `bootstrap()` |85| `quarantineMode` | top-level `retry: N` |86| `stopOnFirstFail: true` | CLI: `--bail` |87| `reporter: 'spec'` | drop (Mocha default) or plugin |88| `hostname` / `port` (proxy) | drop — Playwright manages |8990### 4. Port shared abstractions9192This is the bedrock. Do it before any spec rewrite — every spec rewrite shrinks because the verbs it needs (`I.doSmth(...)`) already exist.9394**Hard rule for shared helper code.** Every reusable browser / HTTP function becomes a method on a custom CodeceptJS helper. **Split across two helpers by the kind of operation** — they have different access patterns and different correct APIs:9596- **`WebExtra`** (`lib/helpers/WebExtra.js`) for **browser-driven** operations — anything that needs the open page, DOM, init scripts, storage, network-response waits. **This is where every `ClientFunction` and `t.eval` body lands, as `page.evaluate(...)`.** Reaches `this.helpers['Playwright'].page` / `.browserContext`.97- **`ApiExtras`** (`lib/helpers/ApiExtras.js`) for **pure HTTP** operations — 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.9899Register both helpers under `helpers` in `codecept.conf.{js,ts}`.100101**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. If the API needs the same auth as the browser, share cookies once at the top of the config:102103```js104import { setSharedCookies } from '@codeceptjs/configure'105setSharedCookies()106```107108…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`.109110**WebExtra example** — `ClientFunction` ports here:111112```js113import Helper from '@codeceptjs/helper'114115export default class WebExtra extends Helper {116 async grabLocationHref() {117 const { page } = this.helpers['Playwright']118 return page.evaluate(() => window.location.href)119 }120121 async setLocalStorage(key, value) {122 const { page } = this.helpers['Playwright']123 await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value])124 }125126 async injectClientScript(path) {127 const { page } = this.helpers['Playwright']128 await page.addInitScript({ path })129 }130}131```132133**ApiExtras example** — `RequestMock` for *real* HTTP calls (seeding test data, not mocking responses) goes here:134135```js136import Helper from '@codeceptjs/helper'137138export default class ApiExtras extends Helper {139 async loginViaApi(email, password) {140 const REST = this.helpers['REST']141 await REST.sendPostRequest('/api/auth/login', { email, password })142 }143144 async seedUser(user) {145 const REST = this.helpers['REST']146 const { data } = await REST.sendPostRequest('/api/users', user)147 return data148 }149}150```151152For **request mocking** (`RequestMock().onRequestTo(...).respond(...)`), see phase 10 — that uses `I.mockRoute` (Playwright), not `ApiExtras`.153154**Helper code style** — applies to both:155156- All `import` statements at the **top of the file**. Never `const fs = await import('node:fs/promises')` inside a method.157- 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`.158159**Other destinations** from the phase 1 inventory:160161- **TestCafe 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(...)` properties (`this.usernameField = Selector('#user')`) become locator-string fields (`fields = { usernameField: '#user' }`); methods rewrite with `const { I } = inject()` at the top, calling `I.fillField`, `I.click`, and any `I.*` verb the `WebExtra` / `ApiExtras` helpers now contribute. Strip the `async t =>` plumbing — methods receive their args directly. Register under `include` in `codecept.conf.{js,ts}` so the page object auto-injects into Scenarios.162163 Page-object anti-patterns to avoid (unless the original TestCafe module already had them):164 - **Assertion methods** (`checkTitle() { I.seeElement(...) }`) — page objects are action verbs (`fillForm`, `submitOrder`); let assertions live in the test.165 - **One-liner wrappers** around a single `I.click` / `I.see*` / `I.grabTextFrom` — the wrapper buys nothing over calling `I.*` from the test.166 - **Methods used by only one test** — leave the steps in the test.167 - **`if (cond) throw new Error(...)`** in any method — use `I.see*`, `I.seeNumberOfElements`, `ExpectHelper`, or `codeceptjs/assertions` factories instead.168169- **Shared `Selector` constants** → fields on the relevant page object. No free-floating `selectors.{js,ts}`.170- **`Role` definitions** → `auth` plugin role definitions (phase 8). If the `Role` body called the UI to log in, port it as a `WebExtra` method first; if it hit the API, port it as `ApiExtras`. The `auth` plugin then calls that method.171- **Pure utility modules** that don't touch the browser → plain ES modules, imported where needed.172173Sanity-check before moving on: `npx codeceptjs check -c <config>` must pass, and `npx codeceptjs list -c <config>` must show every ported helper method as an `I.*` action contributed by `WebExtra` or `ApiExtras`.174175### 5. Convert spec files176177One 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.178179| TestCafe | CodeceptJS 4 |180|---|---|181| File `*.test.{js,ts}` | `*_test.{js,ts}` |182| `fixture('X').page('/x')` | `Feature('X')` at top + `Before(({ I }) => I.amOnPage('/x'))` |183| `test('y', async t => { ... })` | `Scenario('y', ({ I }) => { ... })` — drop `async t =>`, take `({ I, … })` from the test signature |184| `.beforeEach(async t => { ... })` | `Before(({ I }) => { ... })` |185| `.afterEach(async t => { ... })` | `After(({ I }) => { ... })` |186| `.before(...)` / `.after(...)` | `BeforeSuite(...)` / `AfterSuite(...)` |187| `t.navigateTo('/x')` | `I.amOnPage('/x')` |188| `await loginPage.login(u, p)` | `loginPage.login(u, p)` — no `await` on void page-object methods |189190**Strip excess `await`** — this is the single biggest mechanical edit. TestCafe required `await` on every action; CodeceptJS forbids it on actions and reserves it for grabs. Convention:191192```js193I.click('Save') // no await194I.fillField('Email', 'u@t.com') // no await195I.see('Saved') // no await196I.waitForElement('.toast', 3) // no await197const text = await I.grabTextFrom('h1') // await — grabs return data198const ok = await tryTo(() => I.click('Accept')) // await — effects can return values199```200201If a step in the original used `t.ctx.foo = ...` to thread state through one test, store it in a plain `let` declared in the Scenario callback. `t.fixtureCtx` (suite-wide state) becomes a module-level variable, or a `BeforeSuite`-populated object.202203**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 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:204205```js206for (const sort of testSort) {207 I.click(locate(this.filterFormLabel).withText(sort))208}209```210211```js212for (const row of await I.grabWebElements('.row')) {213 const text = await row.getText()214 I.expectNotEmpty(text)215}216```217218**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.219220Then run the batch: `npx codeceptjs run --steps -c <config>`.221222- 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.**223- Every failure → invoke `debugging-codeceptjs-tests` and fix on the fly (breakpoint, live-page inspection, verified fix). No blind rewrites, no `retry` masking.224- A batch is done when it runs green, not when it dry-runs clean.225226### 6. Locator preference227228**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*.229230CodeceptJS priority — pick the highest that fits, then add the context. TestCafe's lazy chainable `Selector` lines up well with CodeceptJS's `locate()` builder, but most chains shrink considerably because a semantic string plus a context covers what `.withText` + `.find` were doing — `Selector('.row').withText('Acme').find('.btn')` becomes `I.click('Edit', locate('.row').withText('Acme'))`.2312321. **Semantic strings** — button text, label, placeholder, link text: `I.click('Save', '.toolbar')`, `I.fillField('Email', 'u@t.com', '#login-form')`. Covers `Selector('button').withText('Save')` cleanly.233A 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"]' }`.2342. **ARIA roles** — `I.click({ role: 'button', name: 'Sign In' }, '#login-form')`. Strong default for modern apps.2353. **`$name` via the `customLocator` plugin** — when the suite uses `data-test` / `data-qa` attributes.2364. **`locate()` builder** — `I.click(locate('.row').withText('Acme').inside('table'))`. Direct equivalent of TestCafe `Selector(...)` chains that don't reduce to locator + context.2375. **CSS / XPath / attribute objects** — `{ id: 'foo' }`, `{ name: 'email' }`, `{ css: '[data-test=submit]' }`, `{ xpath: '//div[@id="x"]' }`. Fallback.238239| TestCafe Selector chain | CodeceptJS 4 |240|---|---|241| `Selector('.btn')` | `'.btn'` |242| `Selector('button').withText('Submit')` | `'Submit'` (semantic) or `locate('button').withText('Submit')` |243| `Selector('button').withExactText('Submit')` | `locate('button').withTextEquals('Submit')` — or assert via `I.seeTextEquals('Submit', 'button')` |244| `Selector('input').withAttribute('name', 'email')` | `{ name: 'email' }` |245| `Selector('input').withAttribute('data-test', /^submit/)` | `{ css: 'input[data-test^="submit"]' }` |246| `Selector('.row').nth(0)` | `step.opts({ elementIndex: 1 })` |247| `Selector('.row').nth(-1)` | `step.opts({ elementIndex: 'last' })` |248| `Selector('.row').find('.btn')` | `locate('.btn').inside('.row')` — or context arg `I.click('.btn', '.row')` |249| `Selector('.parent').child('.kid')` | `locate('.kid').inside('.parent')` |250| `Selector('.row').filter('.active')` | `locate('.row').withClass('active')` |251| `Selector('button').parent('.toolbar')` | n/a one-liner; restructure as `I.click('button', '.toolbar')` |252| `Selector(t => t.foo)` (function selectors) | method on `WebExtra` using `page.locator` / `page.evaluate` |253| custom `t.fixtureCtx.selector = Selector(...)` | page-object field |254255`step.opts(...)` comes from `import step from 'codeceptjs/steps'`.256257### 7. Actions, assertions, grabs258259TestCafe's Test Controller (`t`) and CodeceptJS's actor (`I`) line up closely — most actions are a verb rename. The big edits are dropping `await` from actions, collapsing `t.expect(sel.X).Y(...)` chains into single `I.see*` calls, and rewriting `t.eval` / `ClientFunction` into helper methods.260261| TestCafe | CodeceptJS 4 |262|---|---|263| `await t.click(sel)` | `I.click(sel)` |264| `await t.typeText(sel, 'x')` | `I.fillField(sel, 'x')` (clears by default — same as TestCafe with `replace: true`) |265| `await t.typeText(sel, 'x', { replace: false })` | `I.appendField(sel, 'x')` |266| `await t.pressKey('enter')` | `I.pressKey('Enter')` |267| `await t.hover(sel)` | `I.moveCursorTo(sel)` |268| `await t.dragToElement(sel, target)` | `I.dragAndDrop(sel, target)` |269| `await t.takeScreenshot('x.png')` | `I.saveScreenshot('x.png')` |270| `await t.takeElementScreenshot(sel, 'x.png')` | `I.saveElementScreenshot(sel, 'x.png')` |271| `await t.resizeWindow(W, H)` | `I.resizeWindow(W, H)` |272| `await t.maximizeWindow()` | `I.resizeWindow('maximize')` |273| `await t.setNativeDialogHandler(fn)` | `I.acceptPopup()` / `I.cancelPopup()` per dialog |274| `await t.switchToIframe(sel)` | `within({ frame: sel }, () => { ... })` |275| `await t.switchToMainWindow()` | (end of `within` block) |276| `await t.openWindow(url)` | `session('w2', () => I.amOnPage(url))` |277| `await t.eval(() => document.title)` | `await I.executeScript(() => document.title)` — or method on `WebExtra` |278| `ClientFunction(() => window.location.href)()` | `await I.grabCurrentUrl()` (or `webExtra.grabLocationHref()` from phase 4) |279| `await t.wait(N)` (N ms) | `I.wait(N / 1000)` — CodeceptJS uses **seconds**; avoid in committed tests |280| `await t.getBrowserConsoleMessages()` | `await I.grabBrowserLogs()` |281| `await t.expect(sel.innerText).eql('X')` | `I.seeTextEquals('X', sel)` — or `I.see('X', sel)` for "contains" |282| `await t.expect(sel.innerText).contains('X')` | `I.see('X', sel)` |283| `await t.expect(sel.value).eql('X')` | `I.seeInField(sel, 'X')` |284| `await t.expect(sel.checked).ok()` | `I.seeCheckboxIsChecked(sel)` |285| `await t.expect(sel.classNames).contains('active')` | `I.seeElementHasClass(sel, 'active')` |286| `await t.expect(sel.exists).ok()` | `I.seeElementInDOM(sel)` |287| `await t.expect(sel.exists).notOk()` | `I.dontSeeElementInDOM(sel)` |288| `await t.expect(sel.visible).ok()` | `I.seeElement(sel)` |289| `await t.expect(sel.visible).notOk()` | `I.dontSeeElement(sel)` |290| `await t.expect(sel.count).eql(N)` | `I.seeNumberOfElements(sel, N)` |291| `await t.expect(value).eql(expected)` | `const v = await I.grabXxxFrom(...); I.expectEqual(v, expected)` (ExpectHelper) |292| `await sel.innerText` (grab) | `await I.grabTextFrom(sel)` |293| `await sel.getAttribute('data-id')` | `await I.grabAttributeFrom(sel, 'data-id')` |294| `await sel.count` (grab) | `await I.grabNumberOfVisibleElements(sel)` |295296`await` only on grabs. Plain actions queue automatically.297298### 8. Sessions and auth299300`Role(url, async t => { ... })` + `t.useRole(role)` → the **`auth` plugin**. Hand off to **`codeceptjs-auth`** for the setup walk-through. The plugin caches the post-login cookie/storage state and replays it per test, which is exactly what `Role` does in TestCafe. If phase 4 already ported the `Role` body into `ApiExtras` as `I.loginViaApi(...)` or into `WebExtra` as `I.login(...)`, the `auth` plugin's role definition just calls it.301302For multi-user scenarios (TestCafe handled this via multiple roles + `t.useRole` swaps), use `session(...)` from `codeceptjs/effects`.303304### 9. Fixtures, requests, tasks305306| TestCafe | CodeceptJS 4 |307|---|---|308| `import users from './fixtures/users.json'` | `import users from './fixtures/users.json' with { type: 'json' }` |309| `t.request(...)` (TestCafe 1.20+) | `await I.sendPostRequest(...)` via the **REST helper**; wrap reusable flows in the `ApiExtras` helper from phase 4 |310| `clientScripts` (inject JS per page) | `helpers.Playwright.bootstrap` (per-context init script) or `WebExtra` method using `page.addInitScript` |311| Test data via `fixture('X').meta(...)` | `Scenario(..., { tag: '@x' })` plus a constants module |312313REST 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`.314315### 10. Network mocking316317`RequestMock().onRequestTo(url).respond(body, status, headers)` → `I.mockRoute(url, route => route.fulfill({ status, headers, body }))` (Playwright). `RequestHook` subclasses become route handlers too. Disable with `I.stopMockingRoute(url)`. For request *logging* (`RequestLogger`), there is no direct equivalent — anchor assertions on UI outcomes (`I.waitForText`, `I.see`) or, if you really need a request transcript for the test, attach `page.on('request' | 'response')` inside a `WebExtra` method. See `node_modules/codeceptjs/docs/playwright.md` § Mocking Network Requests.318319### 11. Decommission TestCafe320321Only after every spec is ported and CI is green: delete `.testcaferc.{json,js,ts,cjs}`, drop `testcafe` from `devDependencies` (plus any `testcafe-browser-provider-*`, `testcafe-reporter-*`, `testcafe-react-selectors`, `testcafe-vue-selectors` add-ons), remove the TestCafe CI jobs, uninstall any standalone TestCafe binary.322323## Verify3243251. `npx codeceptjs check -c <config>` — config + helper + plugin sanity.3262. `npx codeceptjs list -c <config>` — every ported helper method appears as an `I.*` action from `WebExtra` or `ApiExtras`; every page object's methods appear.3273. `npx codeceptjs dry-run --steps -c <config>` — every Scenario loads.3284. 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.3295. Hand off to **`codeceptjs-run-analysis`** to inspect `output/trace_*/` artifacts (requires the `aiTrace` plugin enabled).3306. `grep -rE "\\bfixture\\(|\\btest\\(|Selector\\(|ClientFunction\\(|\\bRole\\(|t\\.click\\(|t\\.typeText\\(" tests/` — empty before deleting the original TestCafe directory.331332## Related skills333334- `writing-codeceptjs-tests` — per-spec rewrite playbook (MCP-driven, verified steps); also the path to re-author Studio recordings335- `debugging-codeceptjs-tests` — use on every failing test from the first full run336- `codeceptjs-auth` — replaces `Role` + `t.useRole`337- `codeceptjs-fundamentals` — run after migration to confirm wiring; effects (`tryTo`, `within`) replace `t.switchToIframe`338- Reference docs: `node_modules/codeceptjs/docs/` (basics, playwright, locators, custom-helpers, api, assertions, pageobjects, sessions, auth)