e2e replaces Cypress command chains with async TypeScript tests that run in Node. Both suites can stay in the project while you move one spec at a time.

Agent prompt

Migrate this project's Cypress tests to e2e, one spec at a time. Docs: https://e2e.tester.army/docs/migrate/cypress.md

  1. Read the migration guide above. It maps Cypress config, commands, and assertions to e2e.
  2. If the project has no e2e config (e2e.config.ts, .mts, .js, or .mjs), run npx e2e init --yes (or the pnpm or bun equivalent). It writes a config and an example test, and installs nothing yet.
  3. Read the e2e skill (npx e2e guide prints it, npx e2e guide <topic> prints a topic) and follow it for every step below.
  4. If you ran init, ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription, an API key, or a local model. Configure it with its provider package. If it needs a sign-in, give me the npx e2e login command and wait until I confirm.
  5. Port cypress.config.* into the e2e config: baseUrl and the dev server command become the target's app, and if the specs select by data-cy, set web({ testIdAttribute: 'data-cy' }) so screen.getByTestId reads it. Keep Cypress installed while both suites run.
  6. Install dependencies.
  7. List the Cypress end-to-end specs and ask me which one to move first. Suggest one that breaks often or waits with cy.wait(ms). Component tests have no e2e equivalent; leave them in Cypress.
  8. Rewrite that spec as a .e2e.ts file under tests/ (e2e runs tests/**/*.e2e.ts by default) with the guide's tables, one awaited call per line. Where cy.contains matched a fragment, pass { exact: false }. Replace each cy.wait(ms) with an assertion on the state the test waits for.
  9. Keep navigation and the final assertions deterministic, with app.open() and locator assertions. Where a run of scripted interactions breaks often, propose an agent.act goal for it instead.
  10. Run the new file with npx e2e run <file> and fix it until it passes. Keep the Cypress spec until I confirm it can go.
  11. Summarize which tests moved, which stayed in Cypress, and why.

For a migration that spans several sessions, the migrate-to-e2e skill records what moved in a progress file and keeps the old suite running until you retire each test.

Port the config

Install e2e and configure a model with the quickstart. The example below uses an agent call.

Keep your data-cy attributes. The config below reads them as test ids.

Cypresse2e
baseUrlapp: { url } on the target
start-server-and-testapp: { url, command }, see Starting your app
viewportWidth, viewportHeightweb({ viewport })
data-cy selectorsweb({ testIdAttribute: 'data-cy' }), then screen.getByTestId
retriesretries
env, Cypress.env()process.env
cypress.env.json secretscredentials and secrets, see Signing in
cypress run --browser firefoxA second target with web({ browser: 'firefox' })
Component testingNo equivalent
e2e.config.ts
import type { E2EConfig } from 'e2e';
import { web } from '@e2e-dev/web';

export default {
  targets: [{
    engine: web({ testIdAttribute: 'data-cy' }),
    app: { url: 'http://localhost:3000' },
  }],
} satisfies E2EConfig;

Rewrite one spec

Start with a spec that often breaks.

Cypress
describe('todos', () => {
  it('completes one', () => {
    cy.visit('/todos');
    cy.get('[data-cy=new-todo]').type('Buy milk{enter}');
    cy.get('[data-cy=new-todo]').type('Walk the dog{enter}');
    cy.contains('li', 'Buy milk').find('[data-cy=toggle]').click();
    cy.get('[data-cy=todo]').should('have.length', 2);
    cy.contains('1 item left').should('be.visible');
  });
});
e2e
import { describe, test } from '@e2e-dev/web';
import { expect } from 'e2e';

describe('todos', () => {
  test('completes one', async ({ app, agent, screen }) => {
    await app.open('/todos');
    await agent.act('add todos "Buy milk" and "Walk the dog"');
    await expect(screen.getByTestId('todo')).toHaveCount(2);
    await agent.act('complete "Buy milk"');
    await expect(screen.getByText('1 item left')).toBeVisible();
  });
});

Map each command

Cypresse2e
cy.visit(path)app.open(path)
cy.get('[data-cy=x]')screen.getByTestId('x')
cy.get('css')browser.locator('css'), not portable; prefer a role or label
cy.contains('text')screen.getByText('text', { exact: false })
cy.contains('li', 'text')screen.getByRole('listitem').filter({ hasText: 'text' })
.find(), .within()Chain a query off the locator: row.getByRole(...)
.first(), .eq(n).first(), .nth(n)
.click(), .dblclick(), .rightclick().tap(), .doubleTap() (or .dblclick()), .secondaryTap()
.type('text').fill('text') replaces the content
.type('{enter}').press('Enter')
.clear(), .check(), .select().clear(), .check(), .selectOption()
.selectFile().setInputFiles()
.should('be.visible')await expect(locator).toBeVisible()
.should('have.text', t), .should('contain', t).toHaveText(t), .toContainText(t)
.should('have.length', n).toHaveCount(n)
.should('have.value', v).toHaveValue(v)
.should('not.exist').toHaveCount(0); toBeHidden() also accepts a node that is still in the DOM
cy.url().should('include', p)expect(browser).toHaveURL(/p/)
cy.title()expect(browser).toHaveTitle()
cy.intercept(url, body)browser.route(url, route => route.fulfill({ json: body }))
cy.wait('@alias')browser.waitForResponse(url)
cy.on('window:confirm')browser.onDialog('accept')
cy.window(), cy.document()browser.evaluate(() => ...)
cy.setCookiebrowser.setCookies
cy.viewportbrowser.setViewport
cy.sessiontest.setup and { session }
cy.task, cy.exec, cy.requestNode code: fetch, fs, your database client
cy.fixtureimport data from './fixture.json'
Cypress.Commands.addA function, or a fixture from test.extend
cy.wait(ms)Use a locator assertion for the condition you need
cypress opennpx e2e run --headed, plus --debug for the step table

Two things differ from Cypress:

  • There is no chain. Await each call on its own line. It returns a value: const text = await locator.textContent().
  • Actions and single-node reads and assertions need one match. Several fail with LOCATOR_AMBIGUOUS. Narrow with .filter(), .first(), or .nth(). See screen.

Next