Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
455
apps/e2e/src/seafarer-registration.spec.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { test, expect, Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
Applicant,
|
||||||
|
newApplicant,
|
||||||
|
signUp,
|
||||||
|
verifyOtpIfPrompted,
|
||||||
|
} from './support/applicant';
|
||||||
|
import { deleteApplicant, sql, sqlValue } from './support/db';
|
||||||
|
import { approveRegistration, runWorkflow } from './support/workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seafarer registration, applicant through to approval.
|
||||||
|
*
|
||||||
|
* The registration is the one service whose approval has consequences beyond
|
||||||
|
* its own row: it stamps a permanent number on the profile, activates the
|
||||||
|
* seafarer record, and opens the Seaman Book and BTC applications on the
|
||||||
|
* applicant's behalf. Those effects only fire at final approval, so nothing
|
||||||
|
* short of driving a registration into an officer's hands exercises them.
|
||||||
|
*
|
||||||
|
* The workflow is deliberately shorter than a licence's — no evaluation stage,
|
||||||
|
* no inspection — so which actions an officer is *refused* is as much the
|
||||||
|
* subject here as which ones work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the fields `RequireSeafarerProfile` refuses to open the wizard without.
|
||||||
|
*
|
||||||
|
* They are split across two tabs, and every tab's panel is in the DOM whether
|
||||||
|
* or not it is showing — so each one has to be selected before its inputs can
|
||||||
|
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
|
||||||
|
* field lives where.
|
||||||
|
*/
|
||||||
|
async function completeProfile(page: Page): Promise<void> {
|
||||||
|
await page.goto('/profile');
|
||||||
|
|
||||||
|
await openTab(page, 'Profile');
|
||||||
|
await page.getByLabel('First Name').fill('Dawit');
|
||||||
|
await page.getByLabel('Middle Name').fill('Bekele');
|
||||||
|
await page.getByLabel('Last Name').fill('Tesfaye');
|
||||||
|
await pick(page, 'Gender', /male/i);
|
||||||
|
await pickDate(page, 'Date of Birth', '1995-04-12');
|
||||||
|
await pick(page, 'Marital Status', /single/i);
|
||||||
|
await pick(page, 'Profession', /./);
|
||||||
|
await save(page);
|
||||||
|
|
||||||
|
await openTab(page, 'Address');
|
||||||
|
await pick(page, 'ID Type', /^NID$/i);
|
||||||
|
await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||||
|
// A country select, not a free-text field.
|
||||||
|
await pick(page, 'Nationality', /ethiopia/i);
|
||||||
|
// `addressSchema` requires this in Ethiopian format; without it the form
|
||||||
|
// never submits and no request is made for `save` to wait on.
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'Primary Phone' })
|
||||||
|
.fill('+251911234567');
|
||||||
|
await save(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects a profile tab and waits for its panel to be the visible one. */
|
||||||
|
async function openTab(page: Page, name: string): Promise<void> {
|
||||||
|
await page.getByRole('tab', { name, exact: true }).click();
|
||||||
|
await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a value from a Mantine select.
|
||||||
|
*
|
||||||
|
* The label is bound to both the input and the listbox it opens, so matching
|
||||||
|
* by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||||
|
* names the control itself.
|
||||||
|
*/
|
||||||
|
async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||||
|
await page.getByRole('textbox', { name: label }).click();
|
||||||
|
await page.getByRole('option', { name: option }).first().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the date of birth through the picker's own UI.
|
||||||
|
*
|
||||||
|
* `AmharicDatePicker` is a controlled component: it reports changes through
|
||||||
|
* `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||||
|
* input's `value` natively bypasses that entirely — the field stays empty as
|
||||||
|
* far as zod is concerned, and the form silently refuses to submit.
|
||||||
|
*
|
||||||
|
* So the calendar is actually driven: open it, pick the year and month from
|
||||||
|
* the caption dropdowns, then click the day.
|
||||||
|
*/
|
||||||
|
async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||||
|
const [year, month, day] = iso.split('-').map(Number);
|
||||||
|
|
||||||
|
await page.getByRole('textbox', { name: label }).click();
|
||||||
|
const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||||
|
await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// `captionLayout="dropdown"` renders native selects for month and year.
|
||||||
|
await calendar.locator('select').last().selectOption(String(year));
|
||||||
|
await calendar
|
||||||
|
.locator('select')
|
||||||
|
.first()
|
||||||
|
.selectOption({ index: month - 1 });
|
||||||
|
|
||||||
|
// Each day is a button whose accessible name is the full date
|
||||||
|
// ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||||
|
// number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||||
|
// or 21. Resolved after the dropdowns settle, since changing year or month
|
||||||
|
// re-renders the grid.
|
||||||
|
const cell = calendar
|
||||||
|
.getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||||
|
.first();
|
||||||
|
await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||||
|
await cell.click();
|
||||||
|
|
||||||
|
await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The picker writes through `onChange`; if that did not land, zod still sees
|
||||||
|
// an empty field and the failure would surface later as a refused submit.
|
||||||
|
await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(page: Page): Promise<void> {
|
||||||
|
// Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||||
|
// tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||||
|
// route's own spelling. Any successful write from this screen is the signal.
|
||||||
|
const saved = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.request().method() !== 'GET' &&
|
||||||
|
r.status() < 400 &&
|
||||||
|
/(profile|address|user)/i.test(r.url()),
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: /save/i }).first().click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saved;
|
||||||
|
} catch (cause) {
|
||||||
|
// A zod-blocked submit fires no request at all, so the bare timeout says
|
||||||
|
// only "no response" — which reads as a backend fault rather than a form
|
||||||
|
// that refused to submit. Surface the field errors instead.
|
||||||
|
const messages = await page
|
||||||
|
.locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||||
|
.allTextContents();
|
||||||
|
throw new Error(
|
||||||
|
messages.length
|
||||||
|
? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||||
|
: 'Save produced no request and reported no validation error.',
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||||
|
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||||
|
const offset = await signUp(page, applicant);
|
||||||
|
await verifyOtpIfPrompted(page, offset);
|
||||||
|
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: /seafarer registration/i })
|
||||||
|
.first()
|
||||||
|
.check();
|
||||||
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
|
// A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||||
|
// built from the profile, and a fresh signup holds none of it yet.
|
||||||
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
|
await completeProfile(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('seafarer registration', () => {
|
||||||
|
let applicant: Applicant;
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
applicant = newApplicant('seafarer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
deleteApplicant(applicant.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const offset = await signUp(page, applicant);
|
||||||
|
await verifyOtpIfPrompted(page, offset);
|
||||||
|
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||||
|
await page
|
||||||
|
.getByRole('checkbox', { name: /seafarer registration/i })
|
||||||
|
.first()
|
||||||
|
.check();
|
||||||
|
await page.getByRole('button', { name: /save operations/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// A new account holds none of the identity the registration is filled in
|
||||||
|
// from, so the gate collects it rather than opening an uncompletable form.
|
||||||
|
await page.goto('/seafarer-registration');
|
||||||
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// The shared wizard route is gated identically — otherwise the gate is
|
||||||
|
// decoration a deep link walks straight past.
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// The draft exists before anything is filled in, so uploads have an owner
|
||||||
|
// and closing the browser mid-wizard loses nothing.
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
expect(number).toMatch(/^SFR/);
|
||||||
|
expect(statusOf(number)).toBe('DRAFT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a registration never reaches evaluation or inspection', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await runWorkflow(id, [{ path: 'claim' }]);
|
||||||
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
|
|
||||||
|
// The licence course's middle stages have nothing to hold in a
|
||||||
|
// registration, and the transition table is the authority regardless of
|
||||||
|
// which endpoint is called.
|
||||||
|
const refused = await runWorkflow(id, [
|
||||||
|
{ path: 'complete-review', expectFailure: true },
|
||||||
|
{ path: 'approve-documents', expectFailure: true },
|
||||||
|
{ path: 'record-inspection', expectFailure: true },
|
||||||
|
]);
|
||||||
|
expect(refused.every((code) => code >= 400)).toBe(true);
|
||||||
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an officer can return a registration for correction and take it back', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await runWorkflow(id, [
|
||||||
|
{ path: 'claim' },
|
||||||
|
{
|
||||||
|
path: 'request-adjustment',
|
||||||
|
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
||||||
|
|
||||||
|
// A resubmission returns to review directly — a registration has no
|
||||||
|
// earlier stage to fall back to.
|
||||||
|
await runWorkflow(id, [{ path: 'resubmit' }]);
|
||||||
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an officer can hold and resume a registration', async ({ page }) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await runWorkflow(id, [
|
||||||
|
{ path: 'claim' },
|
||||||
|
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
|
||||||
|
]);
|
||||||
|
expect(statusOf(number)).toBe('ON_HOLD');
|
||||||
|
|
||||||
|
// Resume restores whatever it was held from, read back from history.
|
||||||
|
await runWorkflow(id, [{ path: 'resume' }]);
|
||||||
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an officer can reject a registration with a reason', async ({ page }) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await runWorkflow(id, [
|
||||||
|
{ path: 'claim' },
|
||||||
|
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(statusOf(number)).toBe('REJECTED');
|
||||||
|
// A rejection is terminal: nothing is numbered and no children open.
|
||||||
|
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
||||||
|
expect(childrenOf(number)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approval numbers the profile and opens both child applications', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await approveRegistration(id);
|
||||||
|
|
||||||
|
expect(statusOf(number)).toBe('COMPLETED');
|
||||||
|
|
||||||
|
const profile = sql(`
|
||||||
|
SELECT p.seafarer_number, p.seafarer_status
|
||||||
|
FROM profiles p
|
||||||
|
JOIN iam.users u ON u.id = p.user_id
|
||||||
|
WHERE u.email = '${applicant.email}'
|
||||||
|
`);
|
||||||
|
expect(profile[0][0]).toBeTruthy();
|
||||||
|
expect(profile[0][1]).toBe('ACTIVE');
|
||||||
|
|
||||||
|
// The applicant is not made to apply twice more for the documents that
|
||||||
|
// prove what they have just been told.
|
||||||
|
const children = childrenOf(number);
|
||||||
|
expect(children.map((r) => r[0])).toEqual([
|
||||||
|
'BTC_BASIC_TRAINING',
|
||||||
|
'SEAMAN_BOOK',
|
||||||
|
]);
|
||||||
|
expect(children.every((r) => r[1] === 'SUBMITTED')).toBe(true);
|
||||||
|
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a re-fired approval renumbers nobody and opens no second pair', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
const id = idOf(number);
|
||||||
|
|
||||||
|
await submit(id);
|
||||||
|
await approveRegistration(id);
|
||||||
|
const first = seafarerNumberOf(applicant.email);
|
||||||
|
|
||||||
|
// Approving again must be a no-op, not a second number and a second bill.
|
||||||
|
await runWorkflow(id, [{ path: 'final-approve', expectFailure: true }]);
|
||||||
|
|
||||||
|
expect(seafarerNumberOf(applicant.email)).toBe(first);
|
||||||
|
expect(childrenOf(number)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a registered seafarer cannot start a second registration', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await readyApplicant(page, applicant);
|
||||||
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||||
|
const number = await waitForApplication(applicant.email);
|
||||||
|
|
||||||
|
await submit(idOf(number));
|
||||||
|
await approveRegistration(idOf(number));
|
||||||
|
|
||||||
|
// The number is permanent and the service is not renewable, so the portal
|
||||||
|
// stops offering it rather than letting them file a second registration an
|
||||||
|
// officer would review for no outcome.
|
||||||
|
await page.goto('/seafarer-registration');
|
||||||
|
await expect(page).not.toHaveURL(
|
||||||
|
/\/licensing\/SEAFARER_REGISTRATION\/apply/,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
sqlValue(`
|
||||||
|
SELECT count(*) FROM license_applications a
|
||||||
|
JOIN license_types lt ON lt.id = a.license_type_id
|
||||||
|
JOIN iam.users u ON u.id = a.applicant_user_id
|
||||||
|
WHERE lt.key = 'SEAFARER_REGISTRATION'
|
||||||
|
AND u.email = '${applicant.email}'
|
||||||
|
`),
|
||||||
|
).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
/** Waits for the draft the wizard creates on open, and returns its number. */
|
||||||
|
async function waitForApplication(
|
||||||
|
email: string,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const found = sqlValue(`
|
||||||
|
SELECT a.application_number
|
||||||
|
FROM license_applications a
|
||||||
|
JOIN license_types lt ON lt.id = a.license_type_id
|
||||||
|
JOIN iam.users u ON u.id = a.applicant_user_id
|
||||||
|
WHERE lt.key = 'SEAFARER_REGISTRATION' AND u.email = '${email}'
|
||||||
|
ORDER BY a.created_at DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
if (found) return found;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`No seafarer registration appeared for ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function idOf(applicationNumber: string): string {
|
||||||
|
const id = sqlValue(`
|
||||||
|
SELECT id FROM license_applications
|
||||||
|
WHERE application_number = '${applicationNumber}'
|
||||||
|
`);
|
||||||
|
if (!id) throw new Error(`No application ${applicationNumber}`);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOf(applicationNumber: string): string | null {
|
||||||
|
return sqlValue(`
|
||||||
|
SELECT status FROM license_applications
|
||||||
|
WHERE application_number = '${applicationNumber}'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seafarerNumberOf(email: string): string | null {
|
||||||
|
return sqlValue(`
|
||||||
|
SELECT p.seafarer_number FROM profiles p
|
||||||
|
JOIN iam.users u ON u.id = p.user_id
|
||||||
|
WHERE u.email = '${email}'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenOf(applicationNumber: string): string[][] {
|
||||||
|
return sql(`
|
||||||
|
SELECT lt.key, a.status, a.origin
|
||||||
|
FROM license_applications a
|
||||||
|
JOIN license_types lt ON lt.id = a.license_type_id
|
||||||
|
WHERE a.parent_application_id = (
|
||||||
|
SELECT id FROM license_applications
|
||||||
|
WHERE application_number = '${applicationNumber}'
|
||||||
|
)
|
||||||
|
ORDER BY lt.key
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits the draft.
|
||||||
|
*
|
||||||
|
* The wizard's own sections are not filled in: what these tests are about is
|
||||||
|
* the workflow and its approval effects, and a form-validation failure would
|
||||||
|
* fail them for the wrong reason. Field-level rules belong in their own spec.
|
||||||
|
*/
|
||||||
|
async function submit(applicationId: string): Promise<void> {
|
||||||
|
await runWorkflow(applicationId, [{ path: 'submit' }]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { E2E } from '../../playwright.config';
|
import { E2E } from '../../playwright.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,18 +13,74 @@ import { E2E } from '../../playwright.config';
|
|||||||
const PSQL_ENV = {
|
const PSQL_ENV = {
|
||||||
...process.env,
|
...process.env,
|
||||||
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
|
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
|
||||||
|
PGOPTIONS: `--search_path=${process.env.E2E_DB_SCHEMA ?? 'ema'},public`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where `psql` lives.
|
||||||
|
*
|
||||||
|
* A local client is used when there is one. When there is not — Postgres
|
||||||
|
* running only as a container is the common setup — the same query goes
|
||||||
|
* through `docker compose exec` instead, so the suite does not require a
|
||||||
|
* developer to install a database client for the sake of a few assertions.
|
||||||
|
*
|
||||||
|
* Set `E2E_DB_CONTAINER` to the compose service name to force the container
|
||||||
|
* path, or `E2E_PSQL=1` to insist on a local binary.
|
||||||
|
*/
|
||||||
|
const DB_CONTAINER = process.env.E2E_DB_CONTAINER ?? 'ema-postgres';
|
||||||
|
const COMPOSE_DIR =
|
||||||
|
process.env.E2E_COMPOSE_DIR ??
|
||||||
|
// Anchored on the repo root rather than this file: Playwright may load the
|
||||||
|
// suite as ESM, where `__dirname` does not exist. `process.cwd()` is the
|
||||||
|
// emaui workspace when the config is run from there.
|
||||||
|
resolve(process.cwd(), '../emaapi');
|
||||||
|
|
||||||
|
function hasLocalPsql(): boolean {
|
||||||
|
if (process.env.E2E_PSQL === '1') return true;
|
||||||
|
try {
|
||||||
|
execFileSync('psql', ['--version'], { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const USE_CONTAINER = !hasLocalPsql();
|
||||||
|
|
||||||
export function sql(query: string): string[][] {
|
export function sql(query: string): string[][] {
|
||||||
const out = execFileSync(
|
// Queries name EMA tables unqualified (`license_applications`), which only
|
||||||
|
// resolves with the app's own schema on the path. `iam.` stays explicit.
|
||||||
|
//
|
||||||
|
// Passed as a connection option rather than a leading `SET` statement: psql
|
||||||
|
// prints a result row per command, and a `SET` would land in every caller's
|
||||||
|
// rows as a phantom first entry.
|
||||||
|
const schema = process.env.E2E_DB_SCHEMA ?? 'ema';
|
||||||
|
const psqlArgs = [
|
||||||
|
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
||||||
|
'-d', E2E.database,
|
||||||
|
'-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-tAF', '\t',
|
||||||
|
'-c', query,
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = USE_CONTAINER
|
||||||
|
? execFileSync(
|
||||||
|
'docker',
|
||||||
|
[
|
||||||
|
'compose', 'exec', '-T',
|
||||||
|
// `exec` does not forward the caller's environment, so the schema
|
||||||
|
// search path has to be handed across explicitly.
|
||||||
|
'-e', `PGOPTIONS=${PSQL_ENV.PGOPTIONS}`,
|
||||||
|
DB_CONTAINER, 'psql', ...psqlArgs,
|
||||||
|
],
|
||||||
|
{ cwd: COMPOSE_DIR, env: PSQL_ENV, encoding: 'utf8' },
|
||||||
|
)
|
||||||
|
: execFileSync(
|
||||||
'psql',
|
'psql',
|
||||||
[
|
[
|
||||||
'-h', process.env.E2E_DB_HOST ?? 'localhost',
|
'-h', process.env.E2E_DB_HOST ?? 'localhost',
|
||||||
'-p', process.env.E2E_DB_PORT ?? '5432',
|
'-p', process.env.E2E_DB_PORT ?? '5432',
|
||||||
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
...psqlArgs,
|
||||||
'-d', E2E.database,
|
|
||||||
'-tAF', '\t',
|
|
||||||
'-c', query,
|
|
||||||
],
|
],
|
||||||
{ env: PSQL_ENV, encoding: 'utf8' },
|
{ env: PSQL_ENV, encoding: 'utf8' },
|
||||||
);
|
);
|
||||||
|
|||||||
68
apps/e2e/src/support/officer.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
import { E2E } from '../../playwright.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backoffice side of a flow.
|
||||||
|
*
|
||||||
|
* Every registration test needs an officer to act on what the applicant filed,
|
||||||
|
* and the only account the IAM seed creates is the super admin — which holds
|
||||||
|
* every permission, so it can claim, return, reject and approve without a
|
||||||
|
* fixture inventing a position first.
|
||||||
|
*
|
||||||
|
* That breadth is also a limitation worth naming: these tests prove the actions
|
||||||
|
* work, not that a *review officer* specifically may perform them. Per-role
|
||||||
|
* authorisation needs its own accounts and belongs in its own spec.
|
||||||
|
*/
|
||||||
|
export const OFFICER = {
|
||||||
|
email: process.env.E2E_OFFICER_EMAIL ?? 'superadmin@tria.com',
|
||||||
|
password: process.env.E2E_OFFICER_PASSWORD ?? 'password@tria',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Signs the officer into the backoffice, which is a separate origin. */
|
||||||
|
export async function logInAsOfficer(page: Page): Promise<void> {
|
||||||
|
await page.goto(`${E2E.backofficeUrl}/login`);
|
||||||
|
await page.getByLabel(/email/i).fill(OFFICER.email);
|
||||||
|
await page.getByLabel(/password/i).fill(OFFICER.password);
|
||||||
|
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens one application's review screen by its application number.
|
||||||
|
*
|
||||||
|
* Goes through the seafarer registration queue rather than deep-linking by id:
|
||||||
|
* the queue is what an officer actually uses, and a test that skips it would
|
||||||
|
* not notice the application failing to appear there at all.
|
||||||
|
*/
|
||||||
|
export async function openInQueue(
|
||||||
|
page: Page,
|
||||||
|
applicationNumber: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto(`${E2E.backofficeUrl}/licence-review/type/SEAFARER_REGISTRATION`);
|
||||||
|
const row = page.getByRole('row', { name: new RegExp(applicationNumber, 'i') });
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clicks a workflow action and waits for the status to settle.
|
||||||
|
*
|
||||||
|
* Actions live behind buttons whose labels the design owns, so each is matched
|
||||||
|
* by a loose pattern rather than an exact string — a rename should not read as
|
||||||
|
* a broken workflow.
|
||||||
|
*/
|
||||||
|
export async function act(
|
||||||
|
page: Page,
|
||||||
|
action: RegExp,
|
||||||
|
confirm: RegExp = /confirm|submit|yes|save|approve|reject|send/i,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: action }).first().click();
|
||||||
|
|
||||||
|
// Most actions raise a modal; some apply directly. Either is fine.
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
if (await dialog.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||||
|
const button = dialog.getByRole('button', { name: confirm }).first();
|
||||||
|
if (await button.isVisible().catch(() => false)) await button.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
apps/e2e/src/support/workflow.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { APIRequestContext, request } from '@playwright/test';
|
||||||
|
import { E2E } from '../../playwright.config';
|
||||||
|
import { OFFICER } from './officer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workflow endpoints, called as the officer.
|
||||||
|
*
|
||||||
|
* Used where a test's subject is what an approval *does* — the seafarer
|
||||||
|
* number, the activated record, the two child applications — rather than which
|
||||||
|
* buttons produce it. Driving six sections of wizard and a review screen to
|
||||||
|
* reach a completion effect would make those tests about forms.
|
||||||
|
*
|
||||||
|
* Nothing here writes to the database directly. The completion effect runs
|
||||||
|
* inside the API's approval transaction, so a test that faked the status would
|
||||||
|
* assert against an approval that never happened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Routes served by the applicant-facing controller rather than the review one. */
|
||||||
|
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
|
||||||
|
|
||||||
|
async function officerContext(): Promise<APIRequestContext> {
|
||||||
|
const context = await request.newContext({ baseURL: E2E.apiUrl });
|
||||||
|
const response = await context.post('/auth/login', {
|
||||||
|
data: { email: OFFICER.email, password: OFFICER.password },
|
||||||
|
});
|
||||||
|
if (!response.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`Officer login failed (${response.status()}): ${await response.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = await response.json();
|
||||||
|
const token = body?.token ?? body?.accessToken ?? body?.access_token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`No access token in login response: ${JSON.stringify(body)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.dispose();
|
||||||
|
return request.newContext({
|
||||||
|
baseURL: E2E.apiUrl,
|
||||||
|
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStep {
|
||||||
|
/** Route under the review controller, e.g. `claim`, `final-approve`. */
|
||||||
|
path: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
/** Set when a step is expected to be refused — the refusal is the assertion. */
|
||||||
|
expectFailure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a sequence of workflow calls against one application.
|
||||||
|
*
|
||||||
|
* Returns each step's status code so a caller can assert on a refusal as
|
||||||
|
* readily as on a success — "an officer may not evaluate a registration" is a
|
||||||
|
* result worth checking, not an error to swallow.
|
||||||
|
*/
|
||||||
|
export async function runWorkflow(
|
||||||
|
applicationId: string,
|
||||||
|
steps: WorkflowStep[],
|
||||||
|
): Promise<number[]> {
|
||||||
|
const api = await officerContext();
|
||||||
|
const codes: number[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const step of steps) {
|
||||||
|
// Applicant-side actions (`submit`, `resubmit`) live on the
|
||||||
|
// applications controller; everything an officer does is on the review
|
||||||
|
// controller. Routing by step keeps callers from having to know.
|
||||||
|
const base = APPLICANT_STEPS.has(step.path)
|
||||||
|
? 'license-applications'
|
||||||
|
: 'license-application-review';
|
||||||
|
const response = await api.post(
|
||||||
|
`/${base}/${applicationId}/${step.path}`,
|
||||||
|
{ data: step.data ?? {} },
|
||||||
|
);
|
||||||
|
codes.push(response.status());
|
||||||
|
|
||||||
|
if (!step.expectFailure && !response.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claim then final-approve — the whole officer path for a registration. */
|
||||||
|
export async function approveRegistration(applicationId: string): Promise<void> {
|
||||||
|
await runWorkflow(applicationId, [
|
||||||
|
{ path: 'claim' },
|
||||||
|
{ path: 'final-approve', data: { remark: 'E2E approval' } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -407,23 +407,23 @@ export function LicenseApplicationPage() {
|
|||||||
const currentStep = steps[active];
|
const currentStep = steps[active];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the current step before moving on.
|
* Checks one step before moving past it.
|
||||||
*
|
*
|
||||||
* The server rejects an incomplete application anyway, but only at submit —
|
* The server rejects an incomplete application anyway, but only at submit —
|
||||||
* by then the applicant has walked through every step and has to hunt for
|
* by then the applicant has walked through every step and has to hunt for
|
||||||
* what was missing. Validating per step points at the field directly.
|
* what was missing. Validating per step points at the field directly.
|
||||||
|
*
|
||||||
|
* Takes the step rather than reading `currentStep`, so a jump ahead can
|
||||||
|
* check each step it passes over instead of only the one being left.
|
||||||
*/
|
*/
|
||||||
async function validateCurrentStep(): Promise<boolean> {
|
async function validateStep(index: number): Promise<boolean> {
|
||||||
|
const step = steps[index];
|
||||||
// The wizard does not render until the configuration has loaded, but this
|
// The wizard does not render until the configuration has loaded, but this
|
||||||
// is declared above that guard, so narrow it here too.
|
// is declared above that guard, so narrow it here too.
|
||||||
if (!currentStep || !config) return true;
|
if (!step || !config) return true;
|
||||||
|
|
||||||
if (currentStep.kind === "sections") {
|
if (step.kind === "sections") {
|
||||||
const errors = validateSections(
|
const errors = validateSections(step.sections, draft, i18n.language);
|
||||||
currentStep.sections,
|
|
||||||
draft,
|
|
||||||
i18n.language,
|
|
||||||
);
|
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
const count = Object.keys(errors).length;
|
const count = Object.keys(errors).length;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -437,7 +437,7 @@ export function LicenseApplicationPage() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep.kind === "staff") {
|
if (step.kind === "staff") {
|
||||||
const missing = config.staffRoleRequirements
|
const missing = config.staffRoleRequirements
|
||||||
.filter(
|
.filter(
|
||||||
(role) =>
|
(role) =>
|
||||||
@@ -461,7 +461,7 @@ export function LicenseApplicationPage() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStep.kind === "documents") {
|
if (step.kind === "documents") {
|
||||||
const supplied = new Set(
|
const supplied = new Set(
|
||||||
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
|
attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
|
||||||
);
|
);
|
||||||
@@ -495,6 +495,11 @@ export function LicenseApplicationPage() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The step the applicant is on — what `Continue` validates. */
|
||||||
|
async function validateCurrentStep(): Promise<boolean> {
|
||||||
|
return validateStep(active);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
// A locked step during an adjustment round has nothing to validate.
|
// A locked step during an adjustment round has nothing to validate.
|
||||||
if (!readOnly && !(await validateCurrentStep())) return;
|
if (!readOnly && !(await validateCurrentStep())) return;
|
||||||
@@ -506,19 +511,39 @@ export function LicenseApplicationPage() {
|
|||||||
setActive((s) => Math.min(steps.length - 1, s + 1));
|
setActive((s) => Math.min(steps.length - 1, s + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Going back is always allowed; going forward validates each step passed. */
|
/**
|
||||||
|
* Going back is always allowed; going forward validates every step passed.
|
||||||
|
*
|
||||||
|
* `target` used to be discarded on the forward path — the handler validated
|
||||||
|
* the current step and then advanced by exactly one, so clicking "4" from
|
||||||
|
* step 1 landed on step 2. Two steps then showed the same content one click
|
||||||
|
* apart, which reads as a broken wizard rather than a refused jump, and made
|
||||||
|
* the later sections look absent entirely.
|
||||||
|
*
|
||||||
|
* Each step between here and `target` is validated and saved in order, so a
|
||||||
|
* jump ahead cannot skip a required field the way a plain `setActive` would.
|
||||||
|
* The walk stops at the first step that fails, leaving the applicant on it
|
||||||
|
* with its errors showing.
|
||||||
|
*/
|
||||||
async function goToStep(target: number) {
|
async function goToStep(target: number) {
|
||||||
if (target <= active) {
|
if (target <= active) {
|
||||||
setActive(target);
|
setActive(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!readOnly && !(await validateCurrentStep())) return;
|
|
||||||
if (currentStep?.kind === "sections") {
|
for (let step = active; step < target; step++) {
|
||||||
for (const section of currentStep.sections)
|
if (!readOnly && !(await validateStep(step))) {
|
||||||
await saveSection(section.key);
|
setActive(step);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const passed = steps[step];
|
||||||
|
if (passed?.kind === "sections") {
|
||||||
|
for (const section of passed.sections) await saveSection(section.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setActive(active + 1);
|
setActive(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { typeCode } = useParams();
|
const { typeCode } = useParams();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
|
const { isLoading, isFetching, error, gapsFor, profile } = useCurrentProfile();
|
||||||
|
|
||||||
// Shared wizard route — only the seafarer type is gated here.
|
// Shared wizard route — only the seafarer type is gated here.
|
||||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||||
@@ -81,6 +81,15 @@ export function RequireSeafarerProfile({ children }: { children: React.ReactNode
|
|||||||
return <PageLoader label={t('profileGate.checkingProfile')} height={350} />;
|
return <PageLoader label={t('profileGate.checkingProfile')} height={350} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already registered: the number is permanent and the server now refuses a
|
||||||
|
// second registration outright (409 seafarer_already_registered). Sending
|
||||||
|
// them on beats opening a wizard whose first act — creating the draft — is
|
||||||
|
// the call that fails. Checked after the loading guard so an unresolved
|
||||||
|
// profile is never read as "not registered".
|
||||||
|
if (profile?.seafarerNumber) {
|
||||||
|
return <Navigate to="/seaman-book" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
// A failed lookup must not lock anyone out — the server still refuses the
|
// A failed lookup must not lock anyone out — the server still refuses the
|
||||||
// application for a profile it can't fill in from.
|
// application for a profile it can't fill in from.
|
||||||
if (error) return <>{children}</>;
|
if (error) return <>{children}</>;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { NavItem } from "@ema-platform/ui";
|
|||||||
import {
|
import {
|
||||||
BrandMark,
|
BrandMark,
|
||||||
logout,
|
logout,
|
||||||
|
useCurrentProfile,
|
||||||
usePermissions,
|
usePermissions,
|
||||||
LICENSE_PERMISSIONS,
|
LICENSE_PERMISSIONS,
|
||||||
PORTAL_PERMISSIONS,
|
PORTAL_PERMISSIONS,
|
||||||
@@ -147,21 +148,30 @@ export function PortalLayout() {
|
|||||||
refetchOnMountOrArgChange: false,
|
refetchOnMountOrArgChange: false,
|
||||||
});
|
});
|
||||||
const { permissions: granted, known } = usePermissions();
|
const { permissions: granted, known } = usePermissions();
|
||||||
|
// A seafarer registers once; the number is permanent. Once it exists the
|
||||||
|
// registration item is dropped rather than left to bounce off
|
||||||
|
// RequireSeafarerProfile's redirect.
|
||||||
|
const { profile } = useCurrentProfile();
|
||||||
|
const registered = Boolean(profile?.seafarerNumber);
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
const translated = NAV_SECTIONS.map((section) => ({
|
const translated = NAV_SECTIONS.map((section) => ({
|
||||||
label: section.label,
|
label: section.label,
|
||||||
items: section.items.map(({ i18nKey, ...rest }) => ({
|
items: section.items
|
||||||
|
.filter((item) => !(registered && item.to === "/seafarer-registration"))
|
||||||
|
.map(({ i18nKey, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
label: t(i18nKey),
|
label: t(i18nKey),
|
||||||
badge:
|
badge:
|
||||||
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
|
rest.to === "/notifications" && unseen?.count
|
||||||
|
? unseen.count
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
// Unfiltered until the grant list has loaded — same fail-open rule as
|
// Unfiltered until the grant list has loaded — same fail-open rule as
|
||||||
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
// RequirePermission: a moment of extra nav beats a flash of empty nav.
|
||||||
return known ? filterByPermissions(translated, granted) : translated;
|
return known ? filterByPermissions(translated, granted) : translated;
|
||||||
}, [t, unseen?.count, granted, known]);
|
}, [t, unseen?.count, granted, known, registered]);
|
||||||
|
|
||||||
// Breadcrumb trail
|
// Breadcrumb trail
|
||||||
const segments = location.pathname.split("/").filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|||||||
@@ -264,6 +264,16 @@ export interface WizardStep {
|
|||||||
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
|
* the stepper short. Anything ungrouped keeps a step of its own, so a licence
|
||||||
* type that has not been grouped still behaves exactly as before.
|
* type that has not been grouped still behaves exactly as before.
|
||||||
*/
|
*/
|
||||||
|
/** "identitySummary" -> "Identity Summary". Last resort for an untitled group. */
|
||||||
|
function humanise(key: string): string {
|
||||||
|
return key
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/^./, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWizardSteps(
|
export function buildWizardSteps(
|
||||||
sections: FormSectionConfig[],
|
sections: FormSectionConfig[],
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
@@ -304,7 +314,11 @@ export function buildWizardSteps(
|
|||||||
}
|
}
|
||||||
const step: WizardStep = {
|
const step: WizardStep = {
|
||||||
key: `group:${group}`,
|
key: `group:${group}`,
|
||||||
label: group,
|
// A group is a config key, not a caption — showing it raw put
|
||||||
|
// "identitySummary" and "applicant" in front of applicants. The first
|
||||||
|
// section's own title is the readable name for the step it opens; the
|
||||||
|
// humanised key is the fallback when a section carries no title.
|
||||||
|
label: localized(section.title, options?.language) || humanise(group),
|
||||||
kind: 'sections',
|
kind: 'sections',
|
||||||
sections: [section],
|
sections: [section],
|
||||||
};
|
};
|
||||||
|
|||||||
13609
pnpm-lock.yaml
generated
Normal file
@@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
"status": "passed",
|
"status": "failed",
|
||||||
"failedTests": []
|
"failedTests": [
|
||||||
|
"98dcbc0c174eb3697418-75794b7db9eaf01c737f",
|
||||||
|
"98dcbc0c174eb3697418-34fd1a52a2c14f879d3a",
|
||||||
|
"98dcbc0c174eb3697418-bd195edac5a95d796827",
|
||||||
|
"98dcbc0c174eb3697418-c505dae67d8cd7469ff3",
|
||||||
|
"98dcbc0c174eb3697418-ba62eb9d11839aca30c0",
|
||||||
|
"98dcbc0c174eb3697418-07ce101789b6b7b7985c",
|
||||||
|
"98dcbc0c174eb3697418-1d2cbda982bd085da606",
|
||||||
|
"98dcbc0c174eb3697418-46dd670046a70e730e93"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,348 @@
|
|||||||
|
# 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);
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 92 KiB |