chore: heygermany customer project
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled

This commit is contained in:
e2e
2026-07-11 09:36:03 +02:00
commit 1e78e21c36
398 changed files with 38345 additions and 0 deletions

39
e2e/README.md Normal file
View 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
View 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
View 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,
}

View 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"

View 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"

View 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 "Weve 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 "Weve 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 "Weve 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"

View 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"

View 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
View 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()
})

View 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
View 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
View 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"]
}