Remove obsolete test artifacts and error context files for seafarer registration tests
@@ -1,13 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"98dcbc0c174eb3697418-75794b7db9eaf01c737f",
|
||||
"98dcbc0c174eb3697418-34fd1a52a2c14f879d3a",
|
||||
"98dcbc0c174eb3697418-bd195edac5a95d796827",
|
||||
"98dcbc0c174eb3697418-c505dae67d8cd7469ff3",
|
||||
"98dcbc0c174eb3697418-ba62eb9d11839aca30c0",
|
||||
"98dcbc0c174eb3697418-07ce101789b6b7b7985c",
|
||||
"98dcbc0c174eb3697418-1d2cbda982bd085da606",
|
||||
"98dcbc0c174eb3697418-46dd670046a70e730e93"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
# 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 registered seafarer cannot start a second registration
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:355: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 2475" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042544962475@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042544962475
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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
|
||||
|
||||
```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 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
|
||||
|
||||
```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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 >> approval numbers the profile and opens both child applications
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:303: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 4609" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042485274609@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042485274609
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 re-fired approval renumbers nobody and opens no second pair
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:336: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 2538" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042515032538@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042515032538
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 >> an officer can return a registration for correction and take it back
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:243: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 5517" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042391965517@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042391965517
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 >> an officer can hold and resume a registration
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:267: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 2368" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042424082368@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042424082368
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# 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 >> an officer can reject a registration with a reason
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:285: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 9173" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042455309173@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042455309173
|
||||
- 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<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);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |