Skip to content

Commit d9fbbd1

Browse files
authored
Merge pull request #1290 from david-roper/e2e-testing
E2e testing
2 parents f208729 + 844cd8e commit d9fbbd1

9 files changed

Lines changed: 329 additions & 6 deletions

File tree

apps/web/src/providers/DisclaimerProvider.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const DisclaimerProvider: React.FC<{ children: React.ReactElement }> = ({
1414
return (
1515
<React.Fragment>
1616
{children}
17-
<Dialog open={!isDisclaimerAccepted}>
18-
<Dialog.Content onOpenAutoFocus={(event) => event.preventDefault()}>
17+
<Dialog data-test-id="Disclaimer-dialog" open={!isDisclaimerAccepted}>
18+
<Dialog.Content data-test-id="Disclaimer-dialog-content" onOpenAutoFocus={(event) => event.preventDefault()}>
1919
<Dialog.Header>
2020
<Dialog.Title>
2121
{t({
@@ -31,10 +31,10 @@ export const DisclaimerProvider: React.FC<{ children: React.ReactElement }> = ({
3131
</Dialog.Description>
3232
</Dialog.Header>
3333
<Dialog.Footer>
34-
<Button type="button" onClick={() => setIsDisclaimerAccepted(true)}>
34+
<Button data-test-id="accept-disclaimer" type="button" onClick={() => setIsDisclaimerAccepted(true)}>
3535
{t({ en: 'Accept', fr: 'Accepter' })}
3636
</Button>
37-
<Button type="button" variant="outline" onClick={logout}>
37+
<Button data-test-id="decline-disclaimer" type="button" variant="outline" onClick={logout}>
3838
{t({ en: 'Decline', fr: 'Refuser' })}
3939
</Button>
4040
</Dialog.Footer>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as crypto from 'node:crypto';
2+
import * as path from 'node:path';
3+
4+
import { parseNumber, range, unwrap } from '@douglasneuroinformatics/libjs';
5+
import { defineConfig, devices } from '@playwright/test';
6+
import type { Project } from '@playwright/test';
7+
8+
import { AUTH_STORAGE_DIR } from './src/helpers/constants';
9+
10+
import type { BrowserTarget, ProjectMetadata } from './src/helpers/types';
11+
12+
const appPort = parseNumber(process.env.APP_PORT);
13+
const gatewayPort = parseNumber(process.env.GATEWAY_PORT);
14+
15+
if (Number.isNaN(appPort)) {
16+
throw new Error(`Expected APP_PORT to be number, got ${process.env.APP_PORT}`);
17+
} else if (Number.isNaN(gatewayPort)) {
18+
throw new Error(`Expected GATEWAY_PORT to be number, got ${process.env.GATEWAY_PORT}`);
19+
}
20+
21+
const baseURL = `http://localhost:${appPort}`;
22+
23+
const browsers: { target: BrowserTarget; use: Project['use'] }[] = [
24+
{ target: 'Desktop Chrome', use: { ...devices['Desktop Chrome'], channel: 'chromium', headless: true } },
25+
{ target: 'Desktop Firefox', use: { ...devices['Desktop Firefox'], headless: true } }
26+
] as const;
27+
28+
export default defineConfig({
29+
globalSetup: path.resolve(import.meta.dirname, 'src/global/global.setup.ts'),
30+
globalTeardown: path.resolve(import.meta.dirname, 'src/global/global.teardown.ts'),
31+
maxFailures: 1,
32+
outputDir: path.resolve(import.meta.dirname, '.playwright/output'),
33+
projects: [
34+
{
35+
name: 'Global Setup',
36+
teardown: 'Global Teardown',
37+
testMatch: '**/global/global.setup.spec.ts',
38+
use: {
39+
baseURL
40+
}
41+
},
42+
{
43+
name: 'Global Teardown',
44+
testMatch: '**/global/global.teardown.spec.ts',
45+
use: {
46+
baseURL
47+
}
48+
},
49+
...unwrap(range(1, 4)).flatMap((i) => {
50+
return browsers.map((browser) => {
51+
const browserId = crypto.createHash('sha256').update(browser.target).digest('hex');
52+
return {
53+
dependencies: i === 1 ? ['Global Setup'] : [`${i - 1}.x - ${browser.target}`],
54+
metadata: {
55+
authStorageFile: path.resolve(AUTH_STORAGE_DIR, `${browserId}.json`),
56+
browserId,
57+
browserTarget: browser.target
58+
} satisfies ProjectMetadata,
59+
name: `${i}.x - ${browser.target}`,
60+
testMatch: `**/${i}.*.spec.ts`,
61+
use: {
62+
...browser.use,
63+
baseURL
64+
}
65+
};
66+
});
67+
})
68+
],
69+
reporter: [['html', { open: 'never', outputFolder: path.resolve(import.meta.dirname, '.playwright/report') }]],
70+
testDir: path.resolve(import.meta.dirname, 'src'),
71+
webServer: [
72+
{
73+
command: 'true', // Dummy command since services are assumed running in Docker
74+
reuseExistingServer: true,
75+
timeout: 10_000,
76+
url: `http://localhost:${appPort}/api/v1/setup`
77+
},
78+
{
79+
command: 'true', // Dummy command since services are assumed running in Docker
80+
reuseExistingServer: true,
81+
timeout: 10_000,
82+
url: `http://localhost:${gatewayPort}/api/healthcheck`
83+
},
84+
{
85+
command: 'true', // Dummy command since services are assumed running in Docker
86+
reuseExistingServer: true,
87+
timeout: 10_000,
88+
url: `http://localhost:${appPort}`
89+
}
90+
],
91+
workers: process.env.CI ? 1 : undefined
92+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect, test } from './helpers/fixtures';
2+
3+
test.describe('disclaimer', () => {
4+
test('should accept the disclaimer', async ({ getProjectAuth, page }) => {
5+
// Get the auth token
6+
const auth = await getProjectAuth();
7+
8+
// Set localStorage to ensure disclaimer appears and set auth
9+
await page.addInitScript((accessToken) => {
10+
window.__PLAYWRIGHT_ACCESS_TOKEN__ = accessToken;
11+
// Set the app localStorage item to ensure disclaimer is not accepted
12+
localStorage.setItem('app', JSON.stringify({ state: { isDisclaimerAccepted: false }, version: 1 }));
13+
}, auth.accessToken);
14+
15+
await page.goto('/dashboard');
16+
17+
const disclaimerDialog = page.getByRole('dialog', { name: 'Disclaimer' });
18+
await expect(disclaimerDialog).toBeVisible();
19+
20+
// Click the accept disclaimer button
21+
const acceptButton = page.getByRole('button', { name: 'Accept' });
22+
await expect(acceptButton).toBeVisible();
23+
await acceptButton.click();
24+
25+
await expect(disclaimerDialog).not.toBeVisible();
26+
27+
const pageHeader = page.getByTestId('page-header');
28+
await expect(pageHeader).toBeVisible();
29+
await expect(pageHeader).toContainText('Dashboard');
30+
});
31+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { expect, test } from './helpers/fixtures';
2+
3+
test.describe('dashhub', () => {
4+
test('should display the dashhub header', async ({ getPageModel }) => {
5+
const datahubPage = await getPageModel('/datahub');
6+
await expect(datahubPage.pageHeader).toBeVisible();
7+
await expect(datahubPage.pageHeader).toContainText('Data Hub');
8+
});
9+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect, test } from './helpers/fixtures';
2+
3+
test.describe('start session', () => {
4+
test('should display the start session form header', async ({ getPageModel }) => {
5+
const startSessionPage = await getPageModel('/session/start-session');
6+
await expect(startSessionPage.pageHeader).toBeVisible();
7+
await expect(startSessionPage.pageHeader).toContainText('Start Session');
8+
await expect(startSessionPage.sessionForm).toBeVisible();
9+
});
10+
11+
test('should fill subject personal information input', async ({ getPageModel, page }) => {
12+
await page.addInitScript(() => {
13+
localStorage.setItem(
14+
'app',
15+
JSON.stringify({ state: { isDisclaimerAccepted: true, isWalkthroughComplete: true }, version: 1 })
16+
);
17+
});
18+
19+
const startSessionPage = await getPageModel('/session/start-session');
20+
21+
await startSessionPage.sessionForm.waitFor({ state: 'visible' });
22+
await startSessionPage.selectIdentificationMethod('PERSONAL_INFO');
23+
24+
await expect(startSessionPage.selectField).toHaveValue('PERSONAL_INFO');
25+
26+
await startSessionPage.fillSessionForm('firstNameTest', 'lastNameTest', 'Male');
27+
28+
const firstNameField = startSessionPage.sessionForm.locator('[name="subjectFirstName"]');
29+
await expect(firstNameField).toHaveValue('firstNameTest');
30+
31+
const lastNameField = startSessionPage.sessionForm.locator('[name="subjectLastName"]');
32+
await expect(lastNameField).toHaveValue('lastNameTest');
33+
34+
const dobField = startSessionPage.sessionForm.locator('[name="subjectDateOfBirth"]');
35+
await expect(dobField).toHaveValue('1990-01-01');
36+
37+
const sexField = startSessionPage.sessionForm.locator('[name="subjectSex"]');
38+
await expect(sexField).toHaveValue('MALE');
39+
40+
const sessionTypeSelector = startSessionPage.sessionForm.locator('[name="sessionType"]');
41+
await expect(sessionTypeSelector).toHaveValue('RETROSPECTIVE');
42+
43+
const expectedSessionDate = new Date().toISOString().split('T')[0]!;
44+
const sessionDate = startSessionPage.sessionForm.locator('[name="sessionDate"]');
45+
await expect(sessionDate).toHaveValue(expectedSessionDate);
46+
47+
await startSessionPage.submitForm();
48+
49+
await expect(startSessionPage.successMessage).toBeVisible();
50+
});
51+
52+
test('should fill custom identifier input', async ({ getPageModel, page }) => {
53+
await page.addInitScript(() => {
54+
localStorage.setItem(
55+
'app',
56+
JSON.stringify({ state: { isDisclaimerAccepted: true, isWalkthroughComplete: true }, version: 1 })
57+
);
58+
});
59+
60+
const startSessionPage = await getPageModel('/session/start-session');
61+
62+
await startSessionPage.sessionForm.waitFor({ state: 'visible' });
63+
await startSessionPage.selectIdentificationMethod('CUSTOM_ID');
64+
65+
await expect(startSessionPage.selectField).toHaveValue('CUSTOM_ID');
66+
67+
await startSessionPage.fillCustomIdentifier('customIdentifierTest', 'Male');
68+
69+
const subjectIdField = startSessionPage.sessionForm.locator('[name="subjectId"]');
70+
await expect(subjectIdField).toHaveValue('customIdentifierTest');
71+
72+
const sessionTypeSelector = startSessionPage.sessionForm.locator('[name="sessionType"]');
73+
await expect(sessionTypeSelector).toHaveValue('RETROSPECTIVE');
74+
75+
const sessionDate = startSessionPage.sessionForm.locator('[name="sessionDate"]');
76+
const expectedSessionDate = new Date().toISOString().split('T')[0]!;
77+
await expect(sessionDate).toHaveValue(expectedSessionDate);
78+
79+
await startSessionPage.submitForm();
80+
81+
await expect(startSessionPage.successMessage).toBeVisible();
82+
});
83+
});

testing/e2e/src/helpers/fixtures.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ import { test as base, expect } from '@playwright/test';
66

77
import { LoginPage } from '../pages/auth/login.page';
88
import { DashboardPage } from '../pages/dashboard.page';
9+
import { DatahubPage } from '../pages/datahub/datahub.page';
910
import { SubjectDataTablePage } from '../pages/datahub/subject-data-table.page';
1011
import { SetupPage } from '../pages/setup.page';
12+
import { StartSessionPage } from '../pages/start-session.page';
1113

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

1416
type PageModels = typeof pageModels;
1517

18+
const MAX_WAIT_MS = 30_000;
19+
const POLL_INTERVAL_MS = 200;
20+
1621
type TestArgs = {
1722
getPageModel: <TKey extends Extract<keyof PageModels, RouteTo>>(
1823
key: TKey,
@@ -29,7 +34,9 @@ type WorkerArgs = {
2934
const pageModels = {
3035
'/auth/login': LoginPage,
3136
'/dashboard': DashboardPage,
37+
'/datahub': DatahubPage,
3238
'/datahub/$subjectId/table': SubjectDataTablePage,
39+
'/session/start-session': StartSessionPage,
3340
'/setup': SetupPage
3441
} satisfies { [K in RouteTo]?: any };
3542

@@ -53,8 +60,18 @@ export const test = base.extend<TestArgs, WorkerArgs>({
5360
async ({ getProjectMetadata }, use) => {
5461
return use(async () => {
5562
const authStorageFile = getProjectMetadata('authStorageFile');
63+
// Wait for auth file to exist with timeout
64+
65+
const maxAttempts = MAX_WAIT_MS / POLL_INTERVAL_MS;
66+
let attempts = 0;
67+
while (!fs.existsSync(authStorageFile) && attempts < maxAttempts) {
68+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
69+
attempts++;
70+
}
5671
if (!fs.existsSync(authStorageFile)) {
57-
throw new Error(`Cannot get project auth: storage file does not exist: ${authStorageFile}`);
72+
throw new Error(
73+
`Cannot get project auth: storage file does not exist after waiting 30000ms: ${authStorageFile}`
74+
);
5875
}
5976
return JSON.parse(await fs.promises.readFile(authStorageFile, 'utf8')) as ProjectAuth;
6077
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Locator, Page } from '@playwright/test';
2+
3+
import { AppPage } from '../_app.page';
4+
5+
export class DatahubPage extends AppPage {
6+
readonly pageHeader: Locator;
7+
readonly rowActionsTrigger: Locator;
8+
constructor(page: Page) {
9+
super(page);
10+
this.pageHeader = page.getByTestId('page-header');
11+
this.rowActionsTrigger = page.getByTestId('row-actions-trigger').first();
12+
}
13+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Locator, Page } from '@playwright/test';
2+
3+
import { AppPage } from './_app.page';
4+
5+
export class StartSessionPage extends AppPage {
6+
readonly pageHeader: Locator;
7+
readonly selectField: Locator;
8+
readonly sessionForm: Locator;
9+
readonly successMessage: Locator;
10+
11+
constructor(page: Page) {
12+
super(page);
13+
this.pageHeader = page.getByTestId('page-header');
14+
this.sessionForm = page.getByTestId('start-session-form');
15+
this.selectField = page.locator('[name="subjectIdentificationMethod"]');
16+
this.successMessage = page.getByRole('heading', { name: 'Session Successfully Started' });
17+
}
18+
19+
async fillCustomIdentifier(customIdentifier: string, sex: string) {
20+
const subjectIdField = this.sessionForm.locator('[name="subjectId"]');
21+
const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]');
22+
const sexSelector = this.sessionForm.locator('[name="subjectSex"]');
23+
const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]');
24+
const sessionDate = this.sessionForm.locator('[name="sessionDate"]');
25+
26+
await subjectIdField.waitFor({ state: 'visible' });
27+
await subjectIdField.fill(customIdentifier);
28+
29+
await dateOfBirthField.waitFor({ state: 'visible' });
30+
await dateOfBirthField.fill('1990-01-01');
31+
32+
await sexSelector.selectOption(sex);
33+
34+
await sessionTypeSelector.selectOption('Retrospective');
35+
36+
await sessionDate.waitFor({ state: 'visible' });
37+
const expectedSessionDate = new Date().toISOString().split('T')[0]!;
38+
await sessionDate.fill(expectedSessionDate);
39+
}
40+
41+
async fillSessionForm(firstName: string, lastName: string, sex: string) {
42+
const firstNameField = this.sessionForm.locator('[name="subjectFirstName"]');
43+
const lastNameField = this.sessionForm.locator('[name="subjectLastName"]');
44+
const dateOfBirthField = this.sessionForm.locator('[name="subjectDateOfBirth"]');
45+
const sexSelector = this.sessionForm.locator('[name="subjectSex"]');
46+
const sessionTypeSelector = this.sessionForm.locator('[name="sessionType"]');
47+
const sessionDate = this.sessionForm.locator('[name="sessionDate"]');
48+
49+
await firstNameField.waitFor({ state: 'visible' });
50+
await firstNameField.fill(firstName);
51+
52+
await lastNameField.waitFor({ state: 'visible' });
53+
await lastNameField.fill(lastName);
54+
55+
await dateOfBirthField.waitFor({ state: 'visible' });
56+
await dateOfBirthField.fill('1990-01-01');
57+
58+
await sexSelector.selectOption(sex);
59+
60+
await sessionTypeSelector.selectOption('Retrospective');
61+
62+
await sessionDate.waitFor({ state: 'visible' });
63+
const expectedSessionDate = new Date().toISOString().split('T')[0]!;
64+
await sessionDate.fill(expectedSessionDate);
65+
}
66+
67+
async selectIdentificationMethod(methodName: string) {
68+
await this.selectField.selectOption(methodName);
69+
}
70+
71+
async submitForm() {
72+
const submitButton = this.sessionForm.getByLabel('Submit');
73+
74+
await submitButton.waitFor({ state: 'visible' });
75+
76+
await submitButton.click();
77+
}
78+
}

testing/e2e/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"compilerOptions": {
44
"lib": ["DOM"]
55
},
6-
"include": ["src/**/*", "playwright.config.ts"]
6+
"include": ["src/**/*", "playwright.config.ts", "playwright.docker.config.ts"]
77
}

0 commit comments

Comments
 (0)