Appearance
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
@core/e2e-common-utilstays untouched — the existing Cypress shared library is not modified. A new@core/e2e-common-playwrightlibrary is created alongside it.- Faithful 1:1 test translation — all existing test cases, assertions, and flows are preserved exactly. Same coverage, same behavior, just Playwright syntax.
webServeroption — Playwright config auto-starts the dev server viapnpx 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
| File | Source | Description |
|---|---|---|
auth.ts | recruitee-e2e/src/auth/auth.ts | AccountSetup class — login, prepareAccount, resolveCompanyId, waitForAppReady, switchUser, dismissFeatureDiscovery, buildDomainCookies |
auth-api.ts | recruitee-e2e/src/auth/auth-api.ts | API functions — createE2EUsers, createOrganization, waitForApps, inviteMembersWithRoles, updateAbilities, confirmInvitations, fetchCompanyId, setSubscription, waitForCompanyReady |
auth-helpers.ts | recruitee-e2e/src/auth/auth-helpers.ts | DEFAULT_ABILITIES, ROLE_NAME_MAP, groupBy, findRoleExternalId |
account-factory.ts | recruitee-e2e/src/auth/account-factory.ts | createOrgMember, createAccountConfig, createProfileConfig, AccountProfile |
session-cache.ts | recruitee-e2e/src/auth/session-cache.ts | In-memory per-worker session cache (CachedSession) |
types.ts | recruitee-e2e/src/auth/types.ts | User, Role, AppsForOrganization, Subscription, OrganizationConfig, App, AppRole, AddMemberToAppResponse, AccountState — locally declared, not imported from Angular libs |
lib/fixtures/ — Playwright test fixtures
| File | Source | Description |
|---|---|---|
base.fixture.ts | recruitee-e2e/src/fixtures/base.fixture.ts | Custom test fixtures (accountSetup, provisionOrg, auth) with pool/shareOrg support |
session.ts | recruitee-e2e/src/fixtures/session.ts | AuthenticatedSessionImpl — openApp, switchUser, pageModel caching |
types.ts | recruitee-e2e/src/fixtures/types.ts | ProvisionedOrg, AuthenticatedSession, AppSession, ProvisionedUser, AuthOptions |
lib/pool/ — Org pool pre-provisioning
| File | Source | Description |
|---|---|---|
org-pool.ts | recruitee-e2e/src/pool/org-pool.ts | File-based pool with cross-worker locking |
org-cache.ts | recruitee-e2e/src/pool/org-cache.ts | Per-spec-file org cache (shareOrg) |
pool-provisioner.ts | recruitee-e2e/src/pool/pool-provisioner.ts | Background org provisioning (seed + drip-feed) |
shard-profiles.ts | recruitee-e2e/src/pool/shard-profiles.ts | Static analysis: which profiles does each shard need |
lib/helpers/ — Shared utilities
| File | Source | Description |
|---|---|---|
debug.ts | recruitee-e2e/src/helpers/debug.ts | Debug logging with per-worker JSONL files |
env.ts | recruitee-e2e/src/helpers/env.ts | Environment variable access (E2E_ENVIRONMENT, CI, PW_POOL, SLOW_MO, etc.) |
selectors.ts | recruitee-e2e/src/helpers/selectors.ts | dataCy() selector helper |
i18n.ts | recruitee-e2e/src/helpers/i18n.ts | Parameterized — createTranslator(paths) so each app passes its own translation files |
lib/environments/ — Base environment interface
| File | Description |
|---|---|
interface.ts | E2EBaseEnvironment — 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
| Concern | Stays in each e2e project |
|---|---|
playwright.config.ts | Different baseURLs, ports, webServer commands |
| Environment files | Different URLs, credentials, domains |
| i18n translation paths | Different JSON files per app |
| Page models | App-specific UI components |
| Test specs | App-specific test cases |
| API clients | recruitee-e2e has 20+ API classes — app-specific |
global-setup.ts / global-teardown.ts | May 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.tstsconfig.e2e.jsonglobal.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 supportprovisionOrg— returnsProvisionedOrgwith.membersarrayauth— returnsAuthenticatedSessionwithopenApp(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 changesenvironment.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
| Cypress | Playwright |
|---|---|
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← fromsrc/harnesses/add-member-dialog.harness.tssrc/models/app-switcher.page-model.ts← fromsrc/harnesses/app-switcher.tssrc/models/current-user-profile-dialog.page-model.ts← fromsrc/harnesses/current-user-profile-dialog.harness.tssrc/models/profile-dropdown.page-model.ts← fromsrc/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← fromadd-member-dialog.cy.tssrc/e2e/app-switcher.spec.ts← fromapp-switcher.cy.tssrc/e2e/bootstrap.spec.ts← frombootstrap.cy.tssrc/e2e/current-user-profile-dialog.spec.ts← fromcurrent-user-profile-dialog.cy.tssrc/e2e/profile-dropdown.spec.ts← fromprofile-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.tstsconfig.e2e.jsonglobal.d.tssrc/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
| File | Description |
|---|---|
playwright.config.ts | Playwright config with webServer for auto dev-server |
src/fixtures/base.fixture.ts | Re-exports shared fixtures |
src/helpers/i18n.ts | createTranslator() with tellent translation paths |
src/helpers/consts.ts | ROOT_DIR, PROJECT_DIR |
src/helpers/helpers.ts | UISelector map, uuid() (cleaned of Cypress specifics) |
src/models/*.page-model.ts | 4 page models (from harnesses) |
src/e2e/*.spec.ts | 5 spec files (1:1 from .cy.ts) |
Modified
| File | Change |
|---|---|
tsconfig.base.json (root) | Add @core/e2e-common-playwright path mapping |
tellent-client-e2e/project.json | Cypress → Playwright executor, remove serve target |
tellent-client-e2e/tsconfig.json | Cypress → Node types |
tellent-client-e2e/eslint.config.cjs | Update patterns |
tellent-client-e2e/src/environments/environment.ts | Remove Cypress.env() getters |
tellent-client-e2e/src/environments/interface.ts | Extend E2EBaseEnvironment |
package.json (root) | Add npm scripts |
Deleted (tellent-client-e2e)
| File | Reason |
|---|---|
cypress.config.ts | Replaced by playwright.config.ts |
tsconfig.e2e.json | Not needed for Playwright |
global.d.ts | Not 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.ts | Now imported from shared library |
cypress/ directory | Cypress downloads folder |
NOT modified
@core/e2e-common-util— Cypress shared library stays untouched@core/e2e-common-types— shared types library stays untouchedrecruitee-e2e— keeps its local files (refactoring to use shared lib is a follow-up)
Verification
- Dry run (check config + webServer):
E2E_ENVIRONMENT=local-staging npx nx e2e tellent-client-e2e --headed— should auto-start dev server on port 3007 - Run against staging:
E2E_ENVIRONMENT=staging npx nx e2e tellent-client-e2e - Run individual spec:
E2E_ENVIRONMENT=local-staging npx nx e2e tellent-client-e2e -- --grep "Add Member Dialog" - Verify all 5 spec files pass with equivalent coverage to the Cypress tests