Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
adb9542
test: add datahub page to testing
david-roper Jan 22, 2026
39b9d24
test: add test for datahub page
david-roper Jan 23, 2026
49ab018
test: add start session test
david-roper Jan 26, 2026
3a5b44f
test: remove rowActionsTrigger
david-roper Jan 26, 2026
1632515
test: additions to start session form tests
david-roper Jan 27, 2026
a4bf5b0
test: add content check for session form
david-roper Jan 28, 2026
7055449
test: sample docker config
david-roper Jan 28, 2026
c4e8411
test: add test ids to disclaimer, test local storage in session test
david-roper Jan 28, 2026
e3d4444
test: add a accept disclaimer test
david-roper Feb 11, 2026
b3312bf
fix: add a wait to make sure test does go without loading auth file
david-roper Feb 11, 2026
5c2b5d6
test: fix start session test to navigate to personal info form
david-roper Feb 11, 2026
301f790
add test to fill in lastname and dob to session form
david-roper Feb 11, 2026
3ce8965
test: add subject DOB test
david-roper Feb 11, 2026
da2ec7f
add test for retrospective session
david-roper Feb 11, 2026
9acbe04
comment out current submit button code
david-roper Feb 11, 2026
fdfefba
test: adjust submit button test
david-roper Feb 11, 2026
e2cb443
test: adkist session submission code
david-roper Feb 12, 2026
6c4a8de
test: adjust timeout in playwright config
david-roper Feb 16, 2026
0ee1258
test: add fix to start session test, ignore tutorial/disclaimer
david-roper Feb 16, 2026
34481b1
chore: adjust config for e2e tests
david-roper Feb 16, 2026
d9cf0c8
test: add test for custom identifier
david-roper Feb 16, 2026
fe7ece6
chore: linting fixes
david-roper Feb 17, 2026
39ba2df
chore: go back to 10 sec timeout
david-roper Feb 23, 2026
c6a65d0
Update testing/e2e/playwright.docker.config.ts
david-roper Feb 23, 2026
fa3c963
chore: fix name of datahub test
david-roper Feb 23, 2026
731f98f
chore: move custom indentifier test to start session test suite
david-roper Feb 23, 2026
f880a93
test: add a dynamic date to test dates
david-roper Feb 23, 2026
8750b32
Update testing/e2e/src/helpers/fixtures.ts
david-roper Feb 23, 2026
3360944
fix: remove force submit
david-roper Feb 23, 2026
6eeb967
chore: move global vars to top of file, specify wait time in error msg
david-roper Feb 25, 2026
42aeee0
chore: remove redundant comments
david-roper Feb 25, 2026
844cd8e
chore: remove redundant comments
david-roper Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/web/src/providers/DisclaimerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const DisclaimerProvider: React.FC<{ children: React.ReactElement }> = ({
return (
<React.Fragment>
{children}
<Dialog open={!isDisclaimerAccepted}>
<Dialog.Content onOpenAutoFocus={(event) => event.preventDefault()}>
<Dialog data-test-id="Disclaimer-dialog" open={!isDisclaimerAccepted}>
<Dialog.Content data-test-id="Disclaimer-dialog-content" onOpenAutoFocus={(event) => event.preventDefault()}>
<Dialog.Header>
<Dialog.Title>
{t({
Expand All @@ -31,10 +31,10 @@ export const DisclaimerProvider: React.FC<{ children: React.ReactElement }> = ({
</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button type="button" onClick={() => setIsDisclaimerAccepted(true)}>
<Button data-test-id="accept-disclaimer" type="button" onClick={() => setIsDisclaimerAccepted(true)}>
{t({ en: 'Accept', fr: 'Accepter' })}
</Button>
<Button type="button" variant="outline" onClick={logout}>
<Button data-test-id="decline-disclaimer" type="button" variant="outline" onClick={logout}>
{t({ en: 'Decline', fr: 'Refuser' })}
</Button>
</Dialog.Footer>
Expand Down
92 changes: 92 additions & 0 deletions testing/e2e/playwright.docker.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as crypto from 'node:crypto';
import * as path from 'node:path';

import { parseNumber, range, unwrap } from '@douglasneuroinformatics/libjs';
import { defineConfig, devices } from '@playwright/test';
import type { Project } from '@playwright/test';

import { AUTH_STORAGE_DIR } from './src/helpers/constants';

import type { BrowserTarget, ProjectMetadata } from './src/helpers/types';

const appPort = parseNumber(process.env.APP_PORT);
const gatewayPort = parseNumber(process.env.GATEWAY_PORT);

if (Number.isNaN(appPort)) {
throw new Error(`Expected APP_PORT to be number, got ${process.env.APP_PORT}`);
} else if (Number.isNaN(gatewayPort)) {
throw new Error(`Expected GATEWAY_PORT to be number, got ${process.env.GATEWAY_PORT}`);
}

const baseURL = `http://localhost:${appPort}`;

const browsers: { target: BrowserTarget; use: Project['use'] }[] = [
{ target: 'Desktop Chrome', use: { ...devices['Desktop Chrome'], channel: 'chromium', headless: true } },
{ target: 'Desktop Firefox', use: { ...devices['Desktop Firefox'], headless: true } }
] as const;

export default defineConfig({
globalSetup: path.resolve(import.meta.dirname, 'src/global/global.setup.ts'),
globalTeardown: path.resolve(import.meta.dirname, 'src/global/global.teardown.ts'),
maxFailures: 1,
outputDir: path.resolve(import.meta.dirname, '.playwright/output'),
projects: [
{
name: 'Global Setup',
teardown: 'Global Teardown',
testMatch: '**/global/global.setup.spec.ts',
use: {
baseURL
}
},
{
name: 'Global Teardown',
testMatch: '**/global/global.teardown.spec.ts',
use: {
baseURL
}
},
...unwrap(range(1, 4)).flatMap((i) => {
return browsers.map((browser) => {
const browserId = crypto.createHash('sha256').update(browser.target).digest('hex');
return {
dependencies: i === 1 ? ['Global Setup'] : [`${i - 1}.x - ${browser.target}`],
metadata: {
authStorageFile: path.resolve(AUTH_STORAGE_DIR, `${browserId}.json`),
browserId,
browserTarget: browser.target
} satisfies ProjectMetadata,
name: `${i}.x - ${browser.target}`,
testMatch: `**/${i}.*.spec.ts`,
use: {
...browser.use,
baseURL
}
};
});
})
],
reporter: [['html', { open: 'never', outputFolder: path.resolve(import.meta.dirname, '.playwright/report') }]],
testDir: path.resolve(import.meta.dirname, 'src'),
webServer: [
{
command: 'true', // Dummy command since services are assumed running in Docker
reuseExistingServer: true,
timeout: 10_000,
url: `http://localhost:${appPort}/api/v1/setup`
},
{
command: 'true', // Dummy command since services are assumed running in Docker
reuseExistingServer: true,
timeout: 10_000,
url: `http://localhost:${gatewayPort}/api/healthcheck`
},
{
command: 'true', // Dummy command since services are assumed running in Docker
reuseExistingServer: true,
timeout: 10_000,
url: `http://localhost:${appPort}`
}
],
workers: process.env.CI ? 1 : undefined
});
31 changes: 31 additions & 0 deletions testing/e2e/src/1.2-accept-disclaimer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, test } from './helpers/fixtures';

test.describe('disclaimer', () => {
test('should accept the disclaimer', async ({ getProjectAuth, page }) => {
// Get the auth token
const auth = await getProjectAuth();

// Set localStorage to ensure disclaimer appears and set auth
await page.addInitScript((accessToken) => {
window.__PLAYWRIGHT_ACCESS_TOKEN__ = accessToken;
// Set the app localStorage item to ensure disclaimer is not accepted
localStorage.setItem('app', JSON.stringify({ state: { isDisclaimerAccepted: false }, version: 1 }));
}, auth.accessToken);

await page.goto('/dashboard');

const disclaimerDialog = page.getByRole('dialog', { name: 'Disclaimer' });
await expect(disclaimerDialog).toBeVisible();

// Click the accept disclaimer button
const acceptButton = page.getByRole('button', { name: 'Accept' });
await expect(acceptButton).toBeVisible();
await acceptButton.click();

await expect(disclaimerDialog).not.toBeVisible();

const pageHeader = page.getByTestId('page-header');
await expect(pageHeader).toBeVisible();
await expect(pageHeader).toContainText('Dashboard');
});
});
9 changes: 9 additions & 0 deletions testing/e2e/src/2.2-datahub.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from './helpers/fixtures';

test.describe('dashhub', () => {
test('should display the dashhub header', async ({ getPageModel }) => {
const datahubPage = await getPageModel('/datahub');
await expect(datahubPage.pageHeader).toBeVisible();
await expect(datahubPage.pageHeader).toContainText('Data Hub');
});
});
83 changes: 83 additions & 0 deletions testing/e2e/src/2.3-start-session.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from './helpers/fixtures';

test.describe('start session', () => {
test('should display the start session form header', async ({ getPageModel }) => {
const startSessionPage = await getPageModel('/session/start-session');
await expect(startSessionPage.pageHeader).toBeVisible();
await expect(startSessionPage.pageHeader).toContainText('Start Session');
await expect(startSessionPage.sessionForm).toBeVisible();
});

test('should fill subject personal information input', async ({ getPageModel, page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'app',
JSON.stringify({ state: { isDisclaimerAccepted: true, isWalkthroughComplete: true }, version: 1 })
);
});

const startSessionPage = await getPageModel('/session/start-session');

await startSessionPage.sessionForm.waitFor({ state: 'visible' });
await startSessionPage.selectIdentificationMethod('PERSONAL_INFO');

await expect(startSessionPage.selectField).toHaveValue('PERSONAL_INFO');

await startSessionPage.fillSessionForm('firstNameTest', 'lastNameTest', 'Male');

const firstNameField = startSessionPage.sessionForm.locator('[name="subjectFirstName"]');
await expect(firstNameField).toHaveValue('firstNameTest');

const lastNameField = startSessionPage.sessionForm.locator('[name="subjectLastName"]');
await expect(lastNameField).toHaveValue('lastNameTest');

const dobField = startSessionPage.sessionForm.locator('[name="subjectDateOfBirth"]');
await expect(dobField).toHaveValue('1990-01-01');

const sexField = startSessionPage.sessionForm.locator('[name="subjectSex"]');
await expect(sexField).toHaveValue('MALE');

const sessionTypeSelector = startSessionPage.sessionForm.locator('[name="sessionType"]');
await expect(sessionTypeSelector).toHaveValue('RETROSPECTIVE');

const expectedSessionDate = new Date().toISOString().split('T')[0]!;
const sessionDate = startSessionPage.sessionForm.locator('[name="sessionDate"]');
await expect(sessionDate).toHaveValue(expectedSessionDate);

await startSessionPage.submitForm();

await expect(startSessionPage.successMessage).toBeVisible();
});

test('should fill custom identifier input', async ({ getPageModel, page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'app',
JSON.stringify({ state: { isDisclaimerAccepted: true, isWalkthroughComplete: true }, version: 1 })
);
});

const startSessionPage = await getPageModel('/session/start-session');

await startSessionPage.sessionForm.waitFor({ state: 'visible' });
await startSessionPage.selectIdentificationMethod('CUSTOM_ID');

await expect(startSessionPage.selectField).toHaveValue('CUSTOM_ID');

await startSessionPage.fillCustomIdentifier('customIdentifierTest', 'Male');

const subjectIdField = startSessionPage.sessionForm.locator('[name="subjectId"]');
await expect(subjectIdField).toHaveValue('customIdentifierTest');

const sessionTypeSelector = startSessionPage.sessionForm.locator('[name="sessionType"]');
await expect(sessionTypeSelector).toHaveValue('RETROSPECTIVE');

const sessionDate = startSessionPage.sessionForm.locator('[name="sessionDate"]');
const expectedSessionDate = new Date().toISOString().split('T')[0]!;
await expect(sessionDate).toHaveValue(expectedSessionDate);

await startSessionPage.submitForm();

await expect(startSessionPage.successMessage).toBeVisible();
});
});
19 changes: 18 additions & 1 deletion testing/e2e/src/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import { test as base, expect } from '@playwright/test';

import { LoginPage } from '../pages/auth/login.page';
import { DashboardPage } from '../pages/dashboard.page';
import { DatahubPage } from '../pages/datahub/datahub.page';
import { SubjectDataTablePage } from '../pages/datahub/subject-data-table.page';
import { SetupPage } from '../pages/setup.page';
import { StartSessionPage } from '../pages/start-session.page';

import type { NavigateVariadicArgs, ProjectAuth, ProjectMetadata, RouteTo } from './types';

type PageModels = typeof pageModels;

const MAX_WAIT_MS = 30_000;
const POLL_INTERVAL_MS = 200;

type TestArgs = {
getPageModel: <TKey extends Extract<keyof PageModels, RouteTo>>(
key: TKey,
Expand All @@ -29,7 +34,9 @@ type WorkerArgs = {
const pageModels = {
'/auth/login': LoginPage,
'/dashboard': DashboardPage,
'/datahub': DatahubPage,
'/datahub/$subjectId/table': SubjectDataTablePage,
'/session/start-session': StartSessionPage,
'/setup': SetupPage
} satisfies { [K in RouteTo]?: any };

Expand All @@ -53,8 +60,18 @@ export const test = base.extend<TestArgs, WorkerArgs>({
async ({ getProjectMetadata }, use) => {
return use(async () => {
const authStorageFile = getProjectMetadata('authStorageFile');
// Wait for auth file to exist with timeout

const maxAttempts = MAX_WAIT_MS / POLL_INTERVAL_MS;
let attempts = 0;
while (!fs.existsSync(authStorageFile) && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
attempts++;
}
if (!fs.existsSync(authStorageFile)) {
throw new Error(`Cannot get project auth: storage file does not exist: ${authStorageFile}`);
throw new Error(
`Cannot get project auth: storage file does not exist after waiting 30000ms: ${authStorageFile}`
);
}
return JSON.parse(await fs.promises.readFile(authStorageFile, 'utf8')) as ProjectAuth;
});
Expand Down
13 changes: 13 additions & 0 deletions testing/e2e/src/pages/datahub/datahub.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Locator, Page } from '@playwright/test';

import { AppPage } from '../_app.page';

export class DatahubPage extends AppPage {
readonly pageHeader: Locator;
readonly rowActionsTrigger: Locator;
constructor(page: Page) {
super(page);
this.pageHeader = page.getByTestId('page-header');
this.rowActionsTrigger = page.getByTestId('row-actions-trigger').first();
}
}
78 changes: 78 additions & 0 deletions testing/e2e/src/pages/start-session.page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Locator, Page } from '@playwright/test';

import { AppPage } from './_app.page';

export class StartSessionPage extends AppPage {
readonly pageHeader: Locator;
readonly selectField: Locator;
readonly sessionForm: Locator;
readonly successMessage: Locator;

constructor(page: Page) {
super(page);
this.pageHeader = page.getByTestId('page-header');
this.sessionForm = page.getByTestId('start-session-form');
this.selectField = page.locator('[name="subjectIdentificationMethod"]');
this.successMessage = page.getByRole('heading', { name: 'Session Successfully Started' });
}

async fillCustomIdentifier(customIdentifier: string, sex: string) {
const subjectIdField = this.sessionForm.locator('[name="subjectId"]');
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');

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

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

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

async selectIdentificationMethod(methodName: string) {
await this.selectField.selectOption(methodName);
}

async submitForm() {
const submitButton = this.sessionForm.getByLabel('Submit');

await submitButton.waitFor({ state: 'visible' });

await submitButton.click();
}
}
2 changes: 1 addition & 1 deletion testing/e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"compilerOptions": {
"lib": ["DOM"]
},
"include": ["src/**/*", "playwright.config.ts"]
"include": ["src/**/*", "playwright.config.ts", "playwright.docker.config.ts"]
}