mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
- Created error context markdown files for failed tests in seafarer registration, detailing validation errors related to profile details. - Added binary files (PNG, ZIP, WEBM) for test results, including snapshots and traces for debugging purposes. - Updated test cases to include new error context and media files for better analysis of failures.
16 KiB
16 KiB
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 >> a registration never reaches evaluation or inspection
- Location: apps/e2e/src/seafarer-registration.spec.ts:219:7
Error details
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
Page snapshot
- 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 8190" [level=4] [ref=f1e164]
- generic [ref=f1e165]: Unverified
- paragraph [ref=f1e171]: e2e.seafarer.1787042357258190@example.test
- generic [ref=f1e172]: e2eseafarer1787042357258190
- 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
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);