diff --git a/apps/web/src/__tests__/start-session-form.test.tsx b/apps/web/src/__tests__/start-session-form.test.tsx new file mode 100644 index 000000000..6aee367b1 --- /dev/null +++ b/apps/web/src/__tests__/start-session-form.test.tsx @@ -0,0 +1,100 @@ +import { DEFAULT_GROUP_NAME } from '@opendatacapture/schemas/core'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StartSessionForm } from '@/components/StartSessionForm'; + +import '@/services/i18n'; + +const onSubmit = vi.fn(); + +const renderForm = (customSubjectIds: string[]) => { + render( + + ); + const form = screen.getByTestId('start-session-form'); + fireEvent.change(form.querySelector('[name="subjectIdentificationMethod"]')!, { + target: { value: 'CUSTOM_ID' } + }); + return form; +}; + +const identifierInput = () => screen.getByTestId('subjectId-combobox-input'); + +/** + * Base UI reads `inputType` to tell typing from autofill and only opens the popup for the former, + * so `fireEvent.change` — which sets no `inputType` — would leave it closed. + */ +const typeIdentifier = (identifier: string) => { + fireEvent.input(identifierInput(), { inputType: 'insertText', target: { value: identifier } }); +}; + +/** A custom value is committed when the popup closes, not on each keystroke. */ +const closeIdentifierPopup = () => { + fireEvent.keyDown(identifierInput(), { key: 'Enter' }); +}; + +const submit = (form: HTMLElement) => { + fireEvent.change(form.querySelector('[name="sessionType"]')!, { target: { value: 'IN_PERSON' } }); + fireEvent.click(screen.getByLabelText('Submit')); +}; + +const submittedSubjectId = () => onSubmit.mock.lastCall?.[0].subjectData.id; + +beforeEach(() => { + // There are no vitest setup files in this repo, so RTL never auto-unmounts between tests. + cleanup(); + vi.clearAllMocks(); +}); + +describe('StartSessionForm', () => { + // A new subject's identifier is by definition absent from the options, so the combobox has to keep + // text matching no option rather than reverting to the empty selection when the popup closes. + it('should submit an identifier that matches no existing subject, so a new subject can be enrolled', async () => { + const form = renderForm(['alpha']); + typeIdentifier('gamma'); + closeIdentifierPopup(); + submit(form); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(submittedSubjectId()).toBe(`${DEFAULT_GROUP_NAME}$gamma`); + }); + + it('should leave an identifier matching no existing subject in the input, so the clinician sees what they typed', () => { + renderForm(['alpha']); + typeIdentifier('gamma'); + closeIdentifierPopup(); + expect(identifierInput().value).toBe('gamma'); + }); + + it('should offer the identifiers already in use as options, so an existing subject can be chosen', () => { + renderForm(['alpha']); + typeIdentifier('alpha'); + expect(screen.getByTestId('subjectId-combobox-item-alpha')).toBeTruthy(); + }); + + it('should submit an identifier picked from the options, so a returning subject reuses their own id', async () => { + const form = renderForm(['alpha']); + typeIdentifier('alpha'); + fireEvent.click(screen.getByTestId('subjectId-combobox-item-alpha')); + submit(form); + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(submittedSubjectId()).toBe(`${DEFAULT_GROUP_NAME}$alpha`); + }); + + // The identifier is scoped by prefixing the group name and a `$`, so one inside the identifier + // would make the stored id ambiguous. Validation has to reach a custom value too, not just an option. + it('should reject a custom identifier containing the scope separator', async () => { + const form = renderForm(['alpha']); + typeIdentifier('gam$ma'); + closeIdentifierPopup(); + submit(form); + await waitFor(() => expect(screen.getByText('Illegal character: $')).toBeTruthy()); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx index e308c9df8..cef795dfc 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.stories.tsx @@ -8,6 +8,7 @@ export default { component: StartSessionForm } as Meta; export const Default: Story = { args: { + customSubjectIds: ['SUBJECT_001', 'SUBJECT_002', 'SUBJECT_003'], onSubmit(data) { alert(JSON.stringify(data, null, 2)); } diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index 42f063d3d..dda1d6ea5 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -30,6 +30,7 @@ type StartSessionFormData = { type StartSessionFormProps = { currentGroup: Group | null; + customSubjectIds: string[]; initialValues?: FormTypes.PartialNullableData; onSubmit: (data: CreateSessionData) => Promisable; readOnly: boolean; @@ -38,6 +39,7 @@ type StartSessionFormProps = { export const StartSessionForm = ({ currentGroup, + customSubjectIds, username, initialValues, readOnly, @@ -80,7 +82,9 @@ export const StartSessionForm = ({ ? { kind: 'string', label: t('common.identifier'), - variant: 'input' + variant: 'combobox', + allowCustomValue: true, + options: Object.fromEntries(customSubjectIds.map((id) => [id, id])) } : null; } diff --git a/apps/web/src/routes/_app/session/start-session.tsx b/apps/web/src/routes/_app/session/start-session.tsx index 3ba33529b..a5797785a 100644 --- a/apps/web/src/routes/_app/session/start-session.tsx +++ b/apps/web/src/routes/_app/session/start-session.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { Card, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { FormTypes } from '@opendatacapture/runtime-core'; +import { isSubjectWithPersonalInfo, removeSubjectIdScope } from '@opendatacapture/subject-utils'; import { createFileRoute, useLocation } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'motion/react'; @@ -10,6 +11,7 @@ import { PageHeader } from '@/components/PageHeader'; import { StartSessionForm } from '@/components/StartSessionForm'; import type { StartSessionFormData } from '@/components/StartSessionForm'; import { useCreateSessionMutation } from '@/hooks/useCreateSessionMutation'; +import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery'; import { useAppStore } from '@/store'; const RouteComponent = () => { @@ -28,6 +30,11 @@ const RouteComponent = () => { const { t } = useTranslation('session'); const createSessionMutation = useCreateSessionMutation(); + const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); + + const customSubjectIds = subjectsQuery.data + .filter((subject) => !isSubjectWithPersonalInfo(subject)) + .map((subject) => removeSubjectIdScope(subject.id)); useEffect(() => { if (currentSession === null) { @@ -45,6 +52,7 @@ const RouteComponent = () => { {currentSession === null && ( { }; export const Route = createFileRoute('/_app/session/start-session')({ - component: RouteComponent + component: RouteComponent, + loader: async ({ context }) => { + const { currentGroup } = useAppStore.getState(); + await context.queryClient.ensureQueryData(subjectsQueryOptions({ params: { groupId: currentGroup?.id } })); + } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448760668..cf4e17322 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,11 +25,11 @@ catalogs: specifier: ^0.2.1 version: 0.2.1 '@douglasneuroinformatics/libui': - specifier: ^6.11.2 - version: 6.11.2 + specifier: ^6.14.0 + version: 6.14.0 '@douglasneuroinformatics/libui-form-types': - specifier: ^1.1.0 - version: 1.1.0 + specifier: ^1.3.0 + version: 1.3.0 '@microsoft/api-extractor': specifier: ^7.47.6 version: 7.58.7 @@ -386,7 +386,7 @@ importers: version: 0.0.6 '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/react-core': specifier: workspace:* version: link:../../packages/react-core @@ -486,7 +486,7 @@ importers: dependencies: '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) '@opendatacapture/licenses': specifier: workspace:* version: link:../../packages/licenses @@ -571,7 +571,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.52.2)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -698,7 +698,7 @@ importers: version: 0.0.3(typescript@6.0.3) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@vendor+react@19.x) @@ -992,7 +992,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: workspace:* version: link:../instrument-bundler @@ -1107,7 +1107,7 @@ importers: dependencies: '@douglasneuroinformatics/libui-form-types': specifier: 'catalog:' - version: 1.1.0 + version: 1.3.0 '@opendatacapture/licenses': specifier: workspace:* version: link:../licenses @@ -1183,7 +1183,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: 'workspace:' version: link:../instrument-bundler @@ -1396,7 +1396,7 @@ importers: devDependencies: '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) + version: 6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) '@opendatacapture/instrument-stubs': specifier: workspace:* version: link:../packages/instrument-stubs @@ -2238,8 +2238,16 @@ packages: '@types/react': optional: true - '@douglasneuroinformatics/libui@6.11.2': - resolution: {integrity: sha512-sLPNCaCWbmP2N8DM8NXH2bPNSikjnThNPuILvGqy0NlNjAsTSuaHa198vHQHYbE6nVEO3N7lwRjyreM35O1lsw==} + '@douglasneuroinformatics/libui-form-types@1.3.0': + resolution: {integrity: sha512-F+q2MVGET3vw8/pg2gYFoa9wtBhFxxE/oLdjIl98RYGc7iSt6D0izTHuSWIcfJNcZPluGM9KkykVF1fYR2sVyg==} + peerDependencies: + '@types/react': '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@douglasneuroinformatics/libui@6.14.0': + resolution: {integrity: sha512-17z7FQnxEVq8Su+aTXlyTqkkam3Lbe094Tr5tb+8AQ3l22sfFAgvOlwddYVXxvtnkEEalUPtIafPi4PVvA6G4g==} engines: {node: 24.x} peerDependencies: react: ^19.1.0 @@ -11992,7 +12000,11 @@ snapshots: dependencies: type-fest: 4.41.0 - '@douglasneuroinformatics/libui@6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui-form-types@1.3.0': + dependencies: + type-fest: 4.41.0 + + '@douglasneuroinformatics/libui@6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) @@ -12049,7 +12061,7 @@ snapshots: - neverthrow - use-sync-external-store - '@douglasneuroinformatics/libui@6.11.2(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui@6.14.0(immer@10.2.0)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.0)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ccffa76b..608ee4b40 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,8 +22,8 @@ catalog: '@douglasneuroinformatics/libjs': ^3.2.1 '@douglasneuroinformatics/libpasswd': '^0.0.3' '@douglasneuroinformatics/libstats': '^0.2.1' - '@douglasneuroinformatics/libui': ^6.11.2 - '@douglasneuroinformatics/libui-form-types': ^1.1.0 + '@douglasneuroinformatics/libui': ^6.14.0 + '@douglasneuroinformatics/libui-form-types': ^1.3.0 '@microsoft/api-extractor': '^7.47.6' '@prisma/client': '^6.9.0' '@tailwindcss/vite': '4.3.0' diff --git a/testing/src/pages/_app/session/start-session.page.ts b/testing/src/pages/_app/session/start-session.page.ts index dd530f6b8..2b549c075 100644 --- a/testing/src/pages/_app/session/start-session.page.ts +++ b/testing/src/pages/_app/session/start-session.page.ts @@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test'; import { AppPage } from '../route.page'; export class StartSessionPage extends AppPage { + readonly endSessionButton: Locator; readonly errorMessages: Locator; readonly pageHeader: Locator; readonly selectField: Locator; @@ -18,18 +19,33 @@ export class StartSessionPage extends AppPage { this.successMessage = page.getByRole('heading', { name: 'Session Successfully Started' }); this.errorMessages = page.getByTestId('error-message-text'); this.subjectIdField = this.sessionForm.locator('[name="subjectId"]'); + // The end session nav item opens a dialog rather than navigating, so it carries no route. + this.endSessionButton = page.getByTestId('nav-button-#'); + } + + /** Clicks outside the identifier combobox, which closes its popup and commits what was typed. */ + async dismissSubjectIdOptions() { + await this.pageHeader.click(); + } + + async endSession() { + await this.endSessionButton.click(); + await this.$ref.getByRole('button', { name: 'Yes' }).click(); + await this.sessionForm.waitFor({ state: 'visible' }); } async fillCustomIdentifier(customIdentifier: string, sex: string) { - const subjectIdField = this.sessionForm.locator('[name="subjectId"]'); + await this.typeSubjectId(customIdentifier); + await this.fillSessionDetails(sex); + } + + /** Everything the form needs beyond how the subject was identified. */ + async fillSessionDetails(sex: string) { const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]'); const sexSelector = this.sessionForm.locator('[name="subjectSex"]'); const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]'); const sessionDate = this.sessionForm.locator('[name="sessionDate"]'); - await subjectIdField.waitFor({ state: 'visible' }); - await subjectIdField.fill(customIdentifier); - await dateOfBirthField.waitFor({ state: 'visible' }); await dateOfBirthField.fill('1990-01-01'); @@ -45,10 +61,6 @@ export class StartSessionPage extends AppPage { async fillSessionForm(firstName: string, lastName: string, sex: string) { const firstNameField = this.sessionForm.locator('[name="subjectFirstName"]'); const lastNameField = this.sessionForm.locator('[name="subjectLastName"]'); - const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]'); - const sexSelector = this.sessionForm.locator('[name="subjectSex"]'); - const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]'); - const sessionDate = this.sessionForm.locator('[name="sessionDate"]'); await firstNameField.waitFor({ state: 'visible' }); await firstNameField.fill(firstName); @@ -56,22 +68,18 @@ export class StartSessionPage extends AppPage { await lastNameField.waitFor({ state: 'visible' }); await lastNameField.fill(lastName); - await dateOfBirthField.waitFor({ state: 'visible' }); - await dateOfBirthField.fill('1990-01-01'); - - await sexSelector.selectOption(sex); - - await sessionTypeSelector.selectOption('Retrospective'); - - await sessionDate.waitFor({ state: 'visible' }); - const expectedSessionDate = new Date().toISOString().split('T')[0]!; - await sessionDate.fill(expectedSessionDate); + await this.fillSessionDetails(sex); } async selectIdentificationMethod(methodName: string) { await this.selectField.selectOption(methodName); } + /** An identifier already in use by a subject in the current group, offered in the popup. */ + subjectIdOption(identifier: string) { + return this.$ref.getByTestId(`subjectId-combobox-item-${identifier}`); + } + async submitForm() { const submitButton = this.sessionForm.getByLabel('Submit'); @@ -79,4 +87,10 @@ export class StartSessionPage extends AppPage { await submitButton.click(); } + + /** Types into the identifier combobox, leaving the options popup open. */ + async typeSubjectId(identifier: string) { + await this.subjectIdField.waitFor({ state: 'visible' }); + await this.subjectIdField.fill(identifier); + } } diff --git a/testing/src/specs/start-session.spec.ts b/testing/src/specs/start-session.spec.ts index 9826e29b2..d1ae7e446 100644 --- a/testing/src/specs/start-session.spec.ts +++ b/testing/src/specs/start-session.spec.ts @@ -30,6 +30,56 @@ test.describe('start session', () => { await expect(startSessionPage.successMessage).toBeVisible(); }); + // The identifier combobox offers the subjects already enrolled in the group, so a brand new + // subject's identifier necessarily matches no option. Clicking away used to discard it. + test('should keep a custom identifier matching no existing subject when the options popup is dismissed', async ({ + getPageModel, + uniqueId + }) => { + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + + await startSessionPage.selectIdentificationMethod('CUSTOM_ID'); + await startSessionPage.typeSubjectId(`unmatched-${uniqueId}`); + await startSessionPage.dismissSubjectIdOptions(); + + await expect(startSessionPage.subjectIdField).toHaveValue(`unmatched-${uniqueId}`); + + await startSessionPage.fillSessionDetails('Male'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + }); + + test('should start a session for a subject chosen from the existing custom identifiers', async ({ + getPageModel, + page, + uniqueId + }) => { + const identifier = `returning-${uniqueId}`; + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + + await startSessionPage.selectIdentificationMethod('CUSTOM_ID'); + await startSessionPage.fillCustomIdentifier(identifier, 'Male'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + + // The subject only becomes an option once it exists, and the options are read by the route + // loader, so the second visit has to be a fresh load rather than a client-side navigation. + await startSessionPage.endSession(); + await page.reload(); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + + await startSessionPage.selectIdentificationMethod('CUSTOM_ID'); + await startSessionPage.typeSubjectId(identifier); + await startSessionPage.subjectIdOption(identifier).click(); + await expect(startSessionPage.subjectIdField).toHaveValue(identifier); + + await startSessionPage.fillSessionDetails('Male'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + }); + test('should show a required-field error for every missing field when submitting the personal information form empty', async ({ getPageModel }) => {