# Instructions - Following Playwright test failed. - Explain why, be concise, respect Playwright best practices. - Provide a snippet of code with the fix, if possible. # Test info - Name: seafarer-registration.spec.ts >> seafarer registration >> opening the wizard creates the draft up front - Location: apps/e2e/src/seafarer-registration.spec.ts:206:7 # Error details ``` Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. ``` # Page snapshot ```yaml - generic [ref=f1e3]: - banner [ref=f1e4]: - generic [ref=f1e5]: - generic [ref=f1e6]: - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - generic [ref=f1e10]: - generic [ref=f1e11]: Dashboard - generic [ref=f1e13]: Profile - generic [ref=f1e17]: - button "Language" [ref=f1e18] [cursor=pointer] - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - button "Notifications" [ref=f1e26] [cursor=pointer]: - generic [ref=f1e27]: "1" - button "ES" [ref=f1e32] [cursor=pointer] - navigation [ref=f1e34]: - generic [ref=f1e35]: - img "EMA" [ref=f1e36] - generic [ref=f1e37]: - paragraph [ref=f1e38]: EMA Portal - paragraph [ref=f1e39]: Ethiopian Maritime Authority - generic [ref=f1e43]: - generic [ref=f1e44]: - generic [ref=f1e45] [cursor=pointer]: Dashboard - generic [ref=f1e52] [cursor=pointer]: - generic [ref=f1e57]: Notifications - generic "1 pending" [ref=f1e59]: "1" - generic [ref=f1e61]: - button [expanded] [ref=f1e62] [cursor=pointer]: - paragraph [ref=f1e63]: Licensing - generic [ref=f1e66] [cursor=pointer]: My Applications - generic [ref=f1e73]: - button [expanded] [ref=f1e74] [cursor=pointer]: - paragraph [ref=f1e75]: Seafarer Services - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - generic [ref=f1e82] [cursor=pointer]: My Sea Records - generic [ref=f1e86] [cursor=pointer]: Seaman Book - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - generic [ref=f1e98] [cursor=pointer]: Certificates - generic [ref=f1e104] [cursor=pointer]: Examinations - generic [ref=f1e108] [cursor=pointer]: Endorsements - generic [ref=f1e113]: - button [expanded] [ref=f1e114] [cursor=pointer]: - paragraph [ref=f1e115]: Account - generic [ref=f1e118] [cursor=pointer]: My Documents - generic [ref=f1e123] [cursor=pointer]: Profile - generic [ref=f1e130] [cursor=pointer]: Help & Support - button "Collapse" [ref=f1e139] [cursor=pointer] - main [ref=f1e143]: - generic [ref=f1e145]: - generic [ref=f1e147]: - heading "My Profile" [level=2] [ref=f1e148] - paragraph [ref=f1e149]: Manage your account details and preferences. - alert [ref=f1e150]: - generic [ref=f1e151]: Profile details are needed for seafarer registration. - generic [ref=f1e159]: - paragraph [ref=f1e161]: ES - generic [ref=f1e162]: - generic [ref=f1e163]: - heading "E2E seafarer 3450" [level=4] [ref=f1e164] - generic [ref=f1e165]: Unverified - paragraph [ref=f1e171]: e2e.seafarer.1787042323383450@example.test - generic [ref=f1e172]: e2eseafarer1787042323383450 - generic "0% complete" [ref=f1e178]: - paragraph [ref=f1e183]: 0% - generic [ref=f1e184]: - tablist [ref=f1e185]: - tab "Personal" [ref=f1e186] [cursor=pointer] - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - tab "Address" [ref=f1e199] [cursor=pointer] - tab "Operations" [ref=f1e205] [cursor=pointer] - tab "Security" [ref=f1e212] [cursor=pointer] - tab "Preferences" [ref=f1e218] [cursor=pointer] - tabpanel "Profile" [ref=f1e224]: - generic [ref=f1e227]: - generic [ref=f1e228]: - heading "Maritime Profile" [level=5] [ref=f1e229] - paragraph [ref=f1e230]: Your professional maritime details - generic [ref=f1e231]: - generic [ref=f1e232]: - generic [ref=f1e233]: Profession * - textbox "Profession" [ref=f1e235]: - /placeholder: Select - text: Master Mariner - generic [ref=f1e236]: - generic [ref=f1e237]: First Name * - textbox "First Name" [ref=f1e239]: - /placeholder: Enter first name - text: Dawit - generic [ref=f1e240]: - generic [ref=f1e241]: Middle Name * - textbox "Middle Name" [ref=f1e243]: - /placeholder: Enter middle name - text: Bekele - generic [ref=f1e244]: - generic [ref=f1e245]: Last Name * - textbox "Last Name" [ref=f1e247]: - /placeholder: Enter last name - text: Tesfaye - generic [ref=f1e248]: - generic [ref=f1e249]: Gender * - textbox "Gender" [ref=f1e251] [cursor=pointer]: - /placeholder: Select - text: MALE - generic [ref=f1e252]: - generic [ref=f1e253]: Date of Birth * - generic [ref=f1e254]: - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - generic [ref=f1e257]: EN - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - button [ref=f1e261] [cursor=pointer] - generic [ref=f1e266]: - generic [ref=f1e267]: Place of Birth - textbox "Place of Birth" [ref=f1e269]: - /placeholder: City, Region - generic [ref=f1e270]: - generic [ref=f1e271]: Marital Status * - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - /placeholder: Select - text: SINGLE - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] ``` # Test source ```ts 46 | await openTab(page, 'Address'); 47 | await pick(page, 'ID Type', /^NID$/i); 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); 49 | // A country select, not a free-text field. 50 | await pick(page, 'Nationality', /ethiopia/i); 51 | // `addressSchema` requires this in Ethiopian format; without it the form 52 | // never submits and no request is made for `save` to wait on. 53 | await page 54 | .getByRole('textbox', { name: 'Primary Phone' }) 55 | .fill('+251911234567'); 56 | await save(page); 57 | } 58 | 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ 60 | async function openTab(page: Page, name: string): Promise { 61 | await page.getByRole('tab', { name, exact: true }).click(); 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ 63 | timeout: 15_000, 64 | }); 65 | } 66 | 67 | /** 68 | * Picks a value from a Mantine select. 69 | * 70 | * The label is bound to both the input and the listbox it opens, so matching 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role 72 | * names the control itself. 73 | */ 74 | async function pick(page: Page, label: string, option: RegExp): Promise { 75 | await page.getByRole('textbox', { name: label }).click(); 76 | await page.getByRole('option', { name: option }).first().click(); 77 | } 78 | 79 | /** 80 | * Sets the date of birth through the picker's own UI. 81 | * 82 | * `AmharicDatePicker` is a controlled component: it reports changes through 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the 84 | * input's `value` natively bypasses that entirely — the field stays empty as 85 | * far as zod is concerned, and the form silently refuses to submit. 86 | * 87 | * So the calendar is actually driven: open it, pick the year and month from 88 | * the caption dropdowns, then click the day. 89 | */ 90 | async function pickDate(page: Page, label: string, iso: string): Promise { 91 | const [year, month, day] = iso.split('-').map(Number); 92 | 93 | await page.getByRole('textbox', { name: label }).click(); 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); 96 | 97 | // `captionLayout="dropdown"` renders native selects for month and year. 98 | await calendar.locator('select').last().selectOption(String(year)); 99 | await calendar 100 | .locator('select') 101 | .first() 102 | .selectOption({ index: month - 1 }); 103 | 104 | // Each day is a button whose accessible name is the full date 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 107 | // or 21. Resolved after the dropdowns settle, since changing year or month 108 | // re-renders the grid. 109 | const cell = calendar 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) 111 | .first(); 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); 113 | await cell.click(); 114 | 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); 116 | 117 | // The picker writes through `onChange`; if that did not land, zod still sees 118 | // an empty field and the failure would surface later as a refused submit. 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { 120 | timeout: 10_000, 121 | }); 122 | } 123 | 124 | async function save(page: Page): Promise { 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the 127 | // route's own spelling. Any successful write from this screen is the signal. 128 | const saved = page.waitForResponse( 129 | (r) => 130 | r.request().method() !== 'GET' && 131 | r.status() < 400 && 132 | /(profile|address|user)/i.test(r.url()), 133 | { timeout: 20_000 }, 134 | ); 135 | await page.getByRole('button', { name: /save/i }).first().click(); 136 | 137 | try { 138 | await saved; 139 | } catch (cause) { 140 | // A zod-blocked submit fires no request at all, so the bare timeout says 141 | // only "no response" — which reads as a backend fault rather than a form 142 | // that refused to submit. Surface the field errors instead. 143 | const messages = await page 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') 145 | .allTextContents(); > 146 | throw new Error( | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. 147 | messages.length 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` 149 | : 'Save produced no request and reported no validation error.', 150 | { cause }, 151 | ); 152 | } 153 | } 154 | 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { 157 | const offset = await signUp(page, applicant); 158 | await verifyOtpIfPrompted(page, offset); 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); 160 | await page 161 | .getByRole('checkbox', { name: /seafarer registration/i }) 162 | .first() 163 | .check(); 164 | await page.getByRole('button', { name: /save operations/i }).click(); 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is 166 | // built from the profile, and a fresh signup holds none of it yet. 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); 168 | await completeProfile(page); 169 | } 170 | 171 | test.describe('seafarer registration', () => { 172 | let applicant: Applicant; 173 | 174 | test.beforeEach(() => { 175 | applicant = newApplicant('seafarer'); 176 | }); 177 | 178 | test.afterEach(() => { 179 | deleteApplicant(applicant.email); 180 | }); 181 | 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ 183 | page, 184 | }) => { 185 | const offset = await signUp(page, applicant); 186 | await verifyOtpIfPrompted(page, offset); 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); 188 | await page 189 | .getByRole('checkbox', { name: /seafarer registration/i }) 190 | .first() 191 | .check(); 192 | await page.getByRole('button', { name: /save operations/i }).click(); 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); 194 | 195 | // A new account holds none of the identity the registration is filled in 196 | // from, so the gate collects it rather than opening an uncompletable form. 197 | await page.goto('/seafarer-registration'); 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); 199 | 200 | // The shared wizard route is gated identically — otherwise the gate is 201 | // decoration a deep link walks straight past. 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); 204 | }); 205 | 206 | test('opening the wizard creates the draft up front', async ({ page }) => { 207 | await readyApplicant(page, applicant); 208 | 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); 211 | 212 | // The draft exists before anything is filled in, so uploads have an owner 213 | // and closing the browser mid-wizard loses nothing. 214 | const number = await waitForApplication(applicant.email); 215 | expect(number).toMatch(/^SFR/); 216 | expect(statusOf(number)).toBe('DRAFT'); 217 | }); 218 | 219 | test('a registration never reaches evaluation or inspection', async ({ 220 | page, 221 | }) => { 222 | await readyApplicant(page, applicant); 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); 224 | const number = await waitForApplication(applicant.email); 225 | const id = idOf(number); 226 | 227 | await submit(id); 228 | await runWorkflow(id, [{ path: 'claim' }]); 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); 230 | 231 | // The licence course's middle stages have nothing to hold in a 232 | // registration, and the transition table is the authority regardless of 233 | // which endpoint is called. 234 | const refused = await runWorkflow(id, [ 235 | { path: 'complete-review', expectFailure: true }, 236 | { path: 'approve-documents', expectFailure: true }, 237 | { path: 'record-inspection', expectFailure: true }, 238 | ]); 239 | expect(refused.every((code) => code >= 400)).toBe(true); 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); 241 | }); 242 | 243 | test('an officer can return a registration for correction and take it back', async ({ 244 | page, 245 | }) => { 246 | await readyApplicant(page, applicant); ```