Files
heygermany-e2e-mrg5ilhk/e2e/tests/steps/common.steps.ts
e2e 40352e4179
Some checks failed
Main / Setup and Test (push) Has been cancelled
Main / Build Website (push) Has been cancelled
chore: heygermany customer project
2026-07-11 11:17:38 +02:00

790 lines
24 KiB
TypeScript

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))
},
)