Skip to content

Plan: Rewrite tellent-client-e2e from Cypress to Playwright

Context

The tellent-client-e2e project (domains/tellent/apps/tellent-client-e2e/) currently uses Cypress for E2E testing. The recruitee-e2e project has already migrated to Playwright with a well-established infrastructure (fixtures, auth, page models). This migration brings tellent-client-e2e in line with that pattern — Playwright is faster, has better shadow DOM support, and provides a more modern testing experience.

The project is small (5 spec files, ~91 tests, 4 harnesses) making it a clean migration target.

Key constraints

  1. @core/e2e-common-util stays untouched — the existing Cypress shared library is not modified. A new @core/e2e-common-playwright library is created alongside it.
  2. Faithful 1:1 test translation — all existing test cases, assertions, and flows are preserved exactly. Same coverage, same behavior, just Playwright syntax.
  3. webServer option — Playwright config auto-starts the dev server via pnpx nx serve tellent-client.

Import safety: type-only vs runtime imports

The current Cypress tests import enums from @core/tellent-types (AppKind, TellentDevFlag, TellentOrgAbilities) and @core/e2e-common-types (Role, AppsForOrganization). TypeScript enums are runtime code.

The Angular-specific imports (@core/i18n-legacy, @core/common-legacy, @core/tellent-i18n-legacy) are only in src/support/helpers/i18n.ts — this file gets deleted as part of the migration.

The @core/e2e-common-playwright shared library follows recruitee-e2e's pattern: it re-declares all types and enums locally in its own auth/types.ts rather than importing from Angular-compiled libraries. This ensures everything runs cleanly in a Node.js/Playwright environment.

For spec files: all imports from @core/tellent-types or @core/e2e-common-types should be replaced with imports from @core/e2e-common-playwright. Where enums like AppKind or TellentDevFlag are needed as values, they must be re-exported from the shared library or declared locally. Use import type wherever the import is only used for type annotations.


Step 0: Create @core/e2e-common-playwright shared library

Extract shared Playwright E2E infrastructure into libs/e2e-common/playwright/ (import path: @core/e2e-common-playwright), created alongside the existing @core/e2e-common-util (Cypress) and @core/e2e-common-types libraries. The Cypress library is not modified.

What goes into the shared library

The following modules are extracted from recruitee-e2e into libs/e2e-common/playwright/lib/:

lib/auth/ — Authentication & org provisioning

FileSourceDescription
auth.tsrecruitee-e2e/src/auth/auth.tsAccountSetup class — login, prepareAccount, resolveCompanyId, waitForAppReady, switchUser, dismissFeatureDiscovery, buildDomainCookies
auth-api.tsrecruitee-e2e/src/auth/auth-api.tsAPI functions — createE2EUsers, createOrganization, waitForApps, inviteMembersWithRoles, updateAbilities, confirmInvitations, fetchCompanyId, setSubscription, waitForCompanyReady
auth-helpers.tsrecruitee-e2e/src/auth/auth-helpers.tsDEFAULT_ABILITIES, ROLE_NAME_MAP, groupBy, findRoleExternalId
account-factory.tsrecruitee-e2e/src/auth/account-factory.tscreateOrgMember, createAccountConfig, createProfileConfig, AccountProfile
session-cache.tsrecruitee-e2e/src/auth/session-cache.tsIn-memory per-worker session cache (CachedSession)
types.tsrecruitee-e2e/src/auth/types.tsUser, Role, AppsForOrganization, Subscription, OrganizationConfig, App, AppRole, AddMemberToAppResponse, AccountState — locally declared, not imported from Angular libs

lib/fixtures/ — Playwright test fixtures

FileSourceDescription
base.fixture.tsrecruitee-e2e/src/fixtures/base.fixture.tsCustom test fixtures (accountSetup, provisionOrg, auth) with pool/shareOrg support
session.tsrecruitee-e2e/src/fixtures/session.tsAuthenticatedSessionImpl — openApp, switchUser, pageModel caching
types.tsrecruitee-e2e/src/fixtures/types.tsProvisionedOrg, AuthenticatedSession, AppSession, ProvisionedUser, AuthOptions

lib/pool/ — Org pool pre-provisioning

FileSourceDescription
org-pool.tsrecruitee-e2e/src/pool/org-pool.tsFile-based pool with cross-worker locking
org-cache.tsrecruitee-e2e/src/pool/org-cache.tsPer-spec-file org cache (shareOrg)
pool-provisioner.tsrecruitee-e2e/src/pool/pool-provisioner.tsBackground org provisioning (seed + drip-feed)
shard-profiles.tsrecruitee-e2e/src/pool/shard-profiles.tsStatic analysis: which profiles does each shard need

lib/helpers/ — Shared utilities

FileSourceDescription
debug.tsrecruitee-e2e/src/helpers/debug.tsDebug logging with per-worker JSONL files
env.tsrecruitee-e2e/src/helpers/env.tsEnvironment variable access (E2E_ENVIRONMENT, CI, PW_POOL, SLOW_MO, etc.)
selectors.tsrecruitee-e2e/src/helpers/selectors.tsdataCy() selector helper
i18n.tsrecruitee-e2e/src/helpers/i18n.tsParameterized — createTranslator(paths) so each app passes its own translation files

lib/environments/ — Base environment interface

FileDescription
interface.tsE2EBaseEnvironment — base interface with fields required by auth infrastructure

Base environment interface

typescript
export interface E2EBaseEnvironment {
  baseUrl: string;
  apiUrlTellent: string;
  e2eApiUrl: string;
  appAuthUrlTellent: string;
  basicAuthUser: string;
  basicAuthPassword: string;
  basicAuthUserRecruitee: string;
  basicAuthPasswordRecruitee: string;
  mainOrgGuid: string;
  mainUserGuid: string;
  domain: string;
  domainRecruitee?: string;
  domainTellent?: string;
}

i18n parameterization

The shared i18n.ts accepts translation file paths as a parameter:

typescript
export function createTranslator(translationFiles: string[]): {
  translate: (key: string, params?: Record<string, string | number>) => string;
  i18nDef: <T extends I18nDef>(obj: T) => T;
}

What stays app-specific

ConcernStays in each e2e project
playwright.config.tsDifferent baseURLs, ports, webServer commands
Environment filesDifferent URLs, credentials, domains
i18n translation pathsDifferent JSON files per app
Page modelsApp-specific UI components
Test specsApp-specific test cases
API clientsrecruitee-e2e has 20+ API classes — app-specific
global-setup.ts / global-teardown.tsMay differ per app

Library setup files

libs/e2e-common/playwright/
├── index.ts              — public API barrel export
├── package.json          — name: "@core/e2e-common-playwright"
├── tsconfig.json
├── tsconfig.lib.json
└── lib/
    ├── auth/
    ├── fixtures/
    ├── pool/
    ├── helpers/
    └── environments/

tsconfig.base.json (root) — add path mapping:

json
"@core/e2e-common-playwright": ["libs/e2e-common/playwright/index"]

Refactoring recruitee-e2e (separate follow-up)

After the shared library is stable, update recruitee-e2e to import from @core/e2e-common-playwright instead of its local copies. This is a separate task.


Step 1: Replace configuration files

Delete Cypress-specific files

  • cypress.config.ts
  • tsconfig.e2e.json
  • global.d.ts

Create playwright.config.ts

Follow product-updates-e2e pattern for webServer, plus recruitee-e2e pattern for environment handling:

typescript
import { defineConfig, devices } from '@playwright/test';

const envName = process.env['E2E_ENVIRONMENT'] || 'local-staging';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { environment } = require(`./src/environments/environment.${envName}`);

const CI = !!process.env['CI'];

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.spec.ts',
  timeout: 60_000,
  expect: { timeout: 8_000 },
  fullyParallel: false,
  forbidOnly: CI,
  retries: CI ? 1 : 0,
  workers: CI ? 1 : undefined,
  reporter: CI
    ? [['blob', { outputDir: './blob-report' }], ['playwright-ctrf-json-reporter', { outputDir: './ctrf' }]]
    : [['html'], ['junit', { outputFile: 'results/test-results.xml' }]],
  use: {
    baseURL: environment.baseUrl,
    viewport: { width: 1920, height: 1080 },
    actionTimeout: 8_000,
    trace: CI ? 'on-first-retry' : 'retain-on-failure',
    ignoreHTTPSErrors: true,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } },
    },
  ],
  webServer: {
    command: 'pnpx nx serve tellent-client',
    url: 'https://app.tellent.internal:3007/',
    reuseExistingServer: !CI,
    ignoreHTTPSErrors: true,
    cwd: process.cwd(),
    timeout: 180_000,
  },
});

Update project.json

Replace Cypress executor with Playwright:

json
{
  "name": "tellent-client-e2e",
  "projectType": "application",
  "sourceRoot": "domains/tellent/apps/tellent-client-e2e/src",
  "tags": ["type:e2e"],
  "implicitDependencies": ["tellent-client"],
  "targets": {
    "e2e": {
      "executor": "@nx/playwright:playwright",
      "outputs": ["{workspaceRoot}/playwright-report"],
      "options": {
        "config": "domains/tellent/apps/tellent-client-e2e/playwright.config.ts"
      }
    },
    "lint": { ... keep existing ... }
  }
}

Remove the serve target (Playwright webServer handles dev server startup).

Update tsconfig.json

Replace Cypress types with Node:

json
{
  "extends": "../../../../tsconfig.strict.json",
  "compilerOptions": {
    "types": ["node"],
    "typeRoots": ["../../../../node_modules"],
    "allowJs": false,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "sourceMap": false
  },
  "include": ["**/*.ts"]
}

Update eslint.config.cjs

Adjust lint patterns from .cy.ts to .spec.ts.


Step 2: Wire up fixtures (from shared library)

src/fixtures/base.fixture.ts

Re-export or extend the shared fixtures:

typescript
import { test as base, expect } from '@core/e2e-common-playwright';

export const test = base.extend<{
  // App-specific fixtures can be added here if needed
}>({});

export { expect };

The shared fixtures provide:

  • accountSetup — callable (config, options?) => Promise<OrganizationConfig> with pool/shareOrg support
  • provisionOrg — returns ProvisionedOrg with .members array
  • auth — returns AuthenticatedSession with openApp(page, url?), switchUser(), pageModel<T>()

Step 3: Create app-specific helpers

src/helpers/i18n.ts

typescript
import { createTranslator } from '@core/e2e-common-playwright';

const { translate, i18nDef } = createTranslator([
  'libs/tellent-i18n/legacy/locales/en.json',
  'libs/translations-webapp/legacy/assets/en.json',
]);

export { translate, i18nDef };

src/helpers/consts.ts

typescript
import path from 'path';

export const ROOT_DIR = path.resolve(__dirname, '../../../../../../');
export const PROJECT_DIR = path.join(ROOT_DIR, 'domains/tellent/apps/tellent-client-e2e');

src/helpers/helpers.ts

Keep UISelector map and uuid(). Remove Cypress-specific helpers (getTokenFromLogin).


Step 4: Adapt environment configs

src/environments/interface.ts

Extend E2EBaseEnvironment from the shared library (add setupUrl, tcEnvTarget, etc.).

src/environments/environment.ts

Replace Cypress.env() getters with process.env-based dynamic import:

typescript
const envName = process.env['E2E_ENVIRONMENT'] || 'local-staging';
const { environment } = require(`./environment.${envName}`);
export { environment };

Keep environment files

  • environment.local-staging.ts — no changes (already plain objects)
  • environment.staging.ts — no changes
  • environment.rc.ts — no changes

Step 5: Convert harnesses to page models (1:1 faithful translation)

Each Cypress harness → Playwright page model class. Same methods, same selectors, same behavior.

Key conversion patterns

CypressPlaywright
cy.get(sel, {includeShadowDom: true})page.locator(sel) (pierces shadow DOM by default)
.should('be.visible')await expect(locator).toBeVisible()
.should('not.exist')await expect(locator).not.toBeAttached()
.should('have.class', 'disabled')await expect(locator).toHaveClass(/disabled/)
.clear().type(text)await locator.fill(text)
.click()await locator.click()
cy.contains(text, {includeShadowDom: true})page.getByText(text) or locator.filter({hasText: text})
.find(sel)locator.locator(sel)
.eq(index)locator.nth(index)
.invoke('val')await locator.inputValue()
cy.wait(1000)Remove — use proper wait patterns
cy.intercept('POST', pat).as('alias')page.waitForResponse(r => ...)
cy.wait('@alias')const response = await responsePromise
element.invoke('attr', 'open', 'true')await element.evaluate(el => el.setAttribute('open', 'true'))

Page models to create

  • src/models/add-member-dialog.page-model.ts ← from src/harnesses/add-member-dialog.harness.ts
  • src/models/app-switcher.page-model.ts ← from src/harnesses/app-switcher.ts
  • src/models/current-user-profile-dialog.page-model.ts ← from src/harnesses/current-user-profile-dialog.harness.ts
  • src/models/profile-dropdown.page-model.ts ← from src/harnesses/profile-dropdown.harness.ts

All converted from static Cypress harness methods to instance-based Playwright page models taking Page in constructor.


Step 6: Convert test specs (1:1 faithful translation)

Rename .cy.ts.spec.ts and rewrite using Playwright syntax. Same test cases, same assertions, same flows — no additions or removals.

All imports from @core/tellent-types or @core/e2e-common-types are replaced with imports from @core/e2e-common-playwright (which re-declares types/enums locally). Use import type where the import is only used for type annotations.

Specs to convert

  • src/e2e/add-member-dialog.spec.ts ← from add-member-dialog.cy.ts
  • src/e2e/app-switcher.spec.ts ← from app-switcher.cy.ts
  • src/e2e/bootstrap.spec.ts ← from bootstrap.cy.ts
  • src/e2e/current-user-profile-dialog.spec.ts ← from current-user-profile-dialog.cy.ts
  • src/e2e/profile-dropdown.spec.ts ← from profile-dropdown.cy.ts

Spec pattern (pool-compatible)

typescript
import { test, expect } from '../fixtures/base.fixture';
import { createAccountConfig, createOrgMember, AppsForOrganization } from '@core/e2e-common-playwright';
import type { ProvisionedOrg } from '@core/e2e-common-playwright';

let admin = createOrgMember();
const accountConfig = createAccountConfig({
  apps: [AppsForOrganization.Recruitment, AppsForOrganization.CoreHR],
  members: [admin],
});

test.describe('Feature Name', () => {
  let org: ProvisionedOrg;

  test.beforeAll(async ({ provisionOrg }) => {
    org = await provisionOrg(accountConfig, { shareOrg: true });
    admin = org.members[0];
  });

  test.beforeEach(async ({ auth, page }) => {
    const session = await auth(admin);
    await session.openApp(page);
  });

  test('test case name', async ({ page }) => {
    // Playwright assertions matching original Cypress test exactly
  });
});

Step 7: Delete old Cypress files

  • src/support/ (entire directory)
  • src/harnesses/ (entire directory)
  • src/e2e/*.cy.ts (replaced by *.spec.ts)
  • cypress.config.ts
  • tsconfig.e2e.json
  • global.d.ts
  • src/account-factory.ts (now imported from shared library)
  • cypress/ directory

Step 8: Add npm scripts to root package.json

json
"start:tellent-client-e2e:staging": "E2E_ENVIRONMENT=staging npx nx e2e tellent-client-e2e",
"start:tellent-client-e2e:local-staging": "E2E_ENVIRONMENT=local-staging npx nx e2e tellent-client-e2e"

Files summary

New: libs/e2e-common/playwright/

Shared Playwright E2E library extracted from recruitee-e2e (see Step 0 for full list).

New in tellent-client-e2e

FileDescription
playwright.config.tsPlaywright config with webServer for auto dev-server
src/fixtures/base.fixture.tsRe-exports shared fixtures
src/helpers/i18n.tscreateTranslator() with tellent translation paths
src/helpers/consts.tsROOT_DIR, PROJECT_DIR
src/helpers/helpers.tsUISelector map, uuid() (cleaned of Cypress specifics)
src/models/*.page-model.ts4 page models (from harnesses)
src/e2e/*.spec.ts5 spec files (1:1 from .cy.ts)

Modified

FileChange
tsconfig.base.json (root)Add @core/e2e-common-playwright path mapping
tellent-client-e2e/project.jsonCypress → Playwright executor, remove serve target
tellent-client-e2e/tsconfig.jsonCypress → Node types
tellent-client-e2e/eslint.config.cjsUpdate patterns
tellent-client-e2e/src/environments/environment.tsRemove Cypress.env() getters
tellent-client-e2e/src/environments/interface.tsExtend E2EBaseEnvironment
package.json (root)Add npm scripts

Deleted (tellent-client-e2e)

FileReason
cypress.config.tsReplaced by playwright.config.ts
tsconfig.e2e.jsonNot needed for Playwright
global.d.tsNot needed for Playwright
src/support/ (all)Cypress-specific infrastructure
src/harnesses/ (all)Replaced by src/models/
src/e2e/*.cy.ts (all)Replaced by .spec.ts files
src/account-factory.tsNow imported from shared library
cypress/ directoryCypress downloads folder

NOT modified

  • @core/e2e-common-util — Cypress shared library stays untouched
  • @core/e2e-common-types — shared types library stays untouched
  • recruitee-e2e — keeps its local files (refactoring to use shared lib is a follow-up)

Verification

  1. Dry run (check config + webServer): E2E_ENVIRONMENT=local-staging npx nx e2e tellent-client-e2e --headed — should auto-start dev server on port 3007
  2. Run against staging: E2E_ENVIRONMENT=staging npx nx e2e tellent-client-e2e
  3. Run individual spec: E2E_ENVIRONMENT=local-staging npx nx e2e tellent-client-e2e -- --grep "Add Member Dialog"
  4. Verify all 5 spec files pass with equivalent coverage to the Cypress tests