diff --git a/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx new file mode 100644 index 000000000..018dac7fe --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/ApplicantCard.tsx @@ -0,0 +1,108 @@ +import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { ApplicationApplicant } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; + +interface ApplicantCardProps { + applicant: ApplicationApplicant; +} + +/** + * Who the reviewer is deciding about. + * + * A company licence names itself in the page title (`companyName`); a seafarer + * registration has no company, so the officer's screen led with an application + * number and the human behind it was somewhere in the form answers. This puts + * the identity where it belongs on a person-centric review: name, national ID, + * contact, and — for a seafarer who already holds one — their number and + * standing, which is what says whether this is a first registration or a + * duplicate. + * + * Read-only and sourced from the profile, not the form: this is the record the + * registration will be written onto, so a reviewer comparing the two is exactly + * the intended use. + */ +export function ApplicantCard({ applicant }: ApplicantCardProps) { + const { t } = useTranslation(); + const showDate = useDateDisplayer(); + + const fullName = [applicant.firstName, applicant.middleName, applicant.lastName] + .filter(Boolean) + .join(' '); + const initials = [applicant.firstName, applicant.lastName] + .filter(Boolean) + .map((part) => part?.[0]?.toUpperCase() ?? '') + .join(''); + + return ( + + + + {initials || '—'} + +
+ + {fullName || t('review.nameMissing', 'Name not on profile')} + + {applicant.seafarerNumber ? ( + + + {applicant.seafarerNumber} + + {applicant.seafarerStatus && ( + + {applicant.seafarerStatus} + + )} + + ) : ( + + {t('review.notYetRegistered', 'Not yet registered')} + + )} +
+
+ + + + + + + + + +
+ ); +} + +/** One label/value line, omitted entirely when there is nothing to show. */ +function Row({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); +} diff --git a/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx b/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx new file mode 100644 index 000000000..05d620093 --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/components/FormDetailsTab.tsx @@ -0,0 +1,265 @@ +import { + Badge, + Card, + Checkbox, + Divider, + Grid, + Group, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { + conditionHolds, + displayFieldValue, + useLocalized, + type FormFieldConfig, + type FormSectionConfig, +} from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; + +/** A section as it will be rendered: config where there is some, key otherwise. */ +interface ResolvedSection { + key: string; + title: string; + description?: string; + fields: { field: FormFieldConfig; value: unknown }[]; +} + +interface FormDetailsTabProps { + /** The application's answers, keyed by section. */ + formData: Record>; + /** The licence type's form schema — the order and labels to render by. */ + configSections: FormSectionConfig[]; + currency?: string; + /** sectionKey -> remark. Owned by the review page. */ + flags: Record; + onToggleFlag: (sectionKey: string) => void; + onFlagRemark: (sectionKey: string, remark: string) => void; + /** Resolves a location id to a readable path, when the tree is loaded. */ + resolveLocation?: (locationId: string) => string | undefined; +} + +/** + * What the applicant actually filled in, as the reviewing officer reads it. + * + * Replaces a set of bordered key/value tables built by walking `formData`. + * Three things were wrong with that, all of them worse on a person-centric + * registration than on a company licence: + * + * - Values were printed with `String(v)`, so a reviewer deciding on a seafarer + * read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers + * anybody chose. Now resolved through the same field config that rendered + * the input, shared with the applicant's own summary (`displayFieldValue`). + * - Order came from jsonb key order, which is arbitrary: the declaration could + * appear above the emergency contact. Now the schema's `sortOrder` decides, + * which is the order the applicant filled them in. + * - A location answer is a uuid. Shown raw it told the reviewer nothing; + * resolved, it reads "Addis Ababa → Bole → Woreda 03". + */ +export function FormDetailsTab({ + formData, + configSections, + currency, + flags, + onToggleFlag, + onFlagRemark, + resolveLocation, +}: FormDetailsTabProps) { + const { t, i18n } = useTranslation(); + const localized = useLocalized(); + const showDate = useDateDisplayer(); + + const sections = resolveSections(); + + /** + * Sections in schema order, each with its fields in schema order. + * + * Anything present in `formData` but absent from the schema is still shown, + * appended after the configured sections — a stale answer from a since-edited + * form is exactly the kind of thing a reviewer needs to see, not something to + * hide because the config moved on. + */ + function resolveSections(): ResolvedSection[] { + const configured = [...configSections] + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map((section) => { + const values = formData[section.key] ?? {}; + const fields = [...(section.fields ?? [])] + .filter((f) => conditionHolds(f.showWhen, formData)) + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map((field) => ({ field, value: values[field.key] })); + return { + key: section.key, + title: localized(section.title) || section.key, + description: localized(section.description) || undefined, + fields, + }; + }) + // A section the applicant never reached is noise on a review screen. + .filter((s) => s.fields.some((f) => hasValue(f.value))); + + const configuredKeys = new Set(configSections.map((s) => s.key)); + const orphans: ResolvedSection[] = Object.entries(formData) + .filter(([key, values]) => !configuredKeys.has(key) && values) + .map(([key, values]) => ({ + key, + title: humanise(key), + fields: Object.entries(values).map(([fieldKey, value]) => ({ + // No config to render by, so it is treated as free text under a + // humanised key rather than dropped. + field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig, + value, + })), + })); + + return [...configured, ...orphans]; + } + + function display(field: FormFieldConfig, value: unknown): string { + // A location is stored as a tree id; the reviewer needs the place. + if (isLocationField(field) && typeof value === 'string' && value) { + return resolveLocation?.(value) ?? value; + } + return displayFieldValue(field, value, { + language: i18n.language, + showDate, + currency, + }); + } + + return ( + + {sections.map((section) => { + const flagged = Boolean(flags[section.key]); + const missing = section.fields.filter((f) => !hasValue(f.value)).length; + + return ( + + + +
+ + + {section.title} + + {missing > 0 && ( + + } + > + {missing} + + + )} + + {section.description && ( + + {section.description} + + )} +
+ onToggleFlag(section.key)} + style={{ flexShrink: 0 }} + /> +
+ + + + {/* Label above value, two per row — a reviewer scans a definition + list far faster than a bordered table of the same answers. */} + + {section.fields.map(({ field, value }) => { + const text = display(field, value); + const answered = hasValue(value) && text !== ''; + return ( + + + {localized(field.label) || field.key} + + + {answered && isLocationField(field) && ( + + )} + + {answered + ? text + : t('review.notProvided', 'Not provided')} + + + + ); + })} + + + {flagged && ( + { + // Read here, not inside the updater: React nulls + // `currentTarget` when the handler returns, and the updater + // runs afterwards during the re-render. + onFlagRemark(section.key, e.currentTarget.value); + }} + /> + )} +
+
+ ); + })} +
+ ); +} + +function hasValue(value: unknown): boolean { + return value !== null && value !== undefined && value !== ''; +} + +/** English-pinned, like the portal's own location override. */ +function isLocationField(field: Pick): boolean { + return ( + field.key === 'locationId' || + (field.label?.en ?? '').trim().toLowerCase() === 'location' + ); +} + +function humanise(key: string): string { + const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim(); +} diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index 4c02b4988..8026cff66 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -260,6 +260,28 @@ export interface ResolveContext { hasPendingInspection: boolean; } +/** + * Action ids that are workflow events, so `availableEvents` decides them. + * + * The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are + * screens and side effects rather than transitions, and the server has no + * opinion on them — those keep using their own `from` list. + */ +const WORKFLOW_EVENT_IDS = new Set([ + 'claim', + 'assign', + 'escalate', + 'hold', + 'resume', + 'complete-review', + 'approve-documents', + 'record-inspection', + 'final-approve', + 'request-adjustment', + 'reject', + 'confirm-payment', +]); + /** * Which actions to render, and for each, whether it can fire and why not. * @@ -267,16 +289,35 @@ export interface ResolveContext { * are merely unavailable right now are kept and disabled with a reason, so the * officer can see what the next step would be rather than wondering whether * the screen is broken. + * + * For anything that is a workflow event, `detail.availableEvents` is the + * authority on what fires from here — it comes from the same transition table + * the server validates against, and it is workflow-profile aware. The local + * `from` lists describe the licence course only, so a registration (which skips + * evaluation and inspection, and approves straight out of UNDER_REVIEW) was + * offered Complete Review — rejected server-side with + * `event_not_available_for_service` — while Final Approve, the one action that + * would work, was hidden. */ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { const { detail, currentUserId, can, reasons } = ctx; const app = detail.application; + const serverEvents = detail.availableEvents; return ACTIONS.filter((action) => can(action.permissions)).flatMap( (action) => { // Status-scoped actions vanish outside their stage rather than piling up // as a column of permanently dead buttons. - if (action.from && !action.from.includes(app.status)) return []; + if (WORKFLOW_EVENT_IDS.has(action.id)) { + // Tolerate an older server that sends no list rather than rendering an + // empty action bar. + if (serverEvents?.length && !serverEvents.includes(action.id)) return []; + if (!serverEvents?.length && action.from && !action.from.includes(app.status)) { + return []; + } + } else if (action.from && !action.from.includes(app.status)) { + return []; + } // Scheduling and recording are the same slot at the same status; which // one applies depends on whether an inspection is already booked. diff --git a/apps/backoffice/src/app/features/license-review/config/license-types.ts b/apps/backoffice/src/app/features/license-review/config/license-types.ts index df2d24688..379d74c28 100644 --- a/apps/backoffice/src/app/features/license-review/config/license-types.ts +++ b/apps/backoffice/src/app/features/license-review/config/license-types.ts @@ -87,6 +87,23 @@ const PRESENTATION: Record = { // Person-centric: no company entity, no capital threshold, no staff roles. detailSections: ['overview', 'documents'], }, + // Opened automatically when a registration is approved, and reviewed like any + // other person-centric service. Listed explicitly because neither key matches + // the certificate prefixes below, so both fell through to the company-shaped + // default and offered an officer Company, Financials and Staff tabs for an + // application about one person. + SEAMAN_BOOK: { + key: 'SEAMAN_BOOK', + icon: IconId, + // Its own TRB inspection is a real stage, unlike the other personal + // services, so the inspection tab stays. + detailSections: ['overview', 'documents', 'inspection'], + }, + BTC_BASIC_TRAINING: { + key: 'BTC_BASIC_TRAINING', + icon: IconShieldCheck, + detailSections: ['overview', 'documents'], + }, VESSEL_REGISTRATION: { key: 'VESSEL_REGISTRATION', icon: IconAnchor, diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index 87b62ca14..14ee10e90 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -4,8 +4,6 @@ import { ActionIcon, Alert, Badge, - Card, - Checkbox, Container, Grid, Group, @@ -15,11 +13,9 @@ import { SegmentedControl, Skeleton, Stack, - Table, Tabs, Text, Textarea, - TextInput, ThemeIcon, Timeline, Title, @@ -38,6 +34,7 @@ import { useTranslation } from 'react-i18next'; import { STATUS_COLORS, STATUS_LABELS, + applicantOrCompanyName, extractErrorMessage, useLocalized, useApproveDocumentsMutation, @@ -77,6 +74,9 @@ import { } from '../../components/DecisionConfirmModal'; import { ActivityRail } from '../../components/ActivityRail'; import { DocumentsTab } from '../../components/DocumentsTab'; +import { FormDetailsTab } from '../../components/FormDetailsTab'; +import { ApplicantCard } from '../../components/ApplicantCard'; +import { useGetLocationsQuery } from '../../../location/api/location-api'; import { ScheduleExamModal } from '../../components/ScheduleExamModal'; import { computeSla } from '../../sla'; import { reviewStaffColumns } from './columns'; @@ -258,6 +258,45 @@ export function LicenseReviewPage() { }); }, [data, currentUserId, can, flagged.length, pendingInspection, t]); + // Location answers are tree ids. The picker the applicant used resolves them + // client-side from the same list, so the reviewer reads the place rather than + // the uuid. Fetched only when the form actually has a location field. + // + // Above the early returns because it is a hook: React requires the same hook + // order on every render, and the loading and error branches return before the + // application is known. + const needsLocations = ( + requirements?.licenseType.formSchema.sections ?? [] + ).some((section) => + (section.fields ?? []).some( + (f) => + f.key === 'locationId' || + (f.label?.en ?? '').trim().toLowerCase() === 'location', + ), + ); + const { data: locationsRes } = useGetLocationsQuery( + { take: 10000 }, + { skip: !needsLocations }, + ); + const resolveLocation = useMemo(() => { + const all = locationsRes?.items ?? []; + if (all.length === 0) return undefined; + const byId = new Map(all.map((loc) => [loc.id, loc])); + return (locationId: string) => { + // Walks to the root so the answer reads as a place, not a leaf name: + // "Woreda 03" alone does not say which sub-city it belongs to. Bounded by + // the map size so a cyclic tree cannot spin the render. + const path: string[] = []; + let current = byId.get(locationId); + let hops = 0; + while (current && hops++ <= byId.size) { + path.unshift(localized(current.names) || current.code); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return path.length ? path.join(' → ') : undefined; + }; + }, [locationsRes, localized]); + if (isLoading) { // Skeleton mirrors the real three-zone layout so nothing jumps on load. return ( @@ -515,19 +554,40 @@ export function LicenseReviewPage() { } const sections = presentation.detailSections; - const formSections = Object.entries(app.formData ?? {}); + const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0; // The bilingual section/field labels the applicant's wizard renders — this // page already fetches them (`requirements` above) but used to fall back to // the raw formData keys, so an officer saw `vesselId` instead of a label in // either language. const configSections = requirements?.licenseType.formSchema.sections ?? []; - const sectionsByKey = new Map(configSections.map((s) => [s.key, s])); + + // A person-centric service has no company, so the company-shaped facts are + // not merely empty — they are the wrong question. TIN is hidden rather than + // shown blank, and the applicant's own identity card takes its place. + const isPersonal = !app.companyName; + const applicant = data.applicant; + const applicantFullName = [ + applicant?.firstName, + applicant?.middleName, + applicant?.lastName, + ] + .filter(Boolean) + .join(' '); + // The profile is the reliable name for a personal service — `formData.account` + // holds one only for applications filed after that field was added, and the + // application number identifies the paperwork rather than the person. + const headerName = + app.companyName || + applicantFullName || + applicantOrCompanyName(app) || + app.applicationNumber; + return (
- {app.companyName ?? app.applicationNumber} + {headerName} {app.applicationNumber} @@ -565,13 +625,21 @@ export function LicenseReviewPage() { {/* Zone 1 — sticky summary rail. */} + {/* Who, before what: a person-centric review is about the applicant, + and the licence facts below are the context. */} + {isPersonal && applicant && } + {t('review.summary', 'Summary')} - + {/* A person has no TIN; showing the row blank invited the + reviewer to wonder what was missing. */} + {!isPersonal && ( + + )} {/* Tabs with nothing behind them are not rendered at all. */} - {sections.includes('overview') && formSections.length > 0 && ( + {sections.includes('overview') && hasFormAnswers && ( {t('review.tabs.overview', 'Overview')} )} {sections.includes('financials') && ( @@ -675,83 +743,20 @@ export function LicenseReviewPage() { - - {formSections.map(([sectionKey, values]) => { - const sectionConfig = sectionsByKey.get(sectionKey); - const fieldsByKey = new Map( - (sectionConfig?.fields ?? []).map((f) => [f.key, f]), - ); - return ( - - - - {sectionConfig - ? localized(sectionConfig.title) - : sectionKey.replace(/([A-Z])/g, ' $1')} - - toggleFlag('FORM_SECTION', sectionKey)} - /> - - - - {Object.entries(values ?? {}).map(([k, v]) => { - const fieldConfig = fieldsByKey.get(k); - return ( - - - - {fieldConfig ? localized(fieldConfig.label) : k} - - - - {v === null ? '—' : String(v)} - - - ); - })} - -
- {flags[sectionKey] && ( - { - // Read here, not inside the updater: React nulls - // `currentTarget` when the handler returns, and the - // updater runs afterwards during the re-render — - // which crashed the page on the first keystroke. - const remark = e.currentTarget.value; - setFlags((p) => ({ - ...p, - [sectionKey]: { ...p[sectionKey], remark }, - })); - }} - /> - )} -
- ); - })} -
+ toggleFlag('FORM_SECTION', sectionKey)} + onFlagRemark={(sectionKey, remark) => + setFlags((p) => ({ + ...p, + [sectionKey]: { ...p[sectionKey], remark }, + })) + } + resolveLocation={resolveLocation} + />
@@ -905,7 +910,11 @@ export function LicenseReviewPage() { setScheduleExamOpen(false)} onConfirm={async (payload) => { diff --git a/apps/e2e/src/seafarer-registration.spec.ts b/apps/e2e/src/seafarer-registration.spec.ts index 65df712cb..3c192a985 100644 --- a/apps/e2e/src/seafarer-registration.spec.ts +++ b/apps/e2e/src/seafarer-registration.spec.ts @@ -6,7 +6,11 @@ import { verifyOtpIfPrompted, } from './support/applicant'; import { deleteApplicant, sql, sqlValue } from './support/db'; -import { approveRegistration, runWorkflow } from './support/workflow'; +import { + approveRegistration, + resolveOpenRemarks, + runWorkflow, +} from './support/workflow'; /** * Seafarer registration, applicant through to approval. @@ -35,13 +39,20 @@ import { approveRegistration, runWorkflow } from './support/workflow'; * be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which * field lives where. */ -async function completeProfile(page: Page): Promise { +async function completeProfile( + page: Page, + applicant: Applicant, +): Promise { 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'); + // The account's own name parts, not invented ones: the Maritime tab refuses + // to save when they do not join to the name on the Personal tab, and it + // refuses by returning early — no request, no field error, so the failure + // surfaced only as "save produced no request". + await page.getByLabel('First Name').fill(applicant.firstName); + await page.getByLabel('Middle Name').fill(applicant.middleName); + await page.getByLabel('Last Name').fill(applicant.lastName); await pick(page, 'Gender', /male/i); await pickDate(page, 'Date of Birth', '1995-04-12'); await pick(page, 'Marital Status', /single/i); @@ -49,15 +60,16 @@ async function completeProfile(page: Page): Promise { await save(page); await openTab(page, 'Address'); - await pick(page, 'ID Type', /^NID$/i); + // Matched on the option's label, not its stored value: the select shows + // "National Id" and submits `NID`, so `/^NID$/` matched no option at all. + await pick(page, 'ID Type', /^national id$/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'); + // Primary Phone is deliberately not filled: it is `readOnly` here and already + // carries the account's number ("From your account, edit it in the Personal + // tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a + // fill would only fail against a read-only input. await save(page); } @@ -145,19 +157,35 @@ async function save(page: Page): Promise { // 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. + // Field errors only. `[role="alert"]` also matches Mantine's ``, and + // the profile page renders an informational seafarer banner as one — which + // got reported as "validation errors: Seafarer registration asks for these + // details…", pointing at a form that was in fact filled in correctly. const messages = await page - .locator('.mantine-InputWrapper-error, [role="alert"]') + .locator('.mantine-InputWrapper-error') .allTextContents(); throw new Error( messages.length ? `Save did not submit — validation errors: ${messages.join('; ')}` - : 'Save produced no request and reported no validation error.', + : // No field error either, so the form was valid and something else + // refused: `onSaveProfile` early-returns when the profile name does + // not match the account name, and notifies rather than marking a + // field. + 'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).', { cause }, ); } } -/** Signs up, declares seafarer operations, and fills the gating profile. */ +/** + * Signs up, declares seafarer operations, and fills the profile. + * + * Declaring seafarer now lands on the registration wizard, not `/profile` — the + * wizard collects the identity itself. The profile is still filled here because + * these tests are about the registration workflow, and a profile with a name and + * an address is what the approval's completion effect writes onto; `/profile` is + * navigated to directly rather than waited for as a redirect. + */ async function readyApplicant(page: Page, applicant: Applicant): Promise { const offset = await signUp(page, applicant); await verifyOtpIfPrompted(page, offset); @@ -167,10 +195,12 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise { .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(/\/licensing\/SEAFARER_REGISTRATION\/apply/, { + timeout: 30_000, + }); + await page.goto('/profile'); await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - await completeProfile(page); + await completeProfile(page, applicant); } test.describe('seafarer registration', () => { @@ -229,7 +259,7 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await runWorkflow(id, [{ path: 'claim' }]); expect(statusOf(number)).toBe('UNDER_REVIEW'); @@ -253,19 +283,36 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await runWorkflow(id, [ { path: 'claim' }, { path: 'request-adjustment', - data: { remarks: [{ message: 'Medical certificate is illegible.' }] }, + // `RequestAdjustmentDto` takes `items`, each naming what to fix and + // where — a bare `remarks: [{ message }]` is refused with "items should + // not be empty", which reads as an empty request rather than a wrongly + // shaped one. + data: { + items: [ + { + targetType: 'FORM_SECTION', + targetKey: 'medicalCertificate', + remark: 'Medical certificate is illegible.', + }, + ], + }, }, ]); expect(statusOf(number)).toBe('RESUBMIT_REQUIRED'); + // Every flagged item has to be ticked off first: `resubmit` refuses while + // any remark is open (`unresolved_remarks`), which is what stops an + // applicant returning the same form untouched. + await resolveOpenRemarks(id, openRemarkIds(number), applicant); + // A resubmission returns to review directly — a registration has no // earlier stage to fall back to. - await runWorkflow(id, [{ path: 'resubmit' }]); + await runWorkflow(id, [{ path: 'resubmit' }], applicant); expect(statusOf(number)).toBe('UNDER_REVIEW'); }); @@ -275,7 +322,7 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await runWorkflow(id, [ { path: 'claim' }, { path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } }, @@ -293,7 +340,7 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await runWorkflow(id, [ { path: 'claim' }, { path: 'reject', data: { reason: 'Basic training evidence incomplete.' } }, @@ -314,7 +361,7 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await approveRegistration(id); expect(statusOf(number)).toBe('COMPLETED'); @@ -349,7 +396,7 @@ test.describe('seafarer registration', () => { const number = await waitForApplication(applicant.email); const id = idOf(number); - await submit(id); + await submit(id, applicant); await approveRegistration(id); const first = seafarerNumberOf(applicant.email); @@ -367,7 +414,7 @@ test.describe('seafarer registration', () => { await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); const number = await waitForApplication(applicant.email); - await submit(idOf(number)); + await submit(idOf(number), applicant); await approveRegistration(idOf(number)); // The number is permanent and the service is not renewable, so the portal @@ -438,6 +485,17 @@ function seafarerNumberOf(email: string): string | null { `); } +/** Ids of the remarks still open on the current adjustment round. */ +function openRemarkIds(applicationNumber: string): string[] { + return sql(` + SELECT r.id FROM application_remarks r + JOIN license_applications a ON a.id = r.application_id + WHERE a.application_number = '${applicationNumber}' + AND r.is_resolved = false + AND r.round_number = a.adjustment_round + `).map((row) => row[0]); +} + function childrenOf(applicationNumber: string): string[][] { return sql(` SELECT lt.key, a.status, a.origin @@ -452,12 +510,98 @@ function childrenOf(applicationNumber: string): string[][] { } /** - * Submits the draft. + * Fills the draft's answers and evidence directly, so it can be submitted. * - * 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. + * These tests are about the workflow and its approval effects, not the wizard's + * fields — but `submit` validates the whole form and every required document, so + * an unfilled draft cannot reach the workflow at all. Driving six wizard steps + * and four uploads in each test would make them slow tests of the form instead. + * + * So the answers go in as one `form_data` write and the evidence as attachment + * rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins + * attachments to their files and counts document keys, and nothing at submission + * reads a file's bytes — a row with a storage key is exactly as complete as an + * upload, without requiring object storage to be reachable. + * + * Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a + * required field added there fails these with `application_incomplete`, naming + * the field. */ -async function submit(applicationId: string): Promise { - await runWorkflow(applicationId, [{ path: 'submit' }]); +function fillForSubmission(applicationId: string): void { + const locationId = sqlValue(` + SELECT l.id FROM iam.locations l + JOIN iam.location_types lt ON lt.id = l.location_type_id + WHERE lt.code = 'SUBCITY' LIMIT 1 + `); + if (!locationId) { + throw new Error('No SUBCITY location seeded — run the location seed.'); + } + + const formData = JSON.stringify({ + profileSummary: { + firstName: 'Dawit', + middleName: 'Bekele', + lastName: 'Tesfaye', + gender: 'MALE', + dateOfBirth: '1995-04-12', + maritalStatus: 'SINGLE', + nationality: 'Ethiopian', + nationalIdNumber: 'FYD1234567890', + }, + identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' }, + address: { locationId, permanentAddress: 'Bole, Addis Ababa' }, + emergencyContact: { + name: 'Almaz Tesfaye', + relationship: 'Sister', + phoneNumber: '+251911222333', + }, + physicalCharacteristics: { + hairColor: 'BLACK', + eyeColor: 'BROWN', + heightCm: 172, + weightKg: 68, + bloodType: 'O_POSITIVE', + }, + medicalCertificate: { + certificateNumber: 'MED-2026-001', + issuerName: 'Addis Marine Clinic', + issueDate: '2026-01-15', + }, + declaration: { accepted: true }, + }).replace(/'/g, "''"); + + const documentKeys = [ + 'photo', + 'nationalId', + 'medical_certificate', + 'basic_training_evidence', + ]; + + sql(` + UPDATE license_applications + SET form_data = '${formData}'::jsonb + WHERE id = '${applicationId}'; + + WITH inserted AS ( + INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to) + SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365 + FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key + RETURNING id + ) + INSERT INTO attachment_files + (attachment_id, original_name, mime_type, size_bytes, storage_key) + SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf' + FROM inserted; + `); +} + +/** Fills what submission requires, then submits as the applicant. */ +async function submit( + applicationId: string, + applicant: Applicant, +): Promise { + fillForSubmission(applicationId); + // As the applicant: `submit` is ownership-guarded, so the officer's token — + // which every other step here uses — is refused with `not_application_owner`. + await runWorkflow(applicationId, [{ path: 'submit' }], applicant); } diff --git a/apps/e2e/src/support/api-log.ts b/apps/e2e/src/support/api-log.ts index 5cddf304f..464a8057d 100644 --- a/apps/e2e/src/support/api-log.ts +++ b/apps/e2e/src/support/api-log.ts @@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config'; const OTP_PATTERN = /is (\d{4,8})\./g; -/** Byte offset to read from later. Zero when the log does not exist yet. */ +/** + * Byte offset to read from later. Zero when the log does not exist yet. + * + * Bytes, and read back as bytes — see `otpSince`. + */ export function logOffset(): number { try { return statSync(E2E.apiLog).size; @@ -48,7 +52,14 @@ export async function waitForOtp( function otpSince(offset: number): string | null { let text: string; try { - text = readFileSync(E2E.apiLog, 'utf8').slice(offset); + // Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`. + // `logOffset()` is a byte count from `statSync`, while slicing a string + // counts UTF-16 code units, and the API logs Amharic notification bodies: + // every multi-byte character made the offset overshoot, so a code written + // just after it was skipped and the wait timed out. The drift grows with + // the log, which is why this failed intermittently and more often later in + // a run. + text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8'); } catch { return null; } diff --git a/apps/e2e/src/support/applicant.ts b/apps/e2e/src/support/applicant.ts index ba4452de2..d1eb5a991 100644 --- a/apps/e2e/src/support/applicant.ts +++ b/apps/e2e/src/support/applicant.ts @@ -14,18 +14,35 @@ export interface Applicant { username: string; phoneNumber: string; password: string; + /** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */ name: string; + firstName: string; + middleName: string; + lastName: string; } export function newApplicant(label: string): Applicant { const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`; + // The profile's Maritime tab refuses to save unless first/middle/last join to + // exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is + // a silent early return, no request. So the parts are the source of truth here + // and the account name is composed from them, rather than the two being + // written independently and hoped to agree. + // + // Each part is at least three characters, which `profileSchema` requires. + const firstName = 'Dawit'; + const middleName = 'Bekele'; + const lastName = `Tesfaye${stamp.slice(-4)}`; return { email: `e2e.${label}.${stamp}@example.test`, username: `e2e${label}${stamp}`.slice(0, 28), // Ethiopian mobile format; the last digits vary so two runs never collide. phoneNumber: `+2519${stamp.slice(-8)}`, password: 'E2ePassw0rd!', - name: `E2E ${label} ${stamp.slice(-4)}`, + name: `${firstName} ${middleName} ${lastName}`, + firstName, + middleName, + lastName, }; } diff --git a/apps/e2e/src/support/workflow.ts b/apps/e2e/src/support/workflow.ts index 4ae116a88..cec08bc5b 100644 --- a/apps/e2e/src/support/workflow.ts +++ b/apps/e2e/src/support/workflow.ts @@ -18,14 +18,47 @@ import { OFFICER } from './officer'; /** Routes served by the applicant-facing controller rather than the review one. */ const APPLICANT_STEPS = new Set(['submit', 'resubmit']); -async function officerContext(): Promise { - const context = await request.newContext({ baseURL: E2E.apiUrl }); - const response = await context.post('/auth/login', { - data: { email: OFFICER.email, password: OFFICER.password }, +/** + * Resolves every open remark on an application, as the applicant. + * + * `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is + * expected to tick off each correction as they make it, which the portal does + * per section. A test that only wants the round-trip still has to do it. + */ +export async function resolveOpenRemarks( + applicationId: string, + remarkIds: string[], + applicant: { email: string; password: string }, +): Promise { + await runWorkflow( + applicationId, + remarkIds.map((remarkId) => ({ + path: `remarks/${remarkId}/resolve`, + method: 'patch' as const, + })), + applicant, + ); +} + +/** + * An authenticated API context for one account. + * + * Paths built against it are relative on purpose. `E2E.apiUrl` carries the + * `/api` prefix, and a leading slash resolves against the *origin* — + * `/auth/login` against `http://host/api` requests `http://host/auth/login`, + * which 404s. Every path in this file is therefore written without one. + */ +async function contextFor( + who: string, + credentials: { email: string; password: string }, +): Promise { + const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` }); + const response = await context.post('auth/login', { + data: { email: credentials.email, password: credentials.password }, }); if (!response.ok()) { throw new Error( - `Officer login failed (${response.status()}): ${await response.text()}`, + `${who} login failed (${response.status()}): ${await response.text()}`, ); } const body = await response.json(); @@ -36,17 +69,23 @@ async function officerContext(): Promise { await context.dispose(); return request.newContext({ - baseURL: E2E.apiUrl, + baseURL: `${E2E.apiUrl}/`, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); } +function officerContext(): Promise { + return contextFor('Officer', OFFICER); +} + export interface WorkflowStep { /** Route under the review controller, e.g. `claim`, `final-approve`. */ path: string; data?: Record; /** Set when a step is expected to be refused — the refusal is the assertion. */ expectFailure?: boolean; + /** POST unless stated; the applicant's remark-resolve route is a PATCH. */ + method?: 'post' | 'patch'; } /** @@ -59,8 +98,29 @@ export interface WorkflowStep { export async function runWorkflow( applicationId: string, steps: WorkflowStep[], + /** + * The owner, required only when a step is applicant-side. `submit` and + * `resubmit` are guarded by ownership, not permission — the officer holds + * every permission but is not the applicant, so running them on the officer's + * token is refused with `not_application_owner`. + */ + applicant?: { email: string; password: string }, ): Promise { - const api = await officerContext(); + const officer = await officerContext(); + const needsApplicant = steps.some( + (step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'), + ); + if (needsApplicant && !applicant) { + throw new Error( + `Steps [${steps + .filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/')) + .map((s) => s.path) + .join(', ')}] act as the applicant — pass their credentials to runWorkflow.`, + ); + } + const owner = needsApplicant && applicant + ? await contextFor('Applicant', applicant) + : null; const codes: number[] = []; try { @@ -68,13 +128,17 @@ export async function runWorkflow( // 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) + const isApplicantStep = + APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'); + const base = isApplicantStep ? 'license-applications' : 'license-application-review'; - const response = await api.post( - `/${base}/${applicationId}/${step.path}`, - { data: step.data ?? {} }, - ); + const api = isApplicantStep && owner ? owner : officer; + const url = `${base}/${applicationId}/${step.path}`; + const response = + step.method === 'patch' + ? await api.patch(url, { data: step.data ?? {} }) + : await api.post(url, { data: step.data ?? {} }); codes.push(response.status()); if (!step.expectFailure && !response.ok()) { @@ -84,7 +148,8 @@ export async function runWorkflow( } } } finally { - await api.dispose(); + await officer.dispose(); + await owner?.dispose(); } return codes; diff --git a/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx b/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx index d071e8a42..3acfe337b 100644 --- a/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx +++ b/apps/portal/src/app/features/licensing/components/ApplicationSummary.tsx @@ -1,10 +1,23 @@ -import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core"; +import { + Badge, + Divider, + Group, + Grid, + Paper, + Stack, + Text, + Title, +} from "@mantine/core"; import { conditionHolds, + displayFieldValue, type Attachment, + type FormFieldConfig, type FormSectionConfig, type LicenseTypeRequirements, } from "@ema-platform/api"; +import { useDateDisplayer } from "@ema-platform/shared"; +import { useTranslation } from "react-i18next"; import { DocumentSlots } from "./DocumentSlots"; interface Props { @@ -33,42 +46,83 @@ export function ApplicationSummary({ attachments, applicationId, }: Props) { - return ( - - - {sections.map((section) => ( -
- - {localized(section.title)} - - - - {(section.fields ?? []) - .filter((f) => conditionHolds(f.showWhen, formData)) - .map((field) => ( - - - - {localized(field.label)} - - - - - {String(formData[section.key]?.[field.key] ?? "—")} - - - - ))} - -
-
- ))} + const showDate = useDateDisplayer(); + const { i18n } = useTranslation(); -
- + // Shared with the officer's review screen, so the applicant and the reviewer + // never read the same answer two different ways. + const display = (field: FormFieldConfig, raw: unknown) => + displayFieldValue(field, raw, { + language: i18n.language, + showDate, + currency: config.feeCurrency, + }) || "—"; + + return ( + + {sections.map((section) => { + const fields = (section.fields ?? []).filter((f) => + conditionHolds(f.showWhen, formData), + ); + if (fields.length === 0) return null; + + return ( + + + {localized(section.title)} + + {fields.length} {fields.length === 1 ? "detail" : "details"} + + + {localized(section.description) && ( + + {localized(section.description)} + + )} + + + {/* Label above value in two columns — a definition list reads far + better than a bordered grid when most answers are short. */} + + {fields.map((field) => { + const value = display( + field, + formData[section.key]?.[field.key], + ); + const answered = value !== "—"; + return ( + + + {localized(field.label)} + + + {answered ? value : "Not provided"} + + + ); + })} + + + ); + })} + + Documents + -
-
-
+
+
); } diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 5352f520c..da2fd40bd 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -23,6 +23,7 @@ import { } from "@mantine/core"; import { IconAlertTriangle, + IconPencil, IconCheck, IconInfoCircle, IconPlus, @@ -309,7 +310,16 @@ export function LicenseApplicationPage() { ); } - const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status); + // A submitted application stays editable until an officer takes it, which + // mirrors the server's own rule (`assertEditable`): an applicant who spots + // their own mistake can fix it instead of waiting to be sent back for it. + // Once claimed it locks — the officer reading it must not have the form move + // underneath them. + const editableWhileSubmitted = + application.status === "SUBMITTED" && !application.assignedOfficerId; + const readOnly = + !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) && + !editableWhileSubmitted; // A DRAFT has nothing worth summarising yet, so it always opens straight // into the wizard; every later status (including RESUBMIT_REQUIRED) opens // to the summary first. @@ -397,6 +407,7 @@ export function LicenseApplicationPage() { title: "Resubmitted", message: "Your corrections were sent back to the reviewing officer.", }); + navigate("/licensing/applications"); } else { await submitApplication(appId as string).unwrap(); notifications.show({ @@ -404,8 +415,12 @@ export function LicenseApplicationPage() { title: "Application submitted", message: "You will be notified as it progresses.", }); + // Stays on the application rather than dropping the applicant into a + // list: they have just filled a long form and the useful next screen is + // what they submitted, with its status and — while it is still + // unclaimed — the means to correct it. + setViewingSummary(true); } - navigate("/licensing/applications"); } catch (err) { const found = extractValidationIssues(err); setIssues(found); @@ -571,10 +586,11 @@ export function LicenseApplicationPage() { Fee: {config.fee ?? "—"} {config.feeCurrency} - {showSummary && isAdjusting && ( + {showSummary && !readOnly && (
- {/* Active application status */} + {/* Active application status — one card per service in flight. */} {application && ( - - - - - - -
- Application {application.id} - - {/* An approved seafarer registration opens this application - as a draft, so it can be here before anyone has filed it. - Calling that "Submitted" would misreport where it stands. */} - {application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '} - {formatDate(application.submittedAt)} - -
-
- - {application.status.replaceAll('_', ' ')} - -
- - {/* Progress stepper */} - - {STAGES.map((stage, i) => ( - - ) : ( - - ) - } - /> - ))} - - + {data?.book && ( } mt="md"> Your Seaman Book {data.book.id} has been issued. Please visit the EMA office to collect it, bringing your National ID. )} -
+ + )} + {btcApplication && ( + )} {/* No active application — eligibility + apply */} diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index a7e6f2032..6c23b3900 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -1,6 +1,7 @@ import { resolveTokenFromStorage } from '../../session'; import type { Bilingual, + FormFieldConfig, FormSectionConfig, LicenseApplication, LicenseStatus, @@ -178,6 +179,52 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi return typeof applicantName === 'string' && applicantName ? applicantName : undefined; } +/** + * One form answer as a person should read it back. + * + * The stored value is not it: a SELECT holds the option's `value`, so an + * unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` — + * the codes the database wants, not the words that were chosen. Shared by the + * applicant's summary and the officer's review so the two never describe the + * same application differently. + * + * `showDate` is passed in rather than imported: date display is a hook + * (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function. + */ +export function displayFieldValue( + field: Pick, + raw: unknown, + opts: { + language?: string; + showDate?: (value: string) => string; + currency?: string; + } = {}, +): string { + if (raw === null || raw === undefined || raw === '') return ''; + const { language = 'en', showDate, currency } = opts; + + switch (field.type) { + case 'BOOLEAN': + return raw ? 'Yes' : 'No'; + case 'DATE': + return showDate?.(String(raw)) || String(raw); + case 'SELECT': { + const option = field.options?.find((o) => o.value === raw); + // Falls back to the stored value rather than blanking: an option removed + // from the config since this was filed still has to show what was chosen. + return option ? localized(option.label, language) : String(raw); + } + case 'MONEY': { + const amount = Number(raw); + return Number.isFinite(amount) + ? `${amount.toLocaleString()} ${currency ?? ''}`.trim() + : String(raw); + } + default: + return String(raw); + } +} + /** Reads a bilingual value for the active language, falling back to English. */ export function localized(value: Bilingual | undefined, language = 'en'): string { if (!value) return ''; diff --git a/libs/api/src/lib/features/licensing/licensing.types.ts b/libs/api/src/lib/features/licensing/licensing.types.ts index ce43f1416..1bd2b2c70 100644 --- a/libs/api/src/lib/features/licensing/licensing.types.ts +++ b/libs/api/src/lib/features/licensing/licensing.types.ts @@ -335,8 +335,37 @@ export interface ApplicationRemark { createdAt: string; } +/** + * Who filed the application, from their profile. + * + * Served alongside the form because a person-centric service (seafarer + * registration, a certificate) has no `companyName` to identify itself by — a + * reviewer opening one otherwise sees only an application number and has to + * infer the human from the answers. + */ +export interface ApplicationApplicant { + profileId: string; + firstName: string | null; + middleName: string | null; + lastName: string | null; + gender: string | null; + dob: string | null; + pob: string | null; + maritalStatus: string | null; + seafarerNumber: string | null; + seafarerStatus: string | null; + seafarerDepartment: string | null; + nationality: string | null; + idType: string | null; + idNumber: string | null; + primaryPhoneNumber: string | null; + email: string | null; +} + export interface ApplicationDetail { application: LicenseApplication; + /** Null when the applicant has no profile row (never expected in practice). */ + applicant: ApplicationApplicant | null; staff: ApplicationStaff[]; attachments: Attachment[]; history: StatusHistoryEntry[]; diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 04b490798..cbcc1fbac 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,13 +1,4 @@ { - "status": "failed", - "failedTests": [ - "98dcbc0c174eb3697418-75794b7db9eaf01c737f", - "98dcbc0c174eb3697418-34fd1a52a2c14f879d3a", - "98dcbc0c174eb3697418-bd195edac5a95d796827", - "98dcbc0c174eb3697418-c505dae67d8cd7469ff3", - "98dcbc0c174eb3697418-ba62eb9d11839aca30c0", - "98dcbc0c174eb3697418-07ce101789b6b7b7985c", - "98dcbc0c174eb3697418-1d2cbda982bd085da606", - "98dcbc0c174eb3697418-46dd670046a70e730e93" - ] + "status": "passed", + "failedTests": [] } \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/error-context.md b/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/error-context.md deleted file mode 100644 index 709f8dbf5..000000000 --- a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> opening the wizard creates the draft up front -- Location: apps/e2e/src/seafarer-registration.spec.ts:206:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 3450" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042323383450@example.test - - generic [ref=f1e172]: e2eseafarer1787042323383450 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/test-failed-1.png deleted file mode 100644 index d582095d2..000000000 Binary files a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/trace.zip b/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/trace.zip deleted file mode 100644 index 958dd3071..000000000 Binary files a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/video.webm b/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/video.webm deleted file mode 100644 index a40fac4e8..000000000 Binary files a/test-results/seafarer-registration-seaf-1edaa--creates-the-draft-up-front-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/error-context.md b/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/error-context.md deleted file mode 100644 index 982566ca9..000000000 --- a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> a registered seafarer cannot start a second registration -- Location: apps/e2e/src/seafarer-registration.spec.ts:355:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 2475" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042544962475@example.test - - generic [ref=f1e172]: e2eseafarer1787042544962475 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/test-failed-1.png deleted file mode 100644 index 9a81befda..000000000 Binary files a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/trace.zip b/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/trace.zip deleted file mode 100644 index 8a384f789..000000000 Binary files a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/video.webm b/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/video.webm deleted file mode 100644 index ae4e152da..000000000 Binary files a/test-results/seafarer-registration-seaf-31c7a-start-a-second-registration-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/error-context.md b/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/error-context.md deleted file mode 100644 index 7daf04094..000000000 --- a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> a registration never reaches evaluation or inspection -- Location: apps/e2e/src/seafarer-registration.spec.ts:219:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 8190" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042357258190@example.test - - generic [ref=f1e172]: e2eseafarer1787042357258190 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/test-failed-1.png deleted file mode 100644 index 6332db93a..000000000 Binary files a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/trace.zip b/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/trace.zip deleted file mode 100644 index 676b8e2c4..000000000 Binary files a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/video.webm b/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/video.webm deleted file mode 100644 index fd811ef8e..000000000 Binary files a/test-results/seafarer-registration-seaf-392c3-es-evaluation-or-inspection-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/error-context.md b/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/error-context.md deleted file mode 100644 index 5c77fe87d..000000000 --- a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> approval numbers the profile and opens both child applications -- Location: apps/e2e/src/seafarer-registration.spec.ts:303:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 4609" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042485274609@example.test - - generic [ref=f1e172]: e2eseafarer1787042485274609 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/test-failed-1.png deleted file mode 100644 index 4054788ee..000000000 Binary files a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/trace.zip b/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/trace.zip deleted file mode 100644 index 765d70977..000000000 Binary files a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/video.webm b/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/video.webm deleted file mode 100644 index d5592985e..000000000 Binary files a/test-results/seafarer-registration-seaf-49c3b-ens-both-child-applications-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/error-context.md b/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/error-context.md deleted file mode 100644 index 5ecce9139..000000000 --- a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> a re-fired approval renumbers nobody and opens no second pair -- Location: apps/e2e/src/seafarer-registration.spec.ts:336:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 2538" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042515032538@example.test - - generic [ref=f1e172]: e2eseafarer1787042515032538 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/test-failed-1.png deleted file mode 100644 index 1b90efa7d..000000000 Binary files a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/trace.zip b/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/trace.zip deleted file mode 100644 index 0c559ba6f..000000000 Binary files a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/video.webm b/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/video.webm deleted file mode 100644 index a0dd225d0..000000000 Binary files a/test-results/seafarer-registration-seaf-6b040-dy-and-opens-no-second-pair-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/error-context.md b/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/error-context.md deleted file mode 100644 index 1f95e857a..000000000 --- a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can return a registration for correction and take it back -- Location: apps/e2e/src/seafarer-registration.spec.ts:243:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 5517" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042391965517@example.test - - generic [ref=f1e172]: e2eseafarer1787042391965517 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/test-failed-1.png deleted file mode 100644 index 18b9dacea..000000000 Binary files a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/trace.zip b/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/trace.zip deleted file mode 100644 index 900431f6d..000000000 Binary files a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/video.webm b/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/video.webm deleted file mode 100644 index 19ff2a22e..000000000 Binary files a/test-results/seafarer-registration-seaf-9b96e-correction-and-take-it-back-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/error-context.md b/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/error-context.md deleted file mode 100644 index 55600dccf..000000000 --- a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can hold and resume a registration -- Location: apps/e2e/src/seafarer-registration.spec.ts:267:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 2368" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042424082368@example.test - - generic [ref=f1e172]: e2eseafarer1787042424082368 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/test-failed-1.png deleted file mode 100644 index d20e6c8b1..000000000 Binary files a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/trace.zip b/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/trace.zip deleted file mode 100644 index 43df68602..000000000 Binary files a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/video.webm b/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/video.webm deleted file mode 100644 index a25e8d963..000000000 Binary files a/test-results/seafarer-registration-seaf-9ec0a-d-and-resume-a-registration-chromium/video.webm and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/error-context.md b/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/error-context.md deleted file mode 100644 index 3f0ef5fac..000000000 --- a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/error-context.md +++ /dev/null @@ -1,348 +0,0 @@ -# Instructions - -- Following Playwright test failed. -- Explain why, be concise, respect Playwright best practices. -- Provide a snippet of code with the fix, if possible. - -# Test info - -- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can reject a registration with a reason -- Location: apps/e2e/src/seafarer-registration.spec.ts:285:7 - -# Error details - -``` -Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. -``` - -# Page snapshot - -```yaml -- generic [ref=f1e3]: - - banner [ref=f1e4]: - - generic [ref=f1e5]: - - generic [ref=f1e6]: - - button "Toggle navigation" [ref=f1e8] [cursor=pointer] - - generic [ref=f1e10]: - - generic [ref=f1e11]: Dashboard - - generic [ref=f1e13]: Profile - - generic [ref=f1e17]: - - button "Language" [ref=f1e18] [cursor=pointer] - - button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer] - - button "Notifications" [ref=f1e26] [cursor=pointer]: - - generic [ref=f1e27]: "1" - - button "ES" [ref=f1e32] [cursor=pointer] - - navigation [ref=f1e34]: - - generic [ref=f1e35]: - - img "EMA" [ref=f1e36] - - generic [ref=f1e37]: - - paragraph [ref=f1e38]: EMA Portal - - paragraph [ref=f1e39]: Ethiopian Maritime Authority - - generic [ref=f1e43]: - - generic [ref=f1e44]: - - generic [ref=f1e45] [cursor=pointer]: Dashboard - - generic [ref=f1e52] [cursor=pointer]: - - generic [ref=f1e57]: Notifications - - generic "1 pending" [ref=f1e59]: "1" - - generic [ref=f1e61]: - - button [expanded] [ref=f1e62] [cursor=pointer]: - - paragraph [ref=f1e63]: Licensing - - generic [ref=f1e66] [cursor=pointer]: My Applications - - generic [ref=f1e73]: - - button [expanded] [ref=f1e74] [cursor=pointer]: - - paragraph [ref=f1e75]: Seafarer Services - - generic [ref=f1e78] [cursor=pointer]: Seafarer Registration - - generic [ref=f1e82] [cursor=pointer]: My Sea Records - - generic [ref=f1e86] [cursor=pointer]: Seaman Book - - generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate - - generic [ref=f1e98] [cursor=pointer]: Certificates - - generic [ref=f1e104] [cursor=pointer]: Examinations - - generic [ref=f1e108] [cursor=pointer]: Endorsements - - generic [ref=f1e113]: - - button [expanded] [ref=f1e114] [cursor=pointer]: - - paragraph [ref=f1e115]: Account - - generic [ref=f1e118] [cursor=pointer]: My Documents - - generic [ref=f1e123] [cursor=pointer]: Profile - - generic [ref=f1e130] [cursor=pointer]: Help & Support - - button "Collapse" [ref=f1e139] [cursor=pointer] - - main [ref=f1e143]: - - generic [ref=f1e145]: - - generic [ref=f1e147]: - - heading "My Profile" [level=2] [ref=f1e148] - - paragraph [ref=f1e149]: Manage your account details and preferences. - - alert [ref=f1e150]: - - generic [ref=f1e151]: Profile details are needed for seafarer registration. - - generic [ref=f1e159]: - - paragraph [ref=f1e161]: ES - - generic [ref=f1e162]: - - generic [ref=f1e163]: - - heading "E2E seafarer 9173" [level=4] [ref=f1e164] - - generic [ref=f1e165]: Unverified - - paragraph [ref=f1e171]: e2e.seafarer.1787042455309173@example.test - - generic [ref=f1e172]: e2eseafarer1787042455309173 - - generic "0% complete" [ref=f1e178]: - - paragraph [ref=f1e183]: 0% - - generic [ref=f1e184]: - - tablist [ref=f1e185]: - - tab "Personal" [ref=f1e186] [cursor=pointer] - - tab "Profile" [selected] [ref=f1e193] [cursor=pointer] - - tab "Address" [ref=f1e199] [cursor=pointer] - - tab "Operations" [ref=f1e205] [cursor=pointer] - - tab "Security" [ref=f1e212] [cursor=pointer] - - tab "Preferences" [ref=f1e218] [cursor=pointer] - - tabpanel "Profile" [ref=f1e224]: - - generic [ref=f1e227]: - - generic [ref=f1e228]: - - heading "Maritime Profile" [level=5] [ref=f1e229] - - paragraph [ref=f1e230]: Your professional maritime details - - generic [ref=f1e231]: - - generic [ref=f1e232]: - - generic [ref=f1e233]: Profession * - - textbox "Profession" [ref=f1e235]: - - /placeholder: Select - - text: Master Mariner - - generic [ref=f1e236]: - - generic [ref=f1e237]: First Name * - - textbox "First Name" [ref=f1e239]: - - /placeholder: Enter first name - - text: Dawit - - generic [ref=f1e240]: - - generic [ref=f1e241]: Middle Name * - - textbox "Middle Name" [ref=f1e243]: - - /placeholder: Enter middle name - - text: Bekele - - generic [ref=f1e244]: - - generic [ref=f1e245]: Last Name * - - textbox "Last Name" [ref=f1e247]: - - /placeholder: Enter last name - - text: Tesfaye - - generic [ref=f1e248]: - - generic [ref=f1e249]: Gender * - - textbox "Gender" [ref=f1e251] [cursor=pointer]: - - /placeholder: Select - - text: MALE - - generic [ref=f1e252]: - - generic [ref=f1e253]: Date of Birth * - - generic [ref=f1e254]: - - button "Switch calendar type" [ref=f1e256] [cursor=pointer]: - - generic [ref=f1e257]: EN - - textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995 - - button [ref=f1e261] [cursor=pointer] - - generic [ref=f1e266]: - - generic [ref=f1e267]: Place of Birth - - textbox "Place of Birth" [ref=f1e269]: - - /placeholder: City, Region - - generic [ref=f1e270]: - - generic [ref=f1e271]: Marital Status * - - textbox "Marital Status" [ref=f1e273] [cursor=pointer]: - - /placeholder: Select - - text: SINGLE - - button "Save Profile" [active] [ref=f1e275] [cursor=pointer] -``` - -# Test source - -```ts - 46 | await openTab(page, 'Address'); - 47 | await pick(page, 'ID Type', /^NID$/i); - 48 | await page.getByLabel('ID Number').fill('FYD1234567890'); - 49 | // A country select, not a free-text field. - 50 | await pick(page, 'Nationality', /ethiopia/i); - 51 | // `addressSchema` requires this in Ethiopian format; without it the form - 52 | // never submits and no request is made for `save` to wait on. - 53 | await page - 54 | .getByRole('textbox', { name: 'Primary Phone' }) - 55 | .fill('+251911234567'); - 56 | await save(page); - 57 | } - 58 | - 59 | /** Selects a profile tab and waits for its panel to be the visible one. */ - 60 | async function openTab(page: Page, name: string): Promise { - 61 | await page.getByRole('tab', { name, exact: true }).click(); - 62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({ - 63 | timeout: 15_000, - 64 | }); - 65 | } - 66 | - 67 | /** - 68 | * Picks a value from a Mantine select. - 69 | * - 70 | * The label is bound to both the input and the listbox it opens, so matching - 71 | * by label alone is ambiguous once the dropdown is showing — the textbox role - 72 | * names the control itself. - 73 | */ - 74 | async function pick(page: Page, label: string, option: RegExp): Promise { - 75 | await page.getByRole('textbox', { name: label }).click(); - 76 | await page.getByRole('option', { name: option }).first().click(); - 77 | } - 78 | - 79 | /** - 80 | * Sets the date of birth through the picker's own UI. - 81 | * - 82 | * `AmharicDatePicker` is a controlled component: it reports changes through - 83 | * `onChange`, which is what writes the value into react-hook-form. Setting the - 84 | * input's `value` natively bypasses that entirely — the field stays empty as - 85 | * far as zod is concerned, and the form silently refuses to submit. - 86 | * - 87 | * So the calendar is actually driven: open it, pick the year and month from - 88 | * the caption dropdowns, then click the day. - 89 | */ - 90 | async function pickDate(page: Page, label: string, iso: string): Promise { - 91 | const [year, month, day] = iso.split('-').map(Number); - 92 | - 93 | await page.getByRole('textbox', { name: label }).click(); - 94 | const calendar = page.locator('.amharic-daypicker-dropdown'); - 95 | await expect(calendar).toBeVisible({ timeout: 10_000 }); - 96 | - 97 | // `captionLayout="dropdown"` renders native selects for month and year. - 98 | await calendar.locator('select').last().selectOption(String(year)); - 99 | await calendar - 100 | .locator('select') - 101 | .first() - 102 | .selectOption({ index: month - 1 }); - 103 | - 104 | // Each day is a button whose accessible name is the full date - 105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the - 106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11 - 107 | // or 21. Resolved after the dropdowns settle, since changing year or month - 108 | // re-renders the grid. - 109 | const cell = calendar - 110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) }) - 111 | .first(); - 112 | await expect(cell).toBeVisible({ timeout: 10_000 }); - 113 | await cell.click(); - 114 | - 115 | await expect(calendar).toBeHidden({ timeout: 10_000 }); - 116 | - 117 | // The picker writes through `onChange`; if that did not land, zod still sees - 118 | // an empty field and the failure would surface later as a refused submit. - 119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { - 120 | timeout: 10_000, - 121 | }); - 122 | } - 123 | - 124 | async function save(page: Page): Promise { - 125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile - 126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the - 127 | // route's own spelling. Any successful write from this screen is the signal. - 128 | const saved = page.waitForResponse( - 129 | (r) => - 130 | r.request().method() !== 'GET' && - 131 | r.status() < 400 && - 132 | /(profile|address|user)/i.test(r.url()), - 133 | { timeout: 20_000 }, - 134 | ); - 135 | await page.getByRole('button', { name: /save/i }).first().click(); - 136 | - 137 | try { - 138 | await saved; - 139 | } catch (cause) { - 140 | // A zod-blocked submit fires no request at all, so the bare timeout says - 141 | // only "no response" — which reads as a backend fault rather than a form - 142 | // that refused to submit. Surface the field errors instead. - 143 | const messages = await page - 144 | .locator('.mantine-InputWrapper-error, [role="alert"]') - 145 | .allTextContents(); -> 146 | throw new Error( - | ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration. - 147 | messages.length - 148 | ? `Save did not submit — validation errors: ${messages.join('; ')}` - 149 | : 'Save produced no request and reported no validation error.', - 150 | { cause }, - 151 | ); - 152 | } - 153 | } - 154 | - 155 | /** Signs up, declares seafarer operations, and fills the gating profile. */ - 156 | async function readyApplicant(page: Page, applicant: Applicant): Promise { - 157 | const offset = await signUp(page, applicant); - 158 | await verifyOtpIfPrompted(page, offset); - 159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 160 | await page - 161 | .getByRole('checkbox', { name: /seafarer registration/i }) - 162 | .first() - 163 | .check(); - 164 | await page.getByRole('button', { name: /save operations/i }).click(); - 165 | // A seafarer is taken to `/profile`, not the dashboard: registration is - 166 | // built from the profile, and a fresh signup holds none of it yet. - 167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 168 | await completeProfile(page); - 169 | } - 170 | - 171 | test.describe('seafarer registration', () => { - 172 | let applicant: Applicant; - 173 | - 174 | test.beforeEach(() => { - 175 | applicant = newApplicant('seafarer'); - 176 | }); - 177 | - 178 | test.afterEach(() => { - 179 | deleteApplicant(applicant.email); - 180 | }); - 181 | - 182 | test('the wizard refuses to open until the profile it is built from is complete', async ({ - 183 | page, - 184 | }) => { - 185 | const offset = await signUp(page, applicant); - 186 | await verifyOtpIfPrompted(page, offset); - 187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 }); - 188 | await page - 189 | .getByRole('checkbox', { name: /seafarer registration/i }) - 190 | .first() - 191 | .check(); - 192 | await page.getByRole('button', { name: /save operations/i }).click(); - 193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 194 | - 195 | // A new account holds none of the identity the registration is filled in - 196 | // from, so the gate collects it rather than opening an uncompletable form. - 197 | await page.goto('/seafarer-registration'); - 198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 199 | - 200 | // The shared wizard route is gated identically — otherwise the gate is - 201 | // decoration a deep link walks straight past. - 202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); - 204 | }); - 205 | - 206 | test('opening the wizard creates the draft up front', async ({ page }) => { - 207 | await readyApplicant(page, applicant); - 208 | - 209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 }); - 211 | - 212 | // The draft exists before anything is filled in, so uploads have an owner - 213 | // and closing the browser mid-wizard loses nothing. - 214 | const number = await waitForApplication(applicant.email); - 215 | expect(number).toMatch(/^SFR/); - 216 | expect(statusOf(number)).toBe('DRAFT'); - 217 | }); - 218 | - 219 | test('a registration never reaches evaluation or inspection', async ({ - 220 | page, - 221 | }) => { - 222 | await readyApplicant(page, applicant); - 223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); - 224 | const number = await waitForApplication(applicant.email); - 225 | const id = idOf(number); - 226 | - 227 | await submit(id); - 228 | await runWorkflow(id, [{ path: 'claim' }]); - 229 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 230 | - 231 | // The licence course's middle stages have nothing to hold in a - 232 | // registration, and the transition table is the authority regardless of - 233 | // which endpoint is called. - 234 | const refused = await runWorkflow(id, [ - 235 | { path: 'complete-review', expectFailure: true }, - 236 | { path: 'approve-documents', expectFailure: true }, - 237 | { path: 'record-inspection', expectFailure: true }, - 238 | ]); - 239 | expect(refused.every((code) => code >= 400)).toBe(true); - 240 | expect(statusOf(number)).toBe('UNDER_REVIEW'); - 241 | }); - 242 | - 243 | test('an officer can return a registration for correction and take it back', async ({ - 244 | page, - 245 | }) => { - 246 | await readyApplicant(page, applicant); -``` \ No newline at end of file diff --git a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/test-failed-1.png b/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/test-failed-1.png deleted file mode 100644 index 6e3e04619..000000000 Binary files a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/test-failed-1.png and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/trace.zip b/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/trace.zip deleted file mode 100644 index 3f4aa66f8..000000000 Binary files a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/trace.zip and /dev/null differ diff --git a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/video.webm b/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/video.webm deleted file mode 100644 index 7f6205804..000000000 Binary files a/test-results/seafarer-registration-seaf-b6d60--registration-with-a-reason-chromium/video.webm and /dev/null differ