pw2cy
v1.1.0
Published
Production-quality CLI to migrate Playwright tests to Cypress specs using AST transforms
Maintainers
Readme
pw2cy — Playwright → Cypress Test Converter
Migrate your Playwright test suite to Cypress specs in minutes, not weeks.
pw2cy does the heavy lifting via AST transforms — no regex hacks — preserving test structure, names, and assertions while emitting clean, formatted, readable Cypress specs with a detailed HTML + JSON migration report.
Why pw2cy?
Cypress is the dominant end-to-end framework for many teams, but switching from Playwright means manually rewriting hundreds of test files. No official tooling exists for this migration:
| Problem | pw2cy solution |
|---|---|
| Manual page.* → cy.* rewrites | AST-based transforms, not regexes |
| Broken locator strategies | Maps all getBy* helpers to Cypress equivalents |
| Different assertion APIs | Converts all expect().toXxx() chains |
| Unknown unsupported APIs | Emits TODO comments with guidance |
| No migration visibility | JSON + sortable HTML report with confidence scores |
| TS + JS both needed | Handles .ts and .js inputs, preserves extension |
Installation
You can use pw2cy without even installing it, or set it up as a tool in your project.
Option 1: Zero-Install (Recommended)
Use npx to run it directly from the registry:
npx pw2cy init
npx pw2cy convert <input> --out <output>Option 2: Global Installation
Install it permanently on your machine:
npm install -g pw2cy
# Now you can use the command directly
pw2cy convert ./tests --out ./cypress/e2eOption 3: Local Dev Dependency
Add it to your project's package.json to lock the version:
npm install --save-dev pw2cyQuick Start
# 1. Scaffold config and Cypress compat layer
npx pw2cy init
# 2. Convert a whole directory
npx pw2cy convert ./playwright-tests --out ./cypress/e2e
# 3. Convert a single file
npx pw2cy convert ./tests/login.spec.ts --out ./cypress/e2e
# 4. Dry run (no files written)
npx pw2cy convert ./playwright-tests --out ./cypress/e2e --dry-run
# 5. Print the migration report
npx pw2cy reportAfter running init, import the compat layer in cypress/support/e2e.ts:
import './pw-compat';CLI Commands
pw2cy init
Scaffolds two files:
| File | Purpose |
|---|---|
| pw2cy.config.json | Configuration file with all options |
| cypress/support/pw-compat.ts | Custom Cypress commands mirroring Playwright helpers |
| cypress/tsconfig.json | TypeScript configuration for Cypress specs |
Options:
--force— Overwrite existing files--no-compat— Skip compat layer generation
pw2cy convert <input> [options]
Converts Playwright specs to Cypress specs.
| Flag | Default | Description |
|---|---|---|
| --out <dir> | ./cypress/e2e | Output directory |
| --config <file> | ./pw2cy.config.json | Config file path |
| --report-dir <dir> | . | Where reports are written |
| --dry-run | false | Parse & analyse without writing |
| --verbose | false | Print detailed logs |
pw2cy report
Pretty-prints the last conversion report and optionally opens the HTML report.
| Flag | Default | Description |
|---|---|---|
| --json <file> | ./pw2cy-report.json | JSON report path |
| --no-open | false | Don't auto-open HTML in browser |
Configuration (pw2cy.config.json)
{
"useCompatLayer": true,
"selectorPreference": ["data-testid", "role", "text", "css"],
"baseUrl": "https://app.example.com",
"replacePageGotoWithBaseUrl": true,
"unsupportedBehavior": "comment",
"assertionStyle": "should"
}| Option | Type | Default | Description |
|---|---|---|---|
| useCompatLayer | boolean | true | Use cy.pwTestId/pwRole/pwText from compat layer |
| selectorPreference | string[] | ["data-testid", ...] | Ordered locator strategy preference |
| baseUrl | string? | "" | Base URL for goto() replacement |
| replacePageGotoWithBaseUrl | boolean | false | Strip baseUrl prefix from visit() URLs |
| unsupportedBehavior | "comment"\|"todo"\|"error" | "comment" | How to handle unrecognised APIs |
| assertionStyle | "should"\|"expect" | "should" | Cypress assertion style |
Supported Conversions
Test Structure
| Playwright | Cypress |
|---|---|
| import { test, expect } from '@playwright/test' | (removed) |
| test('name', async ({ page }) => { }) | it('name', () => { }) |
| test.describe('Suite', fn) | describe('Suite', fn) |
| test.describe.only(...) | describe.only(...) |
| test.describe.skip(...) | describe.skip(...) |
| test.beforeEach(async ({ page }) => { }) | beforeEach(() => { }) |
| test.afterEach(...) | afterEach(...) |
| test.beforeAll(...) | before(...) |
| test.afterAll(...) | after(...) |
| test.skip(...) | it.skip(...) |
| test.only(...) | it.only(...) |
| await test.step('name', fn) | cy.log('Step: name') (+ inner logic) |
Page Object Model (POM) & Classes (New!)
pw2cy now provides deep support for refactoring Playwright Page Objects into Cypress-compatible classes.
| Playwright POM | Cypress Transformation |
|---|---|
| export class LoginPage { ... } | export class LoginPage { ... } |
| readonly page: Page; | (removed) |
| constructor(page: Page) { ... } | constructor() { ... } (params removed) |
| this.page = page; | (removed) |
| this.user = page.locator('#u') | this.user = cy.get('#u') |
| async login(u: string) { ... } | login(u) { ... } (async & types stripped) |
| await this.user.fill(u) | this.user.clear().type(u) |
| await this.user.click() | this.user.click() |
| import { ... } from './utils.spec' | import { ... } from './utils.cy' (links updated) |
Navigation
| Playwright | Cypress |
|---|---|
| await page.goto(url) | cy.visit(url) |
| await page.reload() | cy.reload() |
| await page.goBack() | cy.go('back') |
| await page.goForward() | cy.go('forward') |
| await page.screenshot() | cy.screenshot() |
| await page.url() | cy.url() |
| await page.title() | cy.title() |
| await page.content() | cy.document().invoke('documentElement.outerHTML') |
Locators
| Playwright | Cypress (useCompatLayer=true) | Cypress (raw) |
|---|---|---|
| page.locator(sel) | cy.get(sel) | cy.get(sel) |
| page.getByTestId('x') | cy.pwTestId('x') | cy.get('[data-testid="x"]') |
| page.getByRole('btn', {name:'S'}) | cy.pwRole('btn', {name:'S'}) | cy.contains('btn', 'S') |
| page.getByText('Hello') | cy.pwText('Hello') | cy.contains('Hello') |
| page.getByLabel('Email') | cy.pwLabel('Email') | cy.get('[aria-label="Email"]') |
| page.getByPlaceholder('x') | cy.pwPlaceholder('x') | cy.get('[placeholder="x"]') |
| page.getByAltText('x') | cy.pwAltText('x') | cy.get('[alt="x"]') |
| page.getByTitle('x') | cy.pwTitle('x') | cy.get('[title="x"]') |
| locator.first() | .first() | same |
| locator.last() | .last() | same |
| locator.nth(n) | .eq(n) | same |
| locator.filter({hasText:'x'}) | .contains('x') | same |
Actions
| Playwright | Cypress |
|---|---|
| await locator.click() | .click() |
| await locator.dblclick() | .dblclick() |
| await locator.fill('v') | .clear().type('v') |
| await locator.type('v') | .type('v') |
| await locator.press('Enter') | .type('{enter}') |
| await page.keyboard.type('v') | cy.get('body').type('v') |
| await locator.check() | .check() |
| await locator.uncheck() | .uncheck() |
| await locator.selectOption('x') | .select('x') |
| await locator.focus() | .focus() |
| await locator.clear() | .clear() |
| await locator.hover() | .trigger('mouseover') |
| await locator.scrollIntoViewIfNeeded() | .scrollIntoView() |
| await locator.screenshot() | .screenshot() |
Assertions
| Playwright | Cypress |
|---|---|
| await expect(loc).toBeVisible() | cy.get(...).should('be.visible') |
| await expect(loc).toBeHidden() | .should('not.be.visible') |
| await expect(loc).toBeEnabled() | .should('be.enabled') |
| await expect(loc).toBeDisabled() | .should('be.disabled') |
| await expect(loc).toBeChecked() | .should('be.checked') |
| await expect(loc).not.toBeChecked() | .should('not.be.checked') |
| await expect(loc).toHaveText('x') | .should('have.text', 'x') |
| await expect(loc).toContainText('x') | .should('contain.text', 'x') |
| await expect(loc).toHaveValue('x') | .should('have.value', 'x') |
| await expect(loc).toHaveAttribute('a','v') | .should('have.attr', 'a', 'v') |
| await expect(loc).toHaveCount(n) | .should('have.length', n) |
| await expect(page).toHaveURL(/re/) | cy.url().should('match', /re/) |
| await expect(page).toHaveURL('str') | cy.url().should('eq', 'str') |
| await expect(page).toHaveTitle('t') | cy.title().should('include', 't') |
Waits
| Playwright | Cypress |
|---|---|
| await page.waitForURL(url) | cy.url().should('include', url) |
| await page.waitForSelector(sel) | cy.get(sel).should('exist') |
| await page.waitForTimeout(ms) | cy.wait(ms) |
Compat Layer (pw-compat.ts)
The compat layer (cypress/support/pw-compat.ts) adds custom commands that
keep converted specs readable without raw CSS selectors everywhere:
cy.pwTestId('submit-btn') // → [data-testid="submit-btn"]
cy.pwRole('button', { name: 'Save' }) // → [role="button"]:contains('Save')
cy.pwText('Welcome back') // → cy.contains('Welcome back')
cy.pwLocator('.my-class') // → cy.get('.my-class')The testIdAttribute can be customised via Cypress.env('testIdAttribute').
Migration Report
After every convert run, two reports are generated:
pw2cy-report.json — machine-readable:
{
"generatedAt": "2024-01-15T12:00:00.000Z",
"pw2cyVersion": "1.0.0",
"totalFiles": 12,
"totalConverted": 143,
"totalTodos": 7,
"averageConfidence": 94,
"files": [...]
}pw2cy-report.html — sortable HTML table with:
- Per-file confidence score (0–100%)
- Converted nodes count
- TODO count per file
- List of unsupported API calls
- Dark-theme UI, sortable columns
Confidence score = converted / (converted + todos) * 100.
Limitations & How the Compat Layer Helps
| Limitation | Workaround |
|---|---|
| page.route() — no direct equivalent | Use cy.intercept() — TODO comment with guidance |
| page.waitForLoadState('networkidle') | Cypress auto-waits; TODO comment inserted |
| page.evaluate() | Use cy.window().then(win => ...) — TODO comment |
| Complex locator chains | pw-compat layer keeps them clean; review .filter() TODOs |
| Playwright fixtures (test.use, test.extend) | Must be refactored to Cypress plugins — TODO emitted |
| ARIA role semantics | cy.pwRole uses [role=x] and :contains(name) — not a full ARIA tree |
Development
git clone https://github.com/your-org/pw2cy
cd pw2cy
npm install
npm run build
npm testRunning from source
npm run dev -- init
npm run dev -- convert ./tests/samples --out /tmp/cypress-out --verboseProject structure
pw2cy/
├── src/
│ ├── cli.ts # Commander CLI entrypoint
│ ├── index.ts # Programmatic API
│ ├── commands/
│ │ ├── init.ts # pw2cy init
│ │ ├── convert.ts # pw2cy convert
│ │ └── report.ts # pw2cy report
│ ├── core/
│ │ ├── config.ts # Zod schema + loader
│ │ ├── parser.ts # Babel parser wrapper
│ │ ├── transform.ts # AST transformer (main logic)
│ │ ├── mappings.ts # Declarative conversion tables
│ │ └── report.ts # JSON + HTML report generators
│ └── utils/
│ ├── fs.ts # File system helpers
│ └── log.ts # Chalk logger
├── templates/
│ ├── pw2cy.config.json # Default config template
│ └── pw-compat.ts # Cypress compat layer template
├── tests/
│ ├── transform.test.ts # 35+ unit + snapshot tests
│ └── samples/ # 5 Playwright input samples
│ ├── login.spec.ts
│ ├── products.spec.ts
│ ├── form.spec.ts
│ ├── api.spec.ts
│ └── navigation.spec.ts
└── scripts/
└── postbuild.js # Adds shebang + copies templatesContributing
We welcome contributions! To set up pw2cy for development:
- Fork and Clone the repository.
- Install Dependencies:
npm install - Build and Test:
npm run build npm test - Local Testing (Global Link):
To test your changes against a real Playwright project, link your local version:
# In the pw2cy root npm link # Now, in any other folder on your machine, 'pw2cy' will use your code pw2cy convert ./some-tests --out ./here - Create a Branch:
git checkout -b feat/your-awesome-feature - Submit a PR with a clear description and tests.
Adding a new conversion rule
- Add the mapping to
src/core/mappings.ts - Handle the transformation in
src/core/transform.ts - Add a unit test in
tests/transform.test.ts - Update the supported conversions table in README
Roadmap (v2)
| Feature | Status |
|---|---|
| POM (Page Object Model) file conversion | ✅ Supported (v1.0.4) |
| Smart Import Path Adjustment (.spec -> .cy) | ✅ Supported (v1.0.4) |
| cy.intercept() full pattern for page.route() | Planned |
| Playwright fixtures → Cypress fixtures/plugins | Planned |
| page.evaluate() → cy.window().then() | Planned |
| GitHub Action for CI migration validation | Planned |
| Config JSON schema file for IDE autocompletion | Planned |
| --watch mode for incremental conversion | Planned |
| Vue/React component test support | Research |
| @playwright/experimental-ct-* support | Research |
License
MIT © pw2cy contributors
