chore: heygermany customer project
This commit is contained in:
39
e2e/README.md
Normal file
39
e2e/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# E2E Test Harness
|
||||
|
||||
Cucumber + Playwright smoke tests for HeyGermany, based on the Seminarhof setup.
|
||||
|
||||
## Running
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
yarn e2e
|
||||
```
|
||||
|
||||
Headed mode:
|
||||
|
||||
```sh
|
||||
yarn e2e:headed
|
||||
```
|
||||
|
||||
By default the harness starts:
|
||||
|
||||
- `pikku dev --port 3000`
|
||||
- `yarn workspace @heygermany/website dev --port 3001`
|
||||
|
||||
You can also run the servers yourself and disable harness management:
|
||||
|
||||
```sh
|
||||
APP_URL=http://localhost:3001 \
|
||||
API_URL=http://localhost:3000 \
|
||||
yarn workspace @heygermany/e2e test:tag '@smoke'
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
The initial suite is intentionally basic:
|
||||
|
||||
- public page smoke coverage
|
||||
- login page rendering and client-side validation
|
||||
|
||||
This gives the repo a stable baseline before adding seeded auth flows or deeper application journeys.
|
||||
20
e2e/package.json
Normal file
20
e2e/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@heygermany/e2e",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "End-to-end smoke tests for HeyGermany.",
|
||||
"scripts": {
|
||||
"test": "E2E_MANAGE_SERVERS=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:headed": "HEADED=1 E2E_MANAGE_SERVERS=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@types/node": "^22",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
7
e2e/tests/cucumber.mjs
Normal file
7
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
114
e2e/tests/features/auth-pages.feature
Normal file
114
e2e/tests/features/auth-pages.feature
Normal file
@@ -0,0 +1,114 @@
|
||||
@smoke @auth
|
||||
Feature: Auth pages
|
||||
Login entry points should render their forms and support real session-based login.
|
||||
|
||||
Scenario: company login page renders
|
||||
Given I visit "/en/company/auth/login"
|
||||
Then I see "Login to Company Portal"
|
||||
And I see "Email address"
|
||||
And I see "Password"
|
||||
And I see "Login"
|
||||
|
||||
Scenario: company login validates required fields
|
||||
Given I visit "/en/company/auth/login"
|
||||
When I click "Login"
|
||||
Then I see "Email is required"
|
||||
And I see "Password is required"
|
||||
|
||||
Scenario: company user can log in with real credentials
|
||||
Given I visit "/en/company/auth/login"
|
||||
When I log in as "e2e-company-login@heygermany.test" with password "CompanyPass123!"
|
||||
Then the URL contains "/company"
|
||||
And I see "Find qualified international nurses for German healthcare"
|
||||
|
||||
Scenario: company candidates requires authentication
|
||||
Given I visit "/en/company/candidates"
|
||||
Then the URL contains "/en/company/auth/login"
|
||||
And I see "Login to Company Portal"
|
||||
|
||||
Scenario: company candidate detail requires authentication
|
||||
Given I visit "/en/company/candidate/test-candidate"
|
||||
Then the URL contains "/en/company/auth/login"
|
||||
And I see "Login to Company Portal"
|
||||
|
||||
Scenario: backoffice login page renders
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
Then I see "Email address"
|
||||
And I see "Password"
|
||||
And I see "Login"
|
||||
And the URL contains "/backoffice/auth/login"
|
||||
|
||||
Scenario: backoffice user can log in with real credentials
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
Then the URL contains "/backoffice/candidates"
|
||||
And I see "Candidate Pool"
|
||||
|
||||
Scenario: backoffice candidates requires authentication
|
||||
Given I visit "/en/backoffice/candidates"
|
||||
Then the URL contains "/en/backoffice/auth/login"
|
||||
And I see "Login"
|
||||
|
||||
Scenario: backoffice candidate detail requires authentication
|
||||
Given I visit "/en/backoffice/candidate/test-candidate"
|
||||
Then the URL contains "/en/backoffice/auth/login"
|
||||
And I see "Login"
|
||||
|
||||
Scenario: candidate application requires authentication
|
||||
Given I visit "/en/jobs/application"
|
||||
Then the URL contains "/en/jobs/apply"
|
||||
And I see "Who is This For?"
|
||||
|
||||
Scenario: logged-in company user is redirected away from company login
|
||||
Given I visit "/en/company/auth/login"
|
||||
When I log in as "e2e-company-login@heygermany.test" with password "CompanyPass123!"
|
||||
And I visit "/en/company/auth/login"
|
||||
Then the URL contains "/en/company/candidates"
|
||||
And I see "Find qualified international nurses for German healthcare"
|
||||
|
||||
Scenario: company root redirects to candidates after login
|
||||
Given I visit "/en/company/auth/login"
|
||||
When I log in as "e2e-company-login@heygermany.test" with password "CompanyPass123!"
|
||||
And I visit "/en/company"
|
||||
Then the URL contains "/en/company/candidates"
|
||||
|
||||
Scenario: company root requires authentication
|
||||
Given I visit "/en/company"
|
||||
Then the URL contains "/en/company/auth/login"
|
||||
And I see "Login to Company Portal"
|
||||
|
||||
Scenario: logged-in backoffice user is redirected away from backoffice login
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
And I visit "/en/backoffice/auth/login"
|
||||
Then the URL contains "/en/backoffice/candidates"
|
||||
And I see "Candidate Pool"
|
||||
|
||||
Scenario: backoffice dashboard redirects to candidates after login
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
And I visit "/en/backoffice/dashboard"
|
||||
Then the URL contains "/en/backoffice/candidates"
|
||||
|
||||
Scenario: backoffice root requires authentication
|
||||
Given I visit "/en/backoffice"
|
||||
Then the URL contains "/en/backoffice/auth/login"
|
||||
And I see "Login"
|
||||
|
||||
Scenario: company logout ends the protected session
|
||||
Given I visit "/en/company/auth/login"
|
||||
When I log in as "e2e-company-login@heygermany.test" with password "CompanyPass123!"
|
||||
And I log out
|
||||
And I wait for 3500 milliseconds
|
||||
And I visit "/en/company/candidates"
|
||||
Then the URL contains "/en/company/auth/login"
|
||||
And I see "Login to Company Portal"
|
||||
|
||||
Scenario: backoffice logout ends the protected session
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
And I log out
|
||||
And I wait for 3500 milliseconds
|
||||
And I visit "/en/backoffice/candidates"
|
||||
Then the URL contains "/en/backoffice/auth/login"
|
||||
And I see "Login"
|
||||
17
e2e/tests/features/backoffice-pages.feature
Normal file
17
e2e/tests/features/backoffice-pages.feature
Normal file
@@ -0,0 +1,17 @@
|
||||
@backoffice
|
||||
Feature: Backoffice tools
|
||||
Authenticated backoffice users should be able to reach the internal tools they use through the UI.
|
||||
|
||||
Scenario: backoffice translations page loads after login
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
And I visit "/en/backoffice/translations"
|
||||
Then I see "Backend translations"
|
||||
And I see "Save translation bundle"
|
||||
|
||||
Scenario: backoffice next steps guide loads after login
|
||||
Given I visit "/en/backoffice/auth/login"
|
||||
When I log in as "e2e-backoffice-login@heygermany.test" with password "BackofficePass123!"
|
||||
And I visit "/en/backoffice/help/nextsteps"
|
||||
Then I see "Translation System - CSV Guide"
|
||||
And I see "Understanding the CSV Structure"
|
||||
136
e2e/tests/features/multi-actor-workflows.feature
Normal file
136
e2e/tests/features/multi-actor-workflows.feature
Normal file
@@ -0,0 +1,136 @@
|
||||
@multi-actor
|
||||
Feature: Multi-actor candidate workflows
|
||||
Candidate journeys can span multiple roles and still stay coherent entirely through the UI.
|
||||
|
||||
Scenario: candidate can restart an application after backoffice invalidates it
|
||||
Given the "candidate" actor visits "/en/jobs/apply"
|
||||
When the "candidate" actor starts a new application
|
||||
Then the "candidate" actor URL contains "/jobs/application"
|
||||
|
||||
When the "admin" actor logs into backoffice
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Invalid"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation."
|
||||
|
||||
When the "candidate" actor clicks "Upload documents again"
|
||||
Then the "candidate" actor sees "Personal Info"
|
||||
And the "candidate" actor URL contains "/jobs/application"
|
||||
|
||||
Scenario: candidate sees the submitted state after backoffice marks the application pending
|
||||
Given the "candidate" actor visits "/en/jobs/apply"
|
||||
When the "candidate" actor starts a new application
|
||||
Then the "candidate" actor URL contains "/jobs/application"
|
||||
|
||||
When the "admin" actor logs into backoffice
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Pending"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Thanks! Your documents were uploaded successfully."
|
||||
And the "candidate" actor sees "We’ve got everything we need to start your personal evaluation"
|
||||
|
||||
Scenario: candidate result becomes visible to both the candidate and company after backoffice completes the review
|
||||
Given the "candidate" actor visits "/en/jobs/apply"
|
||||
When the "candidate" actor starts a new application
|
||||
And the "candidate" actor completes and submits the candidate application
|
||||
|
||||
When the "admin" actor logs into backoffice
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor sees "Manual Assessment"
|
||||
And the "admin" actor sets the backoffice field "Status In Home Country" to "Nurse"
|
||||
And the "admin" actor sets the backoffice field "Status In Germany" to "Assistant Nurse"
|
||||
And the "admin" actor sets the backoffice field "License Mandatory" to "No"
|
||||
And the "admin" actor sets the backoffice field "Duration Requirements Met" to "Yes"
|
||||
And the "admin" actor sets the verification field "Nursing Related Degree" to "Yes"
|
||||
And the "admin" actor sets the verification field "Accredited University" to "Yes"
|
||||
And the "admin" actor sets the verification field "Accredited Study Program" to "Yes"
|
||||
And the "admin" actor sets the verification field "Is Nursing Focus" to "Yes"
|
||||
And the "admin" actor sets the verification field "Has Theory" to "Yes"
|
||||
And the "admin" actor sets the verification field "Has Practice" to "Yes"
|
||||
And the "admin" actor sets the verification field "Duration Hours" to "4800"
|
||||
Then the "admin" actor sees "Valid"
|
||||
|
||||
When the "admin" actor marks the candidate as "Complete"
|
||||
And the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "A First Step Towards Your Nursing Career"
|
||||
And the "candidate" actor sees "You Qualify as a Nursing Assistant in Berlin"
|
||||
|
||||
When the "company" actor logs into company
|
||||
And the "company" actor opens the current candidate in company
|
||||
Then the "company" actor URL contains "/en/company/candidate/"
|
||||
And the "company" actor sees the current candidate name
|
||||
And the "company" actor sees "Philippines"
|
||||
|
||||
Scenario: candidate sees both the submitted and invalidated states across repeated admin reviews
|
||||
Given the "candidate" actor visits "/en/jobs/apply"
|
||||
When the "candidate" actor starts a new application
|
||||
And the "candidate" actor completes and submits the candidate application
|
||||
|
||||
When the "admin" actor logs into backoffice
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Pending"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Thanks! Your documents were uploaded successfully."
|
||||
And the "candidate" actor sees "We’ve got everything we need to start your personal evaluation"
|
||||
|
||||
When the "admin" actor visits "/en/backoffice/candidates"
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Invalid"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation."
|
||||
When the "candidate" actor clicks "Upload documents again"
|
||||
Then the "candidate" actor sees "Personal Info"
|
||||
And the "candidate" actor URL contains "/jobs/application"
|
||||
|
||||
Scenario: candidate can move from submission to invalidation to resubmission and final company visibility
|
||||
Given the "candidate" actor visits "/en/jobs/apply"
|
||||
When the "candidate" actor starts a new application
|
||||
And the "candidate" actor completes and submits the candidate application
|
||||
|
||||
When the "admin" actor logs into backoffice
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Pending"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Thanks! Your documents were uploaded successfully."
|
||||
And the "candidate" actor sees "We’ve got everything we need to start your personal evaluation"
|
||||
|
||||
When the "admin" actor visits "/en/backoffice/candidates"
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor marks the candidate as "Invalid"
|
||||
|
||||
When the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "Please upload a clear and complete version of the missing or invalid document(s) so we can continue your evaluation."
|
||||
When the "candidate" actor clicks "Upload documents again"
|
||||
And the "candidate" actor completes and submits the candidate application
|
||||
|
||||
When the "admin" actor visits "/en/backoffice/candidates"
|
||||
And the "admin" actor opens the current candidate in backoffice
|
||||
And the "admin" actor sees "Manual Assessment"
|
||||
And the "admin" actor sets the backoffice field "Status In Home Country" to "Nurse"
|
||||
And the "admin" actor sets the backoffice field "Status In Germany" to "Assistant Nurse"
|
||||
And the "admin" actor sets the backoffice field "License Mandatory" to "No"
|
||||
And the "admin" actor sets the backoffice field "Duration Requirements Met" to "Yes"
|
||||
And the "admin" actor sets the verification field "Nursing Related Degree" to "Yes"
|
||||
And the "admin" actor sets the verification field "Accredited University" to "Yes"
|
||||
And the "admin" actor sets the verification field "Accredited Study Program" to "Yes"
|
||||
And the "admin" actor sets the verification field "Is Nursing Focus" to "Yes"
|
||||
And the "admin" actor sets the verification field "Has Theory" to "Yes"
|
||||
And the "admin" actor sets the verification field "Has Practice" to "Yes"
|
||||
And the "admin" actor sets the verification field "Duration Hours" to "4800"
|
||||
Then the "admin" actor sees "Valid"
|
||||
|
||||
When the "admin" actor marks the candidate as "Complete"
|
||||
And the "candidate" actor revisits the current candidate application
|
||||
Then the "candidate" actor sees "A First Step Towards Your Nursing Career"
|
||||
And the "candidate" actor sees "You Qualify as a Nursing Assistant in Berlin"
|
||||
|
||||
When the "company" actor logs into company
|
||||
And the "company" actor opens the current candidate in company
|
||||
Then the "company" actor URL contains "/en/company/candidate/"
|
||||
And the "company" actor sees the current candidate name
|
||||
And the "company" actor sees "Philippines"
|
||||
64
e2e/tests/features/public-pages.feature
Normal file
64
e2e/tests/features/public-pages.feature
Normal file
@@ -0,0 +1,64 @@
|
||||
@smoke
|
||||
Feature: Public pages
|
||||
Public entry points should render without crashing and show their core content.
|
||||
|
||||
Scenario: localized home page loads
|
||||
Given I visit "/en"
|
||||
Then I see "Begin Your Nursing Career in Germany"
|
||||
And I see "Check My Qualification"
|
||||
|
||||
Scenario: about page loads
|
||||
Given I visit "/en/about"
|
||||
Then I see "About HeyGermany"
|
||||
And I see "Ready to Start Your Journey?"
|
||||
|
||||
Scenario: application page loads
|
||||
Given I visit "/en/jobs/apply"
|
||||
Then I see "Who is This For?"
|
||||
And I see "Get Your Results"
|
||||
|
||||
Scenario: result route redirects back to the application flow
|
||||
Given I visit "/en/jobs/result"
|
||||
Then the URL contains "/en/jobs/apply"
|
||||
And I see "Who is This For?"
|
||||
|
||||
Scenario: pilot landing page loads
|
||||
Given I visit "/pilot"
|
||||
Then I see "Pilot-Partner werden"
|
||||
And I see "Warum Pflegeeinrichtungen HeyGermany wählen"
|
||||
|
||||
Scenario: imprint page loads
|
||||
Given I visit "/en/legal/imprint"
|
||||
Then I see "Imprint"
|
||||
And I see "Information according to"
|
||||
|
||||
Scenario: privacy page loads
|
||||
Given I visit "/en/legal/privacy"
|
||||
Then I see "Privacy Policy"
|
||||
And I see "Data Controller"
|
||||
|
||||
Scenario: terms page loads
|
||||
Given I visit "/en/legal/terms-and-conditions"
|
||||
Then I see "Terms & Conditions"
|
||||
And I see "User Responsibilities"
|
||||
|
||||
Scenario: magic link page handles missing token
|
||||
Given I visit "/en/auth/magic-link"
|
||||
Then I see "Magic link failed"
|
||||
And I see "No magic link token provided."
|
||||
|
||||
Scenario: verify email success page loads
|
||||
Given I visit "/en/verify-email?verified=1"
|
||||
Then I see "Thank you for verifying your email!"
|
||||
And I see "Your account is now active and ready to use."
|
||||
|
||||
Scenario: verify email error page loads
|
||||
Given I visit "/en/verify-email?error=expired"
|
||||
Then I see "Email Verification Failed"
|
||||
And I see "Please try requesting a new verification email or contact support if the problem persists."
|
||||
|
||||
Scenario: demo result page loads
|
||||
Given I visit "/en/jobs/result/demo"
|
||||
Then I see "Good News Maria Santos"
|
||||
And I see "You May Qualify as a Nurse in Berlin"
|
||||
And I see "Get Help"
|
||||
789
e2e/tests/steps/common.steps.ts
Normal file
789
e2e/tests/steps/common.steps.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect, type Locator } from '@playwright/test'
|
||||
import { dirname, resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
const looksLikeCss = (s: string) =>
|
||||
s.startsWith('#') ||
|
||||
s.startsWith('.') ||
|
||||
s.startsWith('[') ||
|
||||
s.startsWith('input') ||
|
||||
s.includes('>') ||
|
||||
s.includes(':')
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../../..')
|
||||
const uploadFixturePath = resolve(repoRoot, 'apps/website/public/favicon-16x16.png')
|
||||
|
||||
async function field(world: AppWorld, name: string) {
|
||||
if (looksLikeCss(name)) return world.page.locator(name).first()
|
||||
const byId = world.page.locator(`[data-testid="${name}"]`).first()
|
||||
if (await byId.count()) {
|
||||
const inner = byId.locator('input, textarea, select').first()
|
||||
if (await inner.count()) return inner
|
||||
return byId
|
||||
}
|
||||
const byLabel = world.page.getByLabel(name, { exact: false })
|
||||
if (await byLabel.count()) return firstVisible(byLabel)
|
||||
const byPlaceholder = world.page.getByPlaceholder(name, { exact: false })
|
||||
if (await byPlaceholder.count()) return firstVisible(byPlaceholder)
|
||||
return world.page.getByText(name, { exact: false }).first()
|
||||
}
|
||||
|
||||
async function isVisibleWithin(
|
||||
target: ReturnType<AppWorld['page']['locator']>,
|
||||
timeout = 5_000,
|
||||
) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5_000) {
|
||||
try {
|
||||
await target.click({ timeout })
|
||||
return
|
||||
} catch {
|
||||
await target.evaluate((node) => {
|
||||
;(node as HTMLElement).click()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function firstVisible(target: ReturnType<AppWorld['page']['locator']>) {
|
||||
return await nthVisible(target, 0)
|
||||
}
|
||||
|
||||
async function nthVisible(
|
||||
target: ReturnType<AppWorld['page']['locator']>,
|
||||
visibleIndex: number,
|
||||
) {
|
||||
const count = await target.count()
|
||||
let seenVisible = 0
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const candidate = target.nth(index)
|
||||
if (await candidate.isVisible().catch(() => false)) {
|
||||
if (seenVisible === visibleIndex) {
|
||||
return candidate
|
||||
}
|
||||
seenVisible += 1
|
||||
}
|
||||
}
|
||||
return target.first()
|
||||
}
|
||||
|
||||
async function visibleOption(world: AppWorld, name: string, visibleIndex = 0) {
|
||||
return nthVisible(
|
||||
world.page.getByRole('option', {
|
||||
name: new RegExp(`^${escapeRegex(name)}$`, 'i'),
|
||||
}),
|
||||
visibleIndex,
|
||||
)
|
||||
}
|
||||
|
||||
async function settle(world: AppWorld) {
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
async function useActor(world: AppWorld, actor: string) {
|
||||
await world.useActor(actor)
|
||||
}
|
||||
|
||||
async function labeledComboboxControl(world: AppWorld, label: string) {
|
||||
const name = new RegExp(escapeRegex(label), 'i')
|
||||
const byCombobox = world.page.getByRole('combobox', { name })
|
||||
if (await byCombobox.count()) {
|
||||
return firstVisible(byCombobox)
|
||||
}
|
||||
|
||||
const byTextbox = world.page.getByRole('textbox', { name })
|
||||
if (await byTextbox.count()) {
|
||||
return firstVisible(byTextbox)
|
||||
}
|
||||
|
||||
return firstVisible(world.page.getByLabel(label, { exact: false }))
|
||||
}
|
||||
|
||||
async function selectComboboxOption(
|
||||
world: AppWorld,
|
||||
label: string,
|
||||
search: string,
|
||||
option = search,
|
||||
) {
|
||||
const input = await labeledComboboxControl(world, label)
|
||||
let lastVisibleOptions: string[] = []
|
||||
let lastListId = ''
|
||||
const isReadonly = await input
|
||||
.evaluate((node) => node instanceof HTMLInputElement && node.readOnly)
|
||||
.catch(() => false)
|
||||
|
||||
await expect(input).toBeEnabled({ timeout: 5_000 })
|
||||
await input.click()
|
||||
|
||||
if (!isReadonly) {
|
||||
await input.fill('')
|
||||
await input.fill(search)
|
||||
await world.page.waitForTimeout(150)
|
||||
}
|
||||
|
||||
lastListId = (await input.getAttribute('aria-controls').catch(() => '')) ?? ''
|
||||
if (!lastListId) {
|
||||
await input.press('ArrowDown').catch(() => {})
|
||||
await world.page.waitForTimeout(150)
|
||||
lastListId = (await input.getAttribute('aria-controls').catch(() => '')) ?? ''
|
||||
}
|
||||
|
||||
const optionsRoot = lastListId ? world.page.locator(`#${lastListId}`) : world.page
|
||||
lastVisibleOptions = await optionsRoot
|
||||
.locator('[role="option"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node as HTMLElement).innerText.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.slice(0, 15),
|
||||
)
|
||||
.catch(() => [])
|
||||
const optionLocator = lastListId
|
||||
? optionsRoot
|
||||
.getByRole('option', {
|
||||
name: new RegExp(`^${escapeRegex(option)}$`, 'i'),
|
||||
})
|
||||
.first()
|
||||
: await visibleOption(world, option)
|
||||
const fuzzyOptionLocator = optionsRoot
|
||||
.locator('[role="option"]')
|
||||
.filter({ hasText: new RegExp(escapeRegex(search), 'i') })
|
||||
.first()
|
||||
|
||||
if (await isVisibleWithin(optionLocator, 3_000)) {
|
||||
await optionLocator.click()
|
||||
await settle(world)
|
||||
if (!isReadonly || (await input.inputValue().catch(() => '')).trim()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (await isVisibleWithin(fuzzyOptionLocator, 1_500)) {
|
||||
await fuzzyOptionLocator.click()
|
||||
await settle(world)
|
||||
if (!isReadonly || (await input.inputValue().catch(() => '')).trim()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await input.press('ArrowDown')
|
||||
await input.press('Enter')
|
||||
await settle(world)
|
||||
|
||||
if ((await input.inputValue().catch(() => '')).trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not select option "${option}" from "${label}". Listbox: ${lastListId || 'none'}. Visible options after typing: ${lastVisibleOptions.join(' | ') || 'none'}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function selectCountryOption(world: AppWorld, label: string, country: string) {
|
||||
const input = await labeledComboboxControl(world, label)
|
||||
let lastVisibleOptions: string[] = []
|
||||
let lastListId = ''
|
||||
|
||||
await expect(input).toBeEnabled({ timeout: 5_000 })
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await input.click()
|
||||
await input.fill('')
|
||||
await input.fill(country)
|
||||
await world.page.waitForTimeout(150)
|
||||
|
||||
lastListId = (await input.getAttribute('aria-controls').catch(() => '')) ?? ''
|
||||
if (!lastListId) {
|
||||
await input.press('ArrowDown').catch(() => {})
|
||||
await world.page.waitForTimeout(150)
|
||||
lastListId = (await input.getAttribute('aria-controls').catch(() => '')) ?? ''
|
||||
}
|
||||
|
||||
const optionsRoot = lastListId ? world.page.locator(`#${lastListId}`) : world.page
|
||||
lastVisibleOptions = await optionsRoot
|
||||
.locator('[role="option"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node as HTMLElement).innerText.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.slice(0, 20),
|
||||
)
|
||||
.catch(() => [])
|
||||
|
||||
const optionLocator = lastListId
|
||||
? optionsRoot
|
||||
.getByRole('option', {
|
||||
name: new RegExp(`^${escapeRegex(country)}$`, 'i'),
|
||||
})
|
||||
.first()
|
||||
: await visibleOption(world, country)
|
||||
|
||||
await optionLocator.waitFor({ state: 'visible', timeout: 5_000 }).catch(() => {})
|
||||
if (await optionLocator.isVisible().catch(() => false)) {
|
||||
await optionLocator.click()
|
||||
} else {
|
||||
await input.press('ArrowDown').catch(() => {})
|
||||
await input.press('Enter').catch(() => {})
|
||||
}
|
||||
|
||||
await input.blur().catch(() => {})
|
||||
await settle(world)
|
||||
|
||||
if ((await input.inputValue().catch(() => '')).trim().toLowerCase() === country.toLowerCase()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not select country "${country}" from "${label}". Listbox: ${lastListId || 'none'}. Visible options after typing: ${lastVisibleOptions.join(' | ') || 'none'}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function chooseOptionFromControl(
|
||||
world: AppWorld,
|
||||
control: Locator,
|
||||
option: string,
|
||||
) {
|
||||
await control.click({ force: true })
|
||||
|
||||
const tagName = await control.evaluate((node) => node.tagName.toLowerCase())
|
||||
if (tagName === 'input') {
|
||||
await control.fill('').catch(() => {})
|
||||
await control.fill(option).catch(() => {})
|
||||
}
|
||||
|
||||
const optionLocator = await visibleOption(world, option)
|
||||
|
||||
if (await isVisibleWithin(optionLocator, 1_500)) {
|
||||
await optionLocator.click()
|
||||
await settle(world)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function setContainerValue(
|
||||
world: AppWorld,
|
||||
container: Locator,
|
||||
value: string,
|
||||
) {
|
||||
const control = await firstVisible(container.locator('input, textarea, button'))
|
||||
await control.scrollIntoViewIfNeeded().catch(() => {})
|
||||
|
||||
if (await chooseOptionFromControl(world, control, value)) {
|
||||
return
|
||||
}
|
||||
|
||||
const tagName = await control.evaluate((node) => node.tagName.toLowerCase())
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
await control.fill('').catch(() => {})
|
||||
await control.fill(value)
|
||||
await control.blur().catch(() => {})
|
||||
await world.page.waitForTimeout(700)
|
||||
await settle(world)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Could not set value "${value}" in the selected control`)
|
||||
}
|
||||
|
||||
async function uploadCurrentStepFile(world: AppWorld, filePath = uploadFixturePath) {
|
||||
const input = world.page.locator('input[type="file"]').first()
|
||||
await input.setInputFiles(filePath)
|
||||
await world.page.getByText('favicon-16x16.png', { exact: false }).last().waitFor({
|
||||
state: 'visible',
|
||||
})
|
||||
await settle(world)
|
||||
}
|
||||
|
||||
async function isUploadStepVisible(world: AppWorld) {
|
||||
return isVisibleWithin(world.page.getByRole('button', { name: /^Choose$/i }).first(), 1_500)
|
||||
}
|
||||
|
||||
async function advanceToUploadStep(world: AppWorld) {
|
||||
await clickPrimaryStepButton(world, 'Next')
|
||||
|
||||
if (await isUploadStepVisible(world)) {
|
||||
return
|
||||
}
|
||||
|
||||
const stateErrorVisible = await world.page
|
||||
.getByText('Please select at least one state.', { exact: false })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
|
||||
if (stateErrorVisible) {
|
||||
await selectComboboxOption(world, 'Where would you like to work in Germany?', 'Berlin')
|
||||
await world.page.keyboard.press('Escape').catch(() => {})
|
||||
await settle(world)
|
||||
await clickPrimaryStepButton(world, 'Next')
|
||||
}
|
||||
|
||||
await expectAdvancedToUploadStep(world)
|
||||
}
|
||||
|
||||
async function expectAdvancedToUploadStep(world: AppWorld) {
|
||||
if (await isUploadStepVisible(world)) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleMessages = (
|
||||
await Promise.all([
|
||||
'Please select at least one state.',
|
||||
'Please select an option.',
|
||||
'You must select a valid country.',
|
||||
'jobs.basicinfo.validation.dateofbirthrequired',
|
||||
].map(async (message) =>
|
||||
(await world.page.getByText(message, { exact: false }).first().isVisible().catch(() => false))
|
||||
? message
|
||||
: null
|
||||
))
|
||||
).filter(Boolean)
|
||||
|
||||
const workLocationInput = world.page
|
||||
.getByLabel('Where would you like to work in Germany?', { exact: false })
|
||||
.first()
|
||||
const dateInput = world.page.locator('input[data-dates-input="true"]').first()
|
||||
const countryInput = world.page
|
||||
.getByLabel('In which country did you finish your nursing education?', { exact: false })
|
||||
.first()
|
||||
const selectedStates = await world.page
|
||||
.locator('[data-combobox-pill]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => node.textContent?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
)
|
||||
.catch(() => [])
|
||||
const radioStates = {
|
||||
yes: await Promise.all(
|
||||
[0, 1, 2].map((index) =>
|
||||
world.page
|
||||
.getByRole('radio', { name: /^Yes$/i })
|
||||
.nth(index)
|
||||
.isChecked()
|
||||
.catch(() => null),
|
||||
),
|
||||
),
|
||||
no: await Promise.all(
|
||||
[0, 1, 2].map((index) =>
|
||||
world.page
|
||||
.getByRole('radio', { name: /^No$/i })
|
||||
.nth(index)
|
||||
.isChecked()
|
||||
.catch(() => null),
|
||||
),
|
||||
),
|
||||
}
|
||||
const controlState = {
|
||||
url: world.page.url(),
|
||||
workLocationValue: await workLocationInput.inputValue().catch(() => ''),
|
||||
selectedStates,
|
||||
dateValue: await dateInput.inputValue().catch(() => ''),
|
||||
countryValue: await countryInput.inputValue().catch(() => ''),
|
||||
radioStates,
|
||||
}
|
||||
const bodyText = (await world.page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 500)
|
||||
|
||||
throw new Error(
|
||||
`Application did not advance to the upload step. Visible validation messages: ${visibleMessages.join(' | ') || 'none'}. Control state: ${JSON.stringify(controlState)}. Body excerpt: ${bodyText}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function selectVisibleRadioByName(
|
||||
world: AppWorld,
|
||||
name: string,
|
||||
visibleIndex: number,
|
||||
) {
|
||||
const radio = await nthVisible(world.page.getByRole('radio', { name: new RegExp(`^${escapeRegex(name)}$`, 'i') }), visibleIndex)
|
||||
const id = await radio.getAttribute('id')
|
||||
if (id) {
|
||||
await world.page.locator(`label[for="${id}"]`).click()
|
||||
return
|
||||
}
|
||||
await radio.click({ force: true })
|
||||
}
|
||||
|
||||
async function clickPrimaryStepButton(world: AppWorld, label: 'Next' | 'Submit') {
|
||||
await world.page.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).click()
|
||||
await settle(world)
|
||||
}
|
||||
|
||||
async function startCandidateApplication(world: AppWorld) {
|
||||
const suffix = Date.now().toString(36)
|
||||
world.candidateName = `E2E Candidate ${suffix}`
|
||||
world.candidateEmail = `e2e-candidate-${suffix}@heygermany.test`
|
||||
|
||||
await world.page.getByLabel('Full Name', { exact: false }).fill(world.candidateName)
|
||||
await world.page.getByLabel('E-Mail', { exact: false }).fill(world.candidateEmail)
|
||||
await world.page.locator('#terms').check()
|
||||
await world.page.getByRole('button', { name: /^Check My Qualification$/i }).click()
|
||||
await world.page.waitForURL((url) => url.toString().includes('/jobs/application'))
|
||||
await settle(world)
|
||||
}
|
||||
|
||||
async function completeCandidateApplication(world: AppWorld) {
|
||||
await selectComboboxOption(world, 'Where would you like to work in Germany?', 'Berlin')
|
||||
await world.page.keyboard.press('Escape').catch(() => {})
|
||||
await settle(world)
|
||||
|
||||
await selectVisibleRadioByName(world, 'Yes', 0)
|
||||
await selectVisibleRadioByName(world, 'No', 1)
|
||||
await selectVisibleRadioByName(world, 'No', 2)
|
||||
|
||||
const dateInput = await firstVisible(world.page.locator('input[data-dates-input="true"]'))
|
||||
await dateInput.fill('01/15/1990')
|
||||
|
||||
await selectCountryOption(
|
||||
world,
|
||||
'In which country did you finish your nursing education?',
|
||||
'Philippines',
|
||||
)
|
||||
await advanceToUploadStep(world)
|
||||
|
||||
await uploadCurrentStepFile(world)
|
||||
await clickPrimaryStepButton(world, 'Next')
|
||||
|
||||
await world.page.locator('#license-not-provided').check()
|
||||
await clickPrimaryStepButton(world, 'Next')
|
||||
|
||||
await world.page.getByText("I don't have a language certificate yet.", { exact: false }).click()
|
||||
await selectComboboxOption(world, 'Level', 'B1')
|
||||
await clickPrimaryStepButton(world, 'Next')
|
||||
|
||||
const submitButton = world.page.getByRole('button', {
|
||||
name: /^Submit(?: Application)?$/i,
|
||||
})
|
||||
if (!(await isVisibleWithin(submitButton, 5_000))) {
|
||||
const visibleButtons = await world.page
|
||||
.getByRole('button')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node as HTMLElement).innerText.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.catch(() => [])
|
||||
const visibleHeadings = await world.page
|
||||
.locator('h1, h2, h3')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes
|
||||
.map((node) => (node as HTMLElement).innerText.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.catch(() => [])
|
||||
const levelValue = await world.page
|
||||
.getByRole('textbox', { name: /Level/i })
|
||||
.first()
|
||||
.inputValue()
|
||||
.catch(() => '')
|
||||
const bodyText = (await world.page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 600)
|
||||
|
||||
throw new Error(
|
||||
`Application did not reach the final submit state. Level value: ${levelValue || 'empty'}. Visible headings: ${visibleHeadings.join(' | ') || 'none'}. Visible buttons: ${visibleButtons.join(' | ') || 'none'}. Body excerpt: ${bodyText}`,
|
||||
)
|
||||
}
|
||||
|
||||
await submitButton.click()
|
||||
await settle(world)
|
||||
}
|
||||
|
||||
Given('I visit {string}', async function (this: AppWorld, path: string) {
|
||||
await this.gotoApp(path)
|
||||
})
|
||||
|
||||
Given(
|
||||
'the {string} actor visits {string}',
|
||||
async function (this: AppWorld, actor: string, path: string) {
|
||||
await useActor(this, actor)
|
||||
await this.gotoApp(path)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I log in as {string} with password {string}',
|
||||
async function (this: AppWorld, email: string, password: string) {
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor logs in as {string} with password {string}',
|
||||
async function (this: AppWorld, actor: string, email: string, password: string) {
|
||||
await useActor(this, actor)
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When('I log out', async function (this: AppWorld) {
|
||||
await this.gotoApp('/en/logout')
|
||||
})
|
||||
|
||||
When('I wait for {int} milliseconds', async function (this: AppWorld, ms: number) {
|
||||
await this.page.waitForTimeout(ms)
|
||||
})
|
||||
|
||||
When('the {string} actor starts a new application', async function (this: AppWorld, actor: string) {
|
||||
await useActor(this, actor)
|
||||
await startCandidateApplication(this)
|
||||
})
|
||||
|
||||
When(
|
||||
'the {string} actor completes and submits the candidate application',
|
||||
async function (this: AppWorld, actor: string) {
|
||||
await useActor(this, actor)
|
||||
await completeCandidateApplication(this)
|
||||
},
|
||||
)
|
||||
|
||||
When('the {string} actor logs into backoffice', async function (this: AppWorld, actor: string) {
|
||||
await useActor(this, actor)
|
||||
await this.gotoApp('/en/backoffice/auth/login')
|
||||
await this.login('e2e-backoffice-login@heygermany.test', 'BackofficePass123!')
|
||||
})
|
||||
|
||||
When('the {string} actor logs into company', async function (this: AppWorld, actor: string) {
|
||||
await useActor(this, actor)
|
||||
await this.gotoApp('/en/company/auth/login')
|
||||
await this.login('e2e-company-login@heygermany.test', 'CompanyPass123!')
|
||||
})
|
||||
|
||||
When(
|
||||
'the {string} actor opens the current candidate in backoffice',
|
||||
async function (this: AppWorld, actor: string) {
|
||||
if (!this.candidateName) {
|
||||
throw new Error('No candidate name is available for the current scenario')
|
||||
}
|
||||
|
||||
await useActor(this, actor)
|
||||
const row = this.page.locator('tr', { hasText: this.candidateName }).first()
|
||||
await row.waitFor({ state: 'visible' })
|
||||
|
||||
const popupPromise = this.context.waitForEvent('page')
|
||||
await row.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
await popup.waitForLoadState('networkidle', { timeout: 2_000 }).catch(() => {})
|
||||
this.setActorPage(actor, popup)
|
||||
const candidateId = popup.url().match(/\/candidate\/([^/?#]+)/)?.[1]
|
||||
if (candidateId) {
|
||||
this.candidateId = candidateId
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor opens the current candidate in company',
|
||||
async function (this: AppWorld, actor: string) {
|
||||
if (!this.candidateName) {
|
||||
throw new Error('No candidate name is available for the current scenario')
|
||||
}
|
||||
|
||||
await useActor(this, actor)
|
||||
await this.gotoApp('/en/company/candidates')
|
||||
const link = this.page.locator('a', { hasText: this.candidateName }).first()
|
||||
await link.waitFor({ state: 'visible' })
|
||||
await link.click()
|
||||
await settle(this)
|
||||
const candidateId = this.page.url().match(/\/candidate\/([^/?#]+)/)?.[1]
|
||||
if (candidateId) {
|
||||
this.candidateId = candidateId
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor marks the candidate as {string}',
|
||||
async function (this: AppWorld, actor: string, status: string) {
|
||||
await useActor(this, actor)
|
||||
const combobox = await firstVisible(
|
||||
this.page
|
||||
.locator('input[placeholder="Select status"], button[title="Select status"]')
|
||||
.or(this.page.getByRole('combobox'))
|
||||
)
|
||||
await combobox.click()
|
||||
const option = this.page.getByRole('option', {
|
||||
name: new RegExp(`^${escapeRegex(status)}$`, 'i'),
|
||||
}).first()
|
||||
await option.waitFor({ state: 'visible' })
|
||||
await option.click()
|
||||
await settle(this)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor sets the backoffice field {string} to {string}',
|
||||
async function (this: AppWorld, actor: string, label: string, value: string) {
|
||||
await useActor(this, actor)
|
||||
const labelText = this.page.getByText(label, { exact: false }).first()
|
||||
const labeledContainer = labelText.locator(
|
||||
'xpath=ancestor::div[.//input or .//button][1]',
|
||||
)
|
||||
|
||||
if (await labeledContainer.count()) {
|
||||
try {
|
||||
await setContainerValue(this, labeledContainer, value)
|
||||
return
|
||||
} catch {
|
||||
// fall back to direct field lookup below
|
||||
}
|
||||
}
|
||||
|
||||
const target = await field(this, label)
|
||||
await target.scrollIntoViewIfNeeded().catch(() => {})
|
||||
|
||||
if (await chooseOptionFromControl(this, target, value)) {
|
||||
return
|
||||
}
|
||||
|
||||
const tagName = await target.evaluate((node) => node.tagName.toLowerCase()).catch(() => '')
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
await target.fill('').catch(() => {})
|
||||
await target.fill(value).catch(() => {})
|
||||
await target.blur().catch(() => {})
|
||||
await this.page.waitForTimeout(700)
|
||||
await settle(this)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Could not set backoffice field "${label}" to "${value}"`)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor sets the verification field {string} to {string}',
|
||||
async function (this: AppWorld, actor: string, fieldName: string, value: string) {
|
||||
await useActor(this, actor)
|
||||
const row = this.page.locator('tr', { hasText: fieldName }).first()
|
||||
await row.waitFor({ state: 'visible' })
|
||||
await setContainerValue(this, row.locator('td').last(), value)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the {string} actor revisits the current candidate application',
|
||||
async function (this: AppWorld, actor: string) {
|
||||
await useActor(this, actor)
|
||||
await this.gotoApp('/en/jobs/application')
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I fill {string} with {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await target.fill('')
|
||||
await target.fill(value)
|
||||
},
|
||||
)
|
||||
|
||||
When('I click {string}', async function (this: AppWorld, label: string) {
|
||||
const byId = this.page.getByTestId(label).first()
|
||||
if (await isVisibleWithin(byId, 1_000)) {
|
||||
await activate(byId)
|
||||
return
|
||||
}
|
||||
|
||||
const fuzzyName = new RegExp(escapeRegex(label), 'i')
|
||||
const roles = ['button', 'tab', 'link', 'menuitem'] as const
|
||||
for (const role of roles) {
|
||||
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
|
||||
if (await isVisibleWithin(candidate, 1_000)) {
|
||||
await activate(candidate)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const byText = this.page.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText, 1_000)) {
|
||||
await activate(byText)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Could not click "${label}"`)
|
||||
})
|
||||
|
||||
When('the {string} actor clicks {string}', async function (this: AppWorld, actor: string, label: string) {
|
||||
await useActor(this, actor)
|
||||
const byId = this.page.getByTestId(label).first()
|
||||
if (await isVisibleWithin(byId, 1_000)) {
|
||||
await activate(byId)
|
||||
await settle(this)
|
||||
return
|
||||
}
|
||||
|
||||
const fuzzyName = new RegExp(escapeRegex(label), 'i')
|
||||
const roles = ['button', 'tab', 'link', 'menuitem'] as const
|
||||
for (const role of roles) {
|
||||
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
|
||||
if (await isVisibleWithin(candidate, 1_000)) {
|
||||
await activate(candidate)
|
||||
await settle(this)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const byText = this.page.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText, 1_000)) {
|
||||
await activate(byText)
|
||||
await settle(this)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Could not click "${label}"`)
|
||||
})
|
||||
|
||||
Then('I see {string}', async function (this: AppWorld, text: string) {
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then('the {string} actor sees {string}', async function (this: AppWorld, actor: string, text: string) {
|
||||
await useActor(this, actor)
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the {string} actor sees the current candidate name',
|
||||
async function (this: AppWorld, actor: string) {
|
||||
if (!this.candidateName) {
|
||||
throw new Error('No candidate name is available for the current scenario')
|
||||
}
|
||||
|
||||
await useActor(this, actor)
|
||||
await this.expectText(this.candidateName)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I do not see {string}', async function (this: AppWorld, text: string) {
|
||||
await expect(this.page.getByText(text, { exact: false }).first()).toBeHidden()
|
||||
})
|
||||
|
||||
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
})
|
||||
|
||||
Then(
|
||||
'the URL does not contain {string}',
|
||||
async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => !url.toString().includes(fragment))
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the {string} actor URL contains {string}',
|
||||
async function (this: AppWorld, actor: string, fragment: string) {
|
||||
await useActor(this, actor)
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
},
|
||||
)
|
||||
148
e2e/tests/support/hooks.ts
Normal file
148
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
Before,
|
||||
After,
|
||||
BeforeAll,
|
||||
AfterAll,
|
||||
setDefaultTimeout,
|
||||
} from '@cucumber/cucumber'
|
||||
import { readFileSync } from 'fs'
|
||||
import { spawn, execFileSync, type ChildProcess } from 'child_process'
|
||||
import { resolve, dirname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { AppWorld } from './world.js'
|
||||
import { config } from './types.js'
|
||||
|
||||
setDefaultTimeout(Math.max(config.responseTimeout, 120_000))
|
||||
|
||||
let backendProcess: ChildProcess | undefined
|
||||
let frontendProcess: ChildProcess | undefined
|
||||
|
||||
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.status < 500) return
|
||||
} catch {
|
||||
// not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
function startProcess({
|
||||
cmd,
|
||||
cwd,
|
||||
env,
|
||||
label,
|
||||
}: {
|
||||
cmd: string
|
||||
cwd: string
|
||||
env: NodeJS.ProcessEnv
|
||||
label: string
|
||||
}) {
|
||||
const p = spawn(cmd, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
detached: true,
|
||||
})
|
||||
p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
|
||||
p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
|
||||
return p
|
||||
}
|
||||
|
||||
async function stopProcess(p: ChildProcess | undefined) {
|
||||
if (!p?.pid) return
|
||||
try {
|
||||
process.kill(-p.pid, 'SIGTERM')
|
||||
} catch {
|
||||
try {
|
||||
p.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000))
|
||||
}
|
||||
|
||||
function killPort(port: number) {
|
||||
try {
|
||||
const out = execFileSync('lsof', ['-ti', `tcp:${port}`], {
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
if (!out) return
|
||||
for (const pid of out.split(/\s+/)) {
|
||||
if (!pid) continue
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '../../..')
|
||||
const seedFile = join(repoRoot, 'db', 'sqlite-seed.sql')
|
||||
const sqliteDb = join(repoRoot, '.pikku-runtime', 'dev.db')
|
||||
|
||||
function prepareDatabase() {
|
||||
execFileSync('sqlite3', [sqliteDb], {
|
||||
cwd: repoRoot,
|
||||
input: readFileSync(seedFile),
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
})
|
||||
}
|
||||
|
||||
BeforeAll(async function () {
|
||||
if (!config.manageServers) return
|
||||
|
||||
if (process.env.E2E_PREPARE_DB !== '0' && process.env.E2E_PREPARE_DB !== 'false') {
|
||||
prepareDatabase()
|
||||
}
|
||||
|
||||
killPort(3000)
|
||||
killPort(3001)
|
||||
|
||||
backendProcess = startProcess({
|
||||
cmd: process.env.E2E_BACKEND_CMD ?? 'bun run dev:backend',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
label: 'backend',
|
||||
})
|
||||
await waitForUrl(`${config.apiUrl}/api/auth/get-session`, 'backend')
|
||||
|
||||
frontendProcess = startProcess({
|
||||
cmd: process.env.E2E_FRONTEND_CMD ?? 'bun run dev:frontend',
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
VITE_API_BASE_URL: config.apiUrl,
|
||||
},
|
||||
label: 'frontend',
|
||||
})
|
||||
await waitForUrl(`${config.appUrl}/en`, 'frontend')
|
||||
})
|
||||
|
||||
AfterAll(async function () {
|
||||
await stopProcess(frontendProcess)
|
||||
await stopProcess(backendProcess)
|
||||
})
|
||||
|
||||
Before(async function (this: AppWorld) {
|
||||
await this.openBrowser()
|
||||
})
|
||||
|
||||
After(async function (this: AppWorld) {
|
||||
await this.closeBrowser()
|
||||
})
|
||||
14
e2e/tests/support/types.ts
Normal file
14
e2e/tests/support/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const config = {
|
||||
get appUrl() {
|
||||
return process.env.APP_URL ?? 'http://localhost:3001'
|
||||
},
|
||||
get apiUrl() {
|
||||
return process.env.API_URL ?? 'http://localhost:3000'
|
||||
},
|
||||
get responseTimeout() {
|
||||
return Number(process.env.E2E_TIMEOUT ?? 30_000)
|
||||
},
|
||||
get manageServers() {
|
||||
return process.env.E2E_MANAGE_SERVERS === '1'
|
||||
},
|
||||
} as const
|
||||
134
e2e/tests/support/world.ts
Normal file
134
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test'
|
||||
import { config } from './types.js'
|
||||
|
||||
type ActorSession = {
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
}
|
||||
|
||||
export class AppWorld extends World {
|
||||
browser!: Browser
|
||||
private actorSessions = new Map<string, ActorSession>()
|
||||
private currentActor = 'default'
|
||||
candidateName?: string
|
||||
candidateEmail?: string
|
||||
candidateId?: string
|
||||
|
||||
async openBrowser() {
|
||||
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
|
||||
this.browser = await chromium.launch({
|
||||
headless: !headed,
|
||||
slowMo: headed ? 150 : 0,
|
||||
})
|
||||
await this.useActor('default')
|
||||
}
|
||||
|
||||
async closeBrowser() {
|
||||
for (const { context } of this.actorSessions.values()) {
|
||||
await context.close().catch(() => {})
|
||||
}
|
||||
this.actorSessions.clear()
|
||||
await this.browser?.close()
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.getActorSession(this.currentActor).context
|
||||
}
|
||||
|
||||
get page() {
|
||||
return this.getActorSession(this.currentActor).page
|
||||
}
|
||||
|
||||
get activeActor() {
|
||||
return this.currentActor
|
||||
}
|
||||
|
||||
async useActor(name: string) {
|
||||
if (!this.actorSessions.has(name)) {
|
||||
const context = await this.browser.newContext({
|
||||
locale: process.env.E2E_LOCALE ?? 'en-US',
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.setDefaultTimeout(config.responseTimeout)
|
||||
this.actorSessions.set(name, { context, page })
|
||||
}
|
||||
this.currentActor = name
|
||||
}
|
||||
|
||||
setActorPage(name: string, page: Page) {
|
||||
const session = this.getActorSession(name)
|
||||
page.setDefaultTimeout(config.responseTimeout)
|
||||
this.actorSessions.set(name, { ...session, page })
|
||||
}
|
||||
|
||||
private getActorSession(name: string) {
|
||||
const session = this.actorSessions.get(name)
|
||||
if (!session) {
|
||||
throw new Error(`Actor session "${name}" has not been initialized`)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
async gotoApp(path: string) {
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 2_500 }).catch(() => {})
|
||||
await this.page.waitForTimeout(400)
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const startingUrl = this.page.url()
|
||||
await this.page.locator('input[name="email"]').fill(email)
|
||||
await this.page.locator('input[name="password"]').fill(password)
|
||||
await this.page.getByRole('button', { name: /^login$/i }).click({
|
||||
noWaitAfter: true,
|
||||
})
|
||||
await Promise.race([
|
||||
this.page.waitForURL(
|
||||
(url) => url.toString() !== startingUrl && !url.toString().includes('/auth/login'),
|
||||
{ timeout: 5_000 },
|
||||
),
|
||||
this.page
|
||||
.getByText(/invalid email or password/i, { exact: false })
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 5_000 }),
|
||||
]).catch(() => {})
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => {})
|
||||
await this.page.waitForTimeout(400)
|
||||
|
||||
const invalidCredentials = await this.page
|
||||
.getByText(/invalid email or password/i, { exact: false })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
|
||||
if (invalidCredentials) {
|
||||
throw new Error(`Login failed for ${email}: invalid email or password`)
|
||||
}
|
||||
}
|
||||
|
||||
async expectText(text: string) {
|
||||
const locator = this.page.getByText(text, { exact: false }).first()
|
||||
try {
|
||||
await locator.waitFor({ state: 'visible', timeout: config.responseTimeout })
|
||||
} catch (error) {
|
||||
const bodyText = (await this.page.locator('body').innerText().catch(() => ''))
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 1000)
|
||||
throw new Error(
|
||||
`Expected to see "${text}" but it was not visible. Body excerpt: ${bodyText}`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(AppWorld)
|
||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user