Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-18 14:54:05 +03:00
42 changed files with 17179 additions and 40 deletions

View 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' }]);
}

View File

@@ -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,21 +13,77 @@ 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
'psql', // resolves with the app's own schema on the path. `iam.` stays explicit.
[ //
'-h', process.env.E2E_DB_HOST ?? 'localhost', // Passed as a connection option rather than a leading `SET` statement: psql
'-p', process.env.E2E_DB_PORT ?? '5432', // prints a result row per command, and a `SET` would land in every caller's
'-U', process.env.E2E_DB_USER ?? 'postgres', // rows as a phantom first entry.
'-d', E2E.database, const schema = process.env.E2E_DB_SCHEMA ?? 'ema';
'-tAF', '\t', const psqlArgs = [
'-c', query, '-U', process.env.E2E_DB_USER ?? 'postgres',
], '-d', E2E.database,
{ env: PSQL_ENV, encoding: 'utf8' }, '-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',
[
'-h', process.env.E2E_DB_HOST ?? 'localhost',
'-p', process.env.E2E_DB_PORT ?? '5432',
...psqlArgs,
],
{ env: PSQL_ENV, encoding: 'utf8' },
);
return out return out
.split('\n') .split('\n')
.filter((line) => line.trim() !== '') .filter((line) => line.trim() !== '')

View 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();
}
}

View 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' } },
]);
}

View File

@@ -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 (

View File

@@ -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}</>;

View File

@@ -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
...rest, .filter((item) => !(registered && item.to === "/seafarer-registration"))
label: t(i18nKey), .map(({ i18nKey, ...rest }) => ({
badge: ...rest,
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined, label: t(i18nKey),
})), badge:
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);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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"
]
} }

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -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);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB