diff --git a/.gitignore b/.gitignore index 1d63d38fe..3425108a3 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,7 @@ storybook-static # claude .claude/settings.local.json + +# playwright +testing/e2e/.output/ +testing/e2e/playwright/.cache/ diff --git a/apps/api/package.json b/apps/api/package.json index 3b2728a3f..ce640df97 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,7 +8,7 @@ "build": "rm -rf dist && NODE_ENV=production libnest build -c libnest.config.ts", "db:generate": "prisma generate --no-hints", "dev": "NODE_ENV=development NODE_OPTIONS='--conditions=development' env-cmd -f ../../.env libnest dev -c libnest.config.ts", - "dev:test": "NODE_ENV=test env-cmd -f ../../.env libnest dev -c libnest.config.ts", + "dev:test": "NODE_ENV=test env-cmd -f ../../.env libnest dev -c libnest.config.ts --no-watch", "format": "prettier --write src", "lint": "tsc && eslint --fix src", "start": "NODE_ENV=production env-cmd -f ../../.env node dist/app.js", diff --git a/apps/api/src/setup/setup.controller.ts b/apps/api/src/setup/setup.controller.ts index 9edb6161e..c9e5d1ad0 100644 --- a/apps/api/src/setup/setup.controller.ts +++ b/apps/api/src/setup/setup.controller.ts @@ -1,5 +1,5 @@ import { RouteAccess } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Get, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Patch, Post } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { SetupState } from '@opendatacapture/schemas/setup'; @@ -12,6 +12,12 @@ import { SetupService } from './setup.service'; export class SetupController { constructor(private readonly setupService: SetupService) {} + @Delete() + @RouteAccess({ action: 'delete', subject: 'all' }) + async delete(): Promise { + return this.setupService.delete(); + } + @ApiOperation({ description: 'Return the current setup state', summary: 'Get State' diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index d49465d22..2eed7800c 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -20,6 +20,14 @@ export class SetupService { return this.usersService.create({ ...admin, basePermissionLevel: 'ADMIN', groupIds: [] }); } + async delete(): Promise { + const isTest = this.configService.get('NODE_ENV') === 'test'; + if (!isTest) { + throw new ForbiddenException('Cannot access outside of test'); + } + await this.prismaService.dropDatabase(); + } + async getState() { const savedOptions = await this.getSavedOptions(); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4583bbeb9..e573d9e2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,7 +133,7 @@ importers: version: 22.16.3 '@vitest/browser': specifier: ^3.2.4 - version: 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) + version: 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(playwright@1.56.0)(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4) @@ -1273,6 +1273,18 @@ importers: specifier: workspace:type-fest__4.x@* version: link:../../vendor/type-fest@4.x + testing/e2e: + dependencies: + '@douglasneuroinformatics/libjs': + specifier: 'catalog:' + version: 3.1.0(neverthrow@8.2.0)(zod@3.25.76) + '@opendatacapture/schemas': + specifier: workspace:* + version: link:../../packages/schemas + '@playwright/test': + specifier: ^1.51.1 + version: 1.56.0 + testing/k6: dependencies: '@opendatacapture/demo': @@ -3917,6 +3929,12 @@ packages: { integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== } engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } + '@playwright/test@1.56.0': + resolution: + { integrity: sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg== } + engines: { node: '>=18' } + hasBin: true + '@polka/url@1.0.0-next.29': resolution: { integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== } @@ -7962,6 +7980,12 @@ packages: resolution: { integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== } + fsevents@2.3.2: + resolution: + { integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + fsevents@2.3.3: resolution: { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } @@ -10340,6 +10364,18 @@ packages: { integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== } engines: { node: '>=8' } + playwright-core@1.56.0: + resolution: + { integrity: sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ== } + engines: { node: '>=18' } + hasBin: true + + playwright@1.56.0: + resolution: + { integrity: sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA== } + engines: { node: '>=18' } + hasBin: true + possible-typed-array-names@1.1.0: resolution: { integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== } @@ -15035,6 +15071,10 @@ snapshots: '@pkgr/core@0.2.7': {} + '@playwright/test@1.56.0': + dependencies: + playwright: 1.56.0 + '@polka/url@1.0.0-next.29': {} '@prisma/client@6.11.1(prisma@6.11.1(typescript@5.6.3))(typescript@5.6.3)': @@ -17065,7 +17105,7 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitest/browser@3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4)': + '@vitest/browser@3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(playwright@1.56.0)(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) @@ -17076,6 +17116,8 @@ snapshots: tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.3)(@vitest/browser@3.2.4)(happy-dom@20.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(tsx@4.8.2)(yaml@2.8.0) ws: 8.18.3 + optionalDependencies: + playwright: 1.56.0 transitivePeerDependencies: - bufferutil - msw @@ -17099,7 +17141,7 @@ snapshots: tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.3)(@vitest/browser@3.2.4)(happy-dom@20.0.0)(jiti@2.4.2)(lightningcss@1.30.1)(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(tsx@4.8.2)(yaml@2.8.0) optionalDependencies: - '@vitest/browser': 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) + '@vitest/browser': 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(playwright@1.56.0)(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) transitivePeerDependencies: - supports-color @@ -19456,6 +19498,9 @@ snapshots: fs.realpath@1.0.0: optional: true + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -21826,6 +21871,14 @@ snapshots: dependencies: find-up: 4.1.0 + playwright-core@1.56.0: {} + + playwright@1.56.0: + dependencies: + playwright-core: 1.56.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-load-config@3.1.4(postcss@8.5.6): @@ -23975,7 +24028,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.16.3 - '@vitest/browser': 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) + '@vitest/browser': 3.2.4(msw@2.10.4(@types/node@22.16.3)(typescript@5.6.3))(playwright@1.56.0)(vite@6.3.5(@types/node@22.16.3)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.8.2)(yaml@2.8.0))(vitest@3.2.4) happy-dom: 20.0.0 transitivePeerDependencies: - jiti diff --git a/testing/e2e/package.json b/testing/e2e/package.json new file mode 100644 index 000000000..09214bb12 --- /dev/null +++ b/testing/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@opendatacapture/test", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "lint": "tsc && eslint --fix .", + "test": "env-cmd -f ../../.env playwright test", + "test:dev": "env-cmd -f ../../.env playwright test --ui" + }, + "dependencies": { + "@douglasneuroinformatics/libjs": "catalog:", + "@opendatacapture/schemas": "workspace:*", + "@playwright/test": "^1.51.1" + } +} diff --git a/testing/e2e/playwright.config.ts b/testing/e2e/playwright.config.ts new file mode 100644 index 000000000..c25e2d7da --- /dev/null +++ b/testing/e2e/playwright.config.ts @@ -0,0 +1,67 @@ +import * as path from 'node:path'; + +import { parseNumber } from '@douglasneuroinformatics/libjs'; +import { defineConfig, devices } from '@playwright/test'; + +const API_PORT = parseNumber(process.env.API_DEV_SERVER_PORT); +const WEB_PORT = parseNumber(process.env.WEB_DEV_SERVER_PORT); + +export default defineConfig({ + fullyParallel: true, + outputDir: path.resolve(import.meta.dirname, '.output/results'), + projects: [ + { + name: 'Global Setup', + teardown: 'Global Teardown', + testMatch: '**/global/global.setup.spec.ts' + }, + { + name: 'Global Teardown', + testMatch: '**/global/global.teardown.spec.ts' + }, + { + dependencies: ['Global Setup'], + name: 'Desktop Chrome', + testIgnore: '**/global/**', + use: { ...devices['Desktop Chrome'] } + }, + { + dependencies: ['Global Setup'], + name: 'Desktop Firefox', + testIgnore: '**/global/**', + use: { ...devices['Desktop Firefox'] } + }, + { + dependencies: ['Global Setup'], + name: 'Desktop Safari', + testIgnore: '**/global/**', + use: { ...devices['Desktop Safari'] } + } + ], + reporter: [['html', { open: 'never', outputFolder: path.resolve(import.meta.dirname, '.output/report') }]], + testDir: path.resolve(import.meta.dirname, 'src'), + use: { + baseURL: `http://localhost:${WEB_PORT}`, + trace: 'on-first-retry' + }, + webServer: [ + { + command: 'pnpm dev:test', + cwd: path.resolve(import.meta.dirname, '../../apps/api'), + gracefulShutdown: { + signal: 'SIGINT', + timeout: 1000 + }, + url: `http://localhost:${API_PORT}` + }, + { + command: 'pnpm dev:test', + cwd: path.resolve(import.meta.dirname, '../../apps/web'), + gracefulShutdown: { + signal: 'SIGINT', + timeout: 1000 + }, + url: `http://localhost:${WEB_PORT}` + } + ] +}); diff --git a/testing/e2e/src/global/global.setup.spec.ts b/testing/e2e/src/global/global.setup.spec.ts new file mode 100644 index 000000000..b30dd6ddd --- /dev/null +++ b/testing/e2e/src/global/global.setup.spec.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import type { LoginCredentials } from '@opendatacapture/schemas/auth'; +import type { InitAppOptions } from '@opendatacapture/schemas/setup'; +import { expect, test } from '@playwright/test'; + +const initOptions = { + admin: { + firstName: 'Jane', + lastName: 'Doe', + password: 'DataCapture2025', + username: 'admin' + }, + dummySubjectCount: 10, + enableExperimentalFeatures: false, + initDemo: true, + recordsPerSubject: 10 +} satisfies InitAppOptions; + +declare global { + namespace NodeJS { + interface ProcessEnv { + ADMIN_ACCESS_TOKEN: string; + ADMIN_PASSWORD: string; + ADMIN_USERNAME: string; + GLOBAL_SETUP_COMPLETE?: '1'; + } + } +} + +test.skip(() => process.env.GLOBAL_SETUP_COMPLETE === '1'); + +test.describe.serial(() => { + test.describe.serial('setup', () => { + test('initial setup', async ({ request }) => { + const response = await request.get('/api/v1/setup'); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toMatchObject({ isSetup: false }); + }); + test('successful setup', async ({ page }) => { + await page.goto('/setup'); + await expect(page).toHaveURL('/setup'); + const setupForm = page.locator('form[data-cy="setup-form"]'); + await setupForm.locator('input[name="firstName"]').fill(initOptions.admin.firstName); + await setupForm.locator('input[name="lastName"]').fill(initOptions.admin.lastName); + await setupForm.locator('input[name="username"]').fill(initOptions.admin.username); + await setupForm.locator('input[name="password"]').fill(initOptions.admin.password); + await setupForm.locator('input[name="confirmPassword"]').fill(initOptions.admin.password); + await setupForm.locator('#initDemo-true').click(); + await setupForm.locator('input[name="dummySubjectCount"]').fill(initOptions.dummySubjectCount.toString()); + await setupForm.locator('input[name="recordsPerSubject"]').fill(initOptions.recordsPerSubject.toString()); + await setupForm.getByLabel('Submit').click(); + await expect(page).toHaveURL('/dashboard'); + }); + test('setup state after initialization', async ({ request }) => { + const response = await request.get('/api/v1/setup'); + expect(response.status()).toBe(200); + await expect(response.json()).resolves.toMatchObject({ isSetup: true }); + }); + }); + test.describe.serial('auth', () => { + test('login', async ({ request }) => { + const { password, username } = initOptions.admin; + const response = await request.post('/api/v1/auth/login', { + data: { password, username } satisfies LoginCredentials + }); + expect(response.status()).toBe(200); + const { accessToken } = await response.json(); + expect(typeof accessToken).toBe('string'); + process.env.ADMIN_ACCESS_TOKEN = accessToken; + process.env.ADMIN_USERNAME = username; + process.env.ADMIN_PASSWORD = password; + process.env.GLOBAL_SETUP_COMPLETE = '1'; + }); + }); +}); diff --git a/testing/e2e/src/global/global.teardown.spec.ts b/testing/e2e/src/global/global.teardown.spec.ts new file mode 100644 index 000000000..ea343a6f2 --- /dev/null +++ b/testing/e2e/src/global/global.teardown.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +test('delete database', async ({ request }) => { + const response = await request.delete('/api/v1/setup', { + headers: { + Authorization: `Bearer ${process.env.ADMIN_ACCESS_TOKEN}` + } + }); + expect(response.status()).toBe(200); +}); diff --git a/testing/e2e/tsconfig.json b/testing/e2e/tsconfig.json new file mode 100644 index 000000000..180316516 --- /dev/null +++ b/testing/e2e/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../tsconfig.base.json"], + "include": ["src/**/*", "playwright.config.ts"] +}