From 3fa382ab87c3fe6ce7440a5d630ad80bbde35bc3 Mon Sep 17 00:00:00 2001 From: Patrick Joyce Date: Mon, 13 Apr 2026 12:05:32 -0400 Subject: [PATCH] Add e2e tests with playwright, move tests to seperate folders coveralls --- .github/workflows/build.yml | 8 +- .github/workflows/reusable-test.yml | 55 +++++++++++ .github/workflows/test.yml | 32 ++----- package-lock.json | 64 +++++++++++++ package.json | 5 +- playwright.config.ts | 24 +++++ tests/e2e/fixture.html | 15 +++ tests/e2e/tsml.spec.ts | 95 +++++++++++++++++++ {test => tests/unit}/__fixtures__/index.ts | 0 {test => tests/unit}/__fixtures__/json.ts | 0 {test => tests/unit}/__fixtures__/meeting.ts | 2 +- {test => tests/unit}/__mocks__/styleMock.js | 0 .../unit}/__tests__/Button.spec.tsx | 2 +- .../unit}/__tests__/Controls.spec.tsx | 4 +- .../unit}/__tests__/Loading.spec.tsx | 2 +- {test => tests/unit}/__tests__/Map.spec.tsx | 2 +- .../unit}/__tests__/Meeting.spec.tsx | 10 +- {test => tests/unit}/__tests__/Table.spec.tsx | 8 +- .../unit}/__tests__/analytics.spec.ts | 2 +- .../unit}/__tests__/format-address.spec.ts | 2 +- .../unit}/__tests__/format-array.spec.ts | 2 +- .../format-conference-provider.spec.ts | 4 +- .../__tests__/format-directions-url.spec.ts | 2 +- .../__tests__/format-feedback-email.spec.ts | 8 +- .../unit}/__tests__/format-google-url.spec.ts | 4 +- .../unit}/__tests__/format-ics.spec.ts | 4 +- .../unit}/__tests__/format-search.spec.ts | 2 +- .../unit}/__tests__/format-slug.spec.ts | 2 +- .../unit}/__tests__/format-string.spec.ts | 2 +- .../unit}/__tests__/format-url.spec.ts | 4 +- .../unit}/__tests__/get-distance.spec.ts | 6 +- .../unit}/__tests__/get-index-by-key.spec.ts | 4 +- .../inactive-meetings-filter.spec.ts | 6 +- .../unit}/__tests__/load-meeting-data.spec.ts | 6 +- .../unit}/__tests__/settings.spec.ts | 2 +- .../streamline-regions-index.spec.ts | 4 +- .../__tests__/translate-google-sheet.spec.ts | 6 +- .../unit}/__tests__/user-agent.spec.ts | 2 +- .../__tests__/validate-input-today.spec.ts | 4 +- {test => tests/unit}/setup.ts | 0 vite.config.ts | 3 +- 41 files changed, 325 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/reusable-test.yml create mode 100644 playwright.config.ts create mode 100644 tests/e2e/fixture.html create mode 100644 tests/e2e/tsml.spec.ts rename {test => tests/unit}/__fixtures__/index.ts (100%) rename {test => tests/unit}/__fixtures__/json.ts (100%) rename {test => tests/unit}/__fixtures__/meeting.ts (71%) rename {test => tests/unit}/__mocks__/styleMock.js (100%) rename {test => tests/unit}/__tests__/Button.spec.tsx (95%) rename {test => tests/unit}/__tests__/Controls.spec.tsx (80%) rename {test => tests/unit}/__tests__/Loading.spec.tsx (83%) rename {test => tests/unit}/__tests__/Map.spec.tsx (97%) rename {test => tests/unit}/__tests__/Meeting.spec.tsx (92%) rename {test => tests/unit}/__tests__/Table.spec.tsx (95%) rename {test => tests/unit}/__tests__/analytics.spec.ts (92%) rename {test => tests/unit}/__tests__/format-address.spec.ts (86%) rename {test => tests/unit}/__tests__/format-array.spec.ts (85%) rename {test => tests/unit}/__tests__/format-conference-provider.spec.ts (88%) rename {test => tests/unit}/__tests__/format-directions-url.spec.ts (93%) rename {test => tests/unit}/__tests__/format-feedback-email.spec.ts (85%) rename {test => tests/unit}/__tests__/format-google-url.spec.ts (95%) rename {test => tests/unit}/__tests__/format-ics.spec.ts (95%) rename {test => tests/unit}/__tests__/format-search.spec.ts (85%) rename {test => tests/unit}/__tests__/format-slug.spec.ts (91%) rename {test => tests/unit}/__tests__/format-string.spec.ts (82%) rename {test => tests/unit}/__tests__/format-url.spec.ts (88%) rename {test => tests/unit}/__tests__/get-distance.spec.ts (92%) rename {test => tests/unit}/__tests__/get-index-by-key.spec.ts (94%) rename {test => tests/unit}/__tests__/inactive-meetings-filter.spec.ts (96%) rename {test => tests/unit}/__tests__/load-meeting-data.spec.ts (97%) rename {test => tests/unit}/__tests__/settings.spec.ts (97%) rename {test => tests/unit}/__tests__/streamline-regions-index.spec.ts (93%) rename {test => tests/unit}/__tests__/translate-google-sheet.spec.ts (93%) rename {test => tests/unit}/__tests__/user-agent.spec.ts (93%) rename {test => tests/unit}/__tests__/validate-input-today.spec.ts (88%) rename {test => tests/unit}/setup.ts (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f7b0ccda..668838dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,16 +5,20 @@ on: branches: [main] jobs: + tests: + uses: ./.github/workflows/reusable-test.yml + build: + needs: [tests] runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Node uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' - - run: npm install + - run: npm ci - run: npm run build diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml new file mode 100644 index 00000000..7c6a2a26 --- /dev/null +++ b/.github/workflows/reusable-test.yml @@ -0,0 +1,55 @@ +name: Reusable Tests + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test-unit: + runs-on: ubuntu-latest + steps: + - name: checkout ๐Ÿ›’ + uses: actions/checkout@v6 + - name: node 22 โ‡๏ธ + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - name: install dependencies ๐Ÿ“ฆ + run: npm ci + - name: lint ๐Ÿงน + run: npm run lint + - name: unit tests ๐Ÿงช + run: npm run test-coverage + - name: build ๐Ÿ—๏ธ + run: npm run build + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + test-e2e: + runs-on: ubuntu-latest + steps: + - name: checkout ๐Ÿ›’ + uses: actions/checkout@v6 + - name: node 22 โ‡๏ธ + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'npm' + - name: install dependencies ๐Ÿ“ฆ + run: npm ci + - name: install Playwright browsers ๐ŸŽญ + run: npx playwright install --with-deps chromium firefox webkit + - name: e2e tests ๐ŸŒ + run: npm run test:e2e + - name: upload Playwright report ๐Ÿ“‹ + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9c62a9a..426f60f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,30 +4,10 @@ on: pull_request: branches: ['main'] -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: 'npm' - - - run: npm install +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true - - name: Check Prettier formatting - run: npx prettier --check vite.config.ts src/ - - - name: Run ESLint - run: npx eslint vite.config.ts src/ - - - run: npm run test-coverage - - run: npm run build - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} +jobs: + tests: + uses: ./.github/workflows/reusable-test.yml diff --git a/package-lock.json b/package-lock.json index 9b34f48e..348cfc9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@eslint/compat": "^2.0.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.59.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", @@ -1059,6 +1060,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -6770,6 +6787,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "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 b20d7eec..6ade3b26 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,15 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "serve": "serve public" + "serve": "serve public", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "devDependencies": { "@eslint/compat": "^2.0.0", "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.59.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..54f1f289 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/e2e/fixture.html b/tests/e2e/fixture.html new file mode 100644 index 00000000..970a0b8f --- /dev/null +++ b/tests/e2e/fixture.html @@ -0,0 +1,15 @@ + + + + + + TSML UI E2E + + +
+ + + diff --git a/tests/e2e/tsml.spec.ts b/tests/e2e/tsml.spec.ts new file mode 100644 index 00000000..996fa2b1 --- /dev/null +++ b/tests/e2e/tsml.spec.ts @@ -0,0 +1,95 @@ +import { test, expect, type Page } from '@playwright/test'; + +const MEETINGS = [ + { + slug: 'monday-morning-group', + name: 'Monday Morning Group', + day: 1, + time: '09:00', + timezone: 'America/New_York', + formatted_address: '1 Water St, Vineyard Haven, MA 02568, USA', + location: 'Steamship Terminal', + regions: ['Martha\'s Vineyard'], + types: ['O'], + }, + { + slug: 'wednesday-evening-group', + name: 'Wednesday Evening Group', + day: 3, + time: '19:00', + timezone: 'America/New_York', + formatted_address: '188 Meeting St, Charleston, SC 29401, USA', + location: 'City Market', + regions: ['Charleston'], + types: ['C'], + }, + { + slug: 'friday-online-group', + name: 'Friday Online Group', + day: 5, + time: '20:00', + timezone: 'America/Los_Angeles', + conference_url: 'https://zoom.us/j/999000999', + formatted_address: 'Online', + regions: ['Remote'], + types: ['O'], + }, +]; + +async function mockApi(page: Page) { + await page.route('http://tsml.e2e.test/**', route => + route.fulfill({ json: MEETINGS }) + ); +} + +async function loadApp(page: Page) { + await mockApi(page); + await page.goto('/tests/e2e/fixture.html'); + // Wait until at least one meeting row is visible + await expect( + page.getByRole('cell', { name: 'Monday Morning Group', exact: true }) + ).toBeVisible({ timeout: 15000 }); +} + +const meetingCell = (page: Page, name: string) => page.getByRole('cell', { name, exact: true }); + +test.describe('meeting list', () => { + test('renders all meetings from the API', async ({ page }) => { + await loadApp(page); + await expect(meetingCell(page, 'Monday Morning Group')).toBeVisible(); + await expect(meetingCell(page, 'Wednesday Evening Group')).toBeVisible(); + await expect(meetingCell(page, 'Friday Online Group')).toBeVisible(); + }); + + test('search filters meetings by name', async ({ page }) => { + await loadApp(page); + await page.getByRole('searchbox').fill('wednesday'); + // Wait for exactly 1 name cell, confirms filter fully applied before checking negatives + await expect(page.locator('td.tsml-name')).toHaveCount(1); + await expect(meetingCell(page, 'Wednesday Evening Group')).toBeVisible(); + await expect(meetingCell(page, 'Monday Morning Group')).not.toBeVisible(); + await expect(meetingCell(page, 'Friday Online Group')).not.toBeVisible(); + }); +}); + +test.describe('meeting detail', () => { + test('clicking a meeting opens the detail view', async ({ page }) => { + await loadApp(page); + await meetingCell(page, 'Monday Morning Group').click(); + await expect( + page.getByRole('link', { name: 'Back to Meetings' }) + ).toBeVisible(); + await expect(page.getByText('Monday Morning Group')).toBeVisible(); + }); + + test('navigating directly to a meeting slug loads the detail view', async ({ + page, + }) => { + await mockApi(page); + await page.goto('/tests/e2e/fixture.html#/wednesday-evening-group'); + await expect( + page.getByRole('link', { name: 'Back to Meetings' }) + ).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('Wednesday Evening Group')).toBeVisible(); + }); +}); diff --git a/test/__fixtures__/index.ts b/tests/unit/__fixtures__/index.ts similarity index 100% rename from test/__fixtures__/index.ts rename to tests/unit/__fixtures__/index.ts diff --git a/test/__fixtures__/json.ts b/tests/unit/__fixtures__/json.ts similarity index 100% rename from test/__fixtures__/json.ts rename to tests/unit/__fixtures__/json.ts diff --git a/test/__fixtures__/meeting.ts b/tests/unit/__fixtures__/meeting.ts similarity index 71% rename from test/__fixtures__/meeting.ts rename to tests/unit/__fixtures__/meeting.ts index 689e0249..43a2dd6c 100644 --- a/test/__fixtures__/meeting.ts +++ b/tests/unit/__fixtures__/meeting.ts @@ -1,4 +1,4 @@ -import type { Meeting } from '../../src/types'; +import type { Meeting } from '../../../src/types'; export const mockMeeting: Meeting = { slug: 'foo', diff --git a/test/__mocks__/styleMock.js b/tests/unit/__mocks__/styleMock.js similarity index 100% rename from test/__mocks__/styleMock.js rename to tests/unit/__mocks__/styleMock.js diff --git a/test/__tests__/Button.spec.tsx b/tests/unit/__tests__/Button.spec.tsx similarity index 95% rename from test/__tests__/Button.spec.tsx rename to tests/unit/__tests__/Button.spec.tsx index a84db828..f2687837 100644 --- a/test/__tests__/Button.spec.tsx +++ b/tests/unit/__tests__/Button.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, cleanup } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import Button from '../../src/components/Button'; +import Button from '../../../src/components/Button'; describe('