From 20b70b28e0045585045e055e3dc975808b4d5b6a Mon Sep 17 00:00:00 2001 From: Chris Bayliss Date: Thu, 16 Jul 2026 11:25:10 +1000 Subject: [PATCH] feat(test): adds Playwright driven E2E tests Adds [Playwright](https://playwright.dev/) based end-to-end tests. Initially just some basic strucural checks more suitable for smoke testing an install. See (the documentation)[https://nextjs.org/docs/15/app/guides/testing/playwright] for details. --- .gitignore | 8 +++ package-lock.json | 66 ++++++++++++++++++- package.json | 4 +- playwright.config.ts | 84 ++++++++++++++++++++++++ tests/fixtures/multipartInterviewPage.ts | 22 +++++++ tests/frontPage.spec.ts | 71 ++++++++++++++++++++ tests/issues.spec.ts | 39 +++++++++++ tests/multiPartInterview.spec.ts | 39 +++++++++++ tests/test.ts | 24 +++++++ 9 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/fixtures/multipartInterviewPage.ts create mode 100644 tests/frontPage.spec.ts create mode 100644 tests/issues.spec.ts create mode 100644 tests/multiPartInterview.spec.ts create mode 100644 tests/test.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..546c174 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,11 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/package-lock.json b/package-lock.json index ebd0534..544f59d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pg": "^8.11.11", @@ -2060,6 +2061,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3685,7 +3702,6 @@ "version": "19.0.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3695,7 +3711,7 @@ "version": "19.0.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -5802,6 +5818,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7569,6 +7599,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 1b9999a..90e43a2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "preview": "next build && next start" + "preview": "next build && next start", + "test": "playwright test" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -48,6 +49,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/pg": "^8.11.11", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..86b36b1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,84 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: 'http://localhost:3000', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + wait: { + // stdout: /Ready in/ + stdout: /Local:\s+(?http.+)/ + }, + }, +}); diff --git a/tests/fixtures/multipartInterviewPage.ts b/tests/fixtures/multipartInterviewPage.ts new file mode 100644 index 0000000..818ca35 --- /dev/null +++ b/tests/fixtures/multipartInterviewPage.ts @@ -0,0 +1,22 @@ +import type {Locator, Page} from '@playwright/test'; + +export class MultipartInterviewsPage { + + readonly helpBox: Locator; + readonly multipartTable: Locator; + readonly resultCount: Locator; + readonly nextPageButton: Locator; + readonly previousPageButton: Locator; + + constructor(public readonly page: Page) { + this.helpBox = this.page.getByText('Help differentiate interviews with:1Multi-part Interviews - Interviews that are'); + this.resultCount = this.page.getByText(/The following \d+ interviews have multiple parts/); + this.multipartTable = this.page.getByText('ColumnsFiltersDensityExportInterview NameInterview TypeSubject IDStudy IDParts'); + this.nextPageButton = this.page.getByRole("button", {description: "Go to next page"}); + this.previousPageButton = this.page.getByRole("button", {description: "Go to previous page",}); + } + + async goto() { + await this.page.goto("/issues/multiPart"); // Assumes baseURL is set correctly in playwright.config.ts + } +} diff --git a/tests/frontPage.spec.ts b/tests/frontPage.spec.ts new file mode 100644 index 0000000..04f9c15 --- /dev/null +++ b/tests/frontPage.spec.ts @@ -0,0 +1,71 @@ +import {expect, test} from '@playwright/test'; + +test('main components are present', async ({page}) => { + await page.goto(""); + await expect(page.getByRole('link', {name: 'AV QC Portal v0.2.0'})).toBeVisible(); + await expect(page.getByText('Toggle SidebarHome')).toBeVisible(); + await expect(page.getByText('NavigationIssuesToggleMulti-')).toBeVisible(); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link "AV QC Portal v0.2.0": + - /url: / + - img + - text: "" + `); +}); + +test.describe("main matter", async () => { + + test.beforeEach(async ({page}) => { + await page.goto(""); + }); + + test("Info box", async ({page}) => { + await expect(page.getByRole("main")).toBeVisible(); + await expect(page.getByRole("heading", {name: "👋 Welcome to AV QC Portal"})).toBeVisible() + await expect(page.getByText("This web portal is actively being developed as a companion to theAV QC pipelineproject, designed to streamline audiovisual quality control processes.")).toBeVisible(); + }) +}); + +test.describe("The sidebar", async () => { + + test.beforeEach(async ({page}) => { + await page.goto(""); + }); + + test('Sidebar', async ({page}) => { + await page.goto(""); + await expect(page.locator('body')).toMatchAriaSnapshot(`- text: Navigation`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link "Multi-Part Interviews": + - /url: /issues/multiPart + - listitem: + - link "Unlabelled Audio": + - /url: /issues/unlabelledAudio + - listitem: + - link "Missing Interviews": + - /url: /issues/missing + - listitem: + - link "Missing Runsheets": + - /url: /issues/noRunsheet + - listitem: + - link "Missing Transcripts": + - /url: /issues/noTranscript + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - link "Interviews": + - /url: /interviews + - img + - text: "" + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - link "Audio Journals": + - /url: /journals + - img + - text: "" + `); + }) +}); diff --git a/tests/issues.spec.ts b/tests/issues.spec.ts new file mode 100644 index 0000000..a69eee0 --- /dev/null +++ b/tests/issues.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => { + await page.goto("/utils"); + await expect(page.getByRole('link', { name: '🔍 Quality Control Monitor' })).toBeVisible(); + await page.getByRole('link', { name: '🔍 Quality Control Monitor' }).click(); + await expect(page.getByRole('link', { name: '📹 Multi-Part Interviews Mark' })).toBeVisible(); + await expect(page.getByRole('link', { name: '🎧 Unlabelled Diarized Audio' })).toBeVisible(); + await expect(page.getByRole('link', { name: '📁 Missing Interviews List' })).toBeVisible(); + await expect(page.getByRole('link', { name: '📜 Missing Transcripts List' })).toBeVisible(); + await expect(page.getByRole('link', { name: '📜 Missing Runsheets List' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Issues' })).toBeVisible(); + await expect(page.getByRole('main')).toContainText('This section covers various issues raised / detected by the AV QC pipeline.'); + await expect(page.getByRole('main')).toMatchAriaSnapshot(` + - heading "Issues" [level=2] + - paragraph: This section covers various issues raised / detected by the AV QC pipeline. + - link "📹 Multi-Part Interviews Mark parts of the interview to process, or ignore.": + - /url: /issues/multiPart + - heading "📹 Multi-Part Interviews" [level=4] + - paragraph: Mark parts of the interview to process, or ignore. + - link "🎧 Unlabelled Diarized Audio Label unlabelled audio files with the correct roles, for further downstream processing.": + - /url: /issues/unlabelledAudio + - heading "🎧 Unlabelled Diarized Audio" [level=4] + - paragraph: Label unlabelled audio files with the correct roles, for further downstream processing. + - link "📁 Missing Interviews List interviews with Runsheets marked as conducted, but no data associated with them.": + - /url: /issues/missing + - heading "📁 Missing Interviews" [level=4] + - paragraph: List interviews with Runsheets marked as conducted, but no data associated with them. + - link "📜 Missing Transcripts List interviews with video / audio data, but no associated transcripts.": + - /url: /issues/noTranscript + - heading "📜 Missing Transcripts" [level=4] + - paragraph: List interviews with video / audio data, but no associated transcripts. + - link "📜 Missing Runsheets List interviews with video / audio data, but no associated runsheets.": + - /url: /issues/noRunsheet + - heading "📜 Missing Runsheets" [level=4] + - paragraph: List interviews with video / audio data, but no associated runsheets. + - paragraph: This project is under active development. If you need more issues catalogued, please reach out to developers. + `); +}); diff --git a/tests/multiPartInterview.spec.ts b/tests/multiPartInterview.spec.ts new file mode 100644 index 0000000..bd87d22 --- /dev/null +++ b/tests/multiPartInterview.spec.ts @@ -0,0 +1,39 @@ +import {expect, test} from './test'; + +test('can navigate to page', async ({page}) => { + await page.goto("") + await expect(page.getByRole('navigation', {name: 'breadcrumb'})).toBeVisible(); + await page.locator('a').filter({hasText: 'Multi-Part Interviews'}).click(); + await expect(page.getByRole('heading')).toContainText('Interviews with Multiple Parts'); + await expect(page.getByRole('main')).toContainText('Help differentiate interviews with:'); + await expect(page.getByRole('main')).toContainText('Multi-part Interviews - Interviews that are split into multiple parts. Common with PSYCHS interviews, where interviews could happen over multiple days.'); + await expect(page.getByRole('main')).toContainText('Duplicate Interviews - Interviews that are duplicates of each other. Probably related to Data Flow and renaming files at the source.'); +}); + +test.describe("Multipart Interviews page", async () => { + + /** + * Checks all the major static parts of the page are present + */ + test("main components present", async ({page, multipartInterviews}) => { + await expect(multipartInterviews.helpBox).toBeVisible(); + await expect(page.getByRole('main')).toContainText('Multi-part Interviews - Interviews that are split into multiple parts. Common with PSYCHS interviews, where interviews could happen over multiple days.'); + await expect(page.getByRole('main')).toContainText('Duplicate Interviews - Interviews that are duplicates of each other. Probably related to Data Flow and renaming files at the source.'); + await expect(multipartInterviews.multipartTable).toBeVisible(); + }); + + /** + * Checks the results table is well-formed + */ + test('interviews loaded', async ({multipartInterviews}) => { + await expect(multipartInterviews.resultCount).toBeVisible(); + + // We're on the first page of results so there should be no previous page + await expect(multipartInterviews.previousPageButton).toBeVisible(); + await expect(multipartInterviews.previousPageButton).toBeDisabled(); + + // We're on the next button should be present by may not be enabled + await expect(multipartInterviews.nextPageButton).toBeVisible(); + await expect.soft(multipartInterviews.nextPageButton).toBeEnabled(); + }); +}); diff --git a/tests/test.ts b/tests/test.ts new file mode 100644 index 0000000..c5eb70d --- /dev/null +++ b/tests/test.ts @@ -0,0 +1,24 @@ +import { test as base } from '@playwright/test'; +import { MultipartInterviewsPage } from './fixtures/multipartInterviewPage'; + + +// Declare the types of your fixtures. +type DashboardFixtures = { + multipartInterviews: MultipartInterviewsPage; +}; + +/* + * Adds custom fixtures to basic test object. + */ +export const test = base.extend({ + multipartInterviews: async ({ page }, use) => { + // Set up the fixture. + const multipartInterviews = new MultipartInterviewsPage(page); + await multipartInterviews.goto(); + + // eslint-disable-next-line react-hooks/rules-of-hooks -- Not actually a React Hook + await use(multipartInterviews); + }, +}); + +export { expect } from '@playwright/test';