From 3ee1535c879abcb6a8d9005a3687808c4f5aac1e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:00:15 -0400 Subject: [PATCH] Real-browser tests (Playwright) and housekeeping - npm run test:e2e builds the app, runs it on port 3101 with an empty throwaway database and drives Chrome (the installed one; no browser download) through notes and the paste check, the daily five minutes and weekly recall, translation compare (Bible text faked), and two-screen presenting. - Vitest skips e2e/; ESLint knows the e2e folder and playwright.config.js. - .gitignore: the local demo video, Playwright output and sign-in file. - README and TODO describe the browser tests and what CI still needs. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 8 +++++++ README.md | 9 +++++++- TODO.md | 5 +++-- e2e/auth.setup.js | 12 +++++++++++ e2e/compare.spec.js | 37 ++++++++++++++++++++++++++++++++ e2e/helpers.js | 51 ++++++++++++++++++++++++++++++++++++++++++++ e2e/practice.spec.js | 34 +++++++++++++++++++++++++++++ e2e/present.spec.js | 36 +++++++++++++++++++++++++++++++ e2e/start-server.sh | 9 ++++++++ e2e/study.spec.js | 43 +++++++++++++++++++++++++++++++++++++ eslint.config.js | 8 ++++++- package-lock.json | 46 +++++++++++++++++++++++++++++++++++++++ package.json | 4 +++- playwright.config.js | 18 ++++++++++++++++ vite.config.js | 1 + 15 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 e2e/auth.setup.js create mode 100644 e2e/compare.spec.js create mode 100644 e2e/helpers.js create mode 100644 e2e/practice.spec.js create mode 100644 e2e/present.spec.js create mode 100755 e2e/start-server.sh create mode 100644 e2e/study.spec.js create mode 100644 playwright.config.js diff --git a/.gitignore b/.gitignore index 43ef431..8a16d38 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ coverage/ .env .session-secret server/data/backups/ + +# Playwright +e2e/.auth.json +test-results/ +playwright-report/ + +# a local demo video, not part of the app +demo/ diff --git a/README.md b/README.md index c48ddbc..50c02bd 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,17 @@ Private / personal use. ## Development ```bash -npm run test:run # all tests +npm run test:run # all unit and component tests +npm run test:e2e # real-browser tests (Playwright, in your installed Chrome) npm run lint # ESLint (errors fail; warnings are advice) ``` +The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never +touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly +recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the +Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js` +and run `npx playwright install` to use Playwright's own browser. + Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created. Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic). diff --git a/TODO.md b/TODO.md index 53da92b..2dbd48f 100644 --- a/TODO.md +++ b/TODO.md @@ -9,8 +9,9 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them. "sign out other devices"; optionally a login alert through ntfy. - [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a "restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`). -- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in, - creating a study and syncing; so far everything is tested in simulation. +- [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push. + The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in + and cross-device sync to them. - [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin). ## Teaching together diff --git a/e2e/auth.setup.js b/e2e/auth.setup.js new file mode 100644 index 0000000..8e906bb --- /dev/null +++ b/e2e/auth.setup.js @@ -0,0 +1,12 @@ +// Makes one account in the throwaway database and keeps its sign-in for the other tests. +import { test as setup, expect } from '@playwright/test'; + +setup('create an account and sign in', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /need an account\? sign up/i }).click(); + await page.locator('input[type="email"]').fill('e2e@example.test'); + await page.locator('input[type="password"]').fill('E2e-Test-Password-7391'); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible(); + await page.context().storageState({ path: 'e2e/.auth.json' }); +}); diff --git a/e2e/compare.spec.js b/e2e/compare.spec.js new file mode 100644 index 0000000..9f499dd --- /dev/null +++ b/e2e/compare.spec.js @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { resumeButton, seedStudy } from './helpers.js'; + +// Fake the free Bible text so the test needs no internet: every translation answers with its own wording. +const WORDING = { + BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' }, + NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' }, +}; + +test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => { + await page.route('https://bible.helloao.org/api/**', async (route) => { + const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase(); + const words = WORDING[id] ?? WORDING.BSB; + await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } }); + }); + const { title } = await seedStudy(page); + await resumeButton(page, title).click(); + await page.getByRole('button', { name: /compare…/i }).click(); + await page.getByRole('tab', { name: 'Translations' }).click(); + + const dialog = page.getByRole('dialog', { name: /compare/i }); + await expect(dialog.getByText('Berean Standard Bible')).toBeVisible(); + await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible(); + await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked + await dialog.getByLabel(/highlight the words that differ/i).uncheck(); + await expect(dialog.locator('mark')).toHaveCount(0); + + // a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page + await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"'); + await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/); + await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.'); + await dialog.getByRole('button', { name: 'Close' }).click(); + await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy + await page.keyboard.press('3'); + await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.'); + await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/); +}); diff --git a/e2e/helpers.js b/e2e/helpers.js new file mode 100644 index 0000000..7734645 --- /dev/null +++ b/e2e/helpers.js @@ -0,0 +1,51 @@ +// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text. +const DAY = 86400000; +const verses = [ + { number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' }, + { number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' }, + { number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' }, + { number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' }, +]; +export const NOTE_TEXT = { + observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.', + interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.', + application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.', +}; +export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.'; + +// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage +// for the signed-in account, then reloads. +export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) { + await page.goto('/'); + await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k))); + await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => { + const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1]; + const now = Date.now(); + const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra }); + const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra }); + const a = chunk(`${id}-a`, 16, 18, { ...notes }); + const b = chunk(`${id}-b`, 19, 19, {}); + // a title of its own: studies from earlier tests sync back through the account, so titles must not repeat + const title = `John 3 (${id.slice(-6)})`; + const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : []; + const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] }; + localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project)); + const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id); + index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] }); + localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index)); + }, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY }); + await page.reload(); + return { id, title: `John 3 (${id.slice(-6)})` }; +} + +// The Resume button on one study's card on Home +export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' }); + +// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup) +export async function pasteInto(locator, text) { + await locator.evaluate((el, value) => { + const dt = new DataTransfer(); + dt.setData('text', value); + el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })); + }, text); +} diff --git a/e2e/practice.spec.js b/e2e/practice.spec.js new file mode 100644 index 0000000..9dd1a7a --- /dev/null +++ b/e2e/practice.spec.js @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; +import { pasteInto, seedStudy } from './helpers.js'; + +test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => { + await seedStudy(page); + + // daily five minutes + await expect(page.getByText(/your five minutes:/i)).toBeVisible(); + await page.getByRole('button', { name: 'Start' }).first().click(); + const box = page.getByRole('textbox', { name: /your five minutes/i }); + await expect(page.getByRole('timer')).toContainText('4:'); + await pasteInto(box, 'text from somewhere else'); + await expect(page.getByText(/pasting is off here/i)).toBeVisible(); + await expect(box).toHaveValue(''); + await box.fill('Three things I notice in the passage.'); + await page.getByRole('button', { name: /save to my notes/i }).click(); + await expect(page.getByText(/saved to your notes/i)).toBeVisible(); + + // weekly recall: write from memory, compare with the notes, rate it + await page.getByRole('button', { name: 'Start' }).click(); + await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.'); + await page.getByRole('button', { name: 'Show my notes' }).click(); + await expect(page.getByText('Your notes', { exact: true })).toBeVisible(); + await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to'); + await page.getByRole('button', { name: 'Partly' }).click(); + await expect(page.getByText(/recall saved/i)).toBeVisible(); + + // it shows in the history and in Insights + await page.getByRole('button', { name: /recall history/i }).click(); + await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Insights' }).click(); + await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible(); + await expect(page.getByText('who Jesus was talking to')).toBeVisible(); +}); diff --git a/e2e/present.spec.js b/e2e/present.spec.js new file mode 100644 index 0000000..da0eaaa --- /dev/null +++ b/e2e/present.spec.js @@ -0,0 +1,36 @@ +import { test, expect } from '@playwright/test'; +import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js'; + +test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => { + const { title } = await seedStudy(page, { withOutline: true }); + await resumeButton(page, title).click(); + await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click(); + await page.getByRole('button', { name: 'Present' }).click(); + const presenter = page.getByRole('dialog', { name: /presenting/i }); + + // one screen: notes stay hidden until turned on + await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible(); + await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0); + + // a second window (the projector) connects and follows along + const audience = await context.newPage(); + await audience.goto('/?audience=1'); + await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible(); + await expect(presenter.getByText('Only you see this')).toBeVisible(); + await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only + await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0); + expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/); + + await page.keyboard.press('ArrowRight'); + await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible(); + + // B blacks the audience screen out, and again brings it back + await page.keyboard.press('b'); + await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0); + await page.keyboard.press('b'); + await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible(); + + // closing the presentation tells the audience window + await presenter.getByRole('button', { name: 'Close' }).click(); + await expect(audience.getByText(/presentation has ended/i)).toBeVisible(); +}); diff --git a/e2e/start-server.sh b/e2e/start-server.sh new file mode 100755 index 0000000..fa351cd --- /dev/null +++ b/e2e/start-server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies). +set -euo pipefail +cd "$(dirname "$0")/.." +export DATA_DIR="$(mktemp -d)" +export PORT=3101 +export NODE_ENV=production +npx vite build --logLevel error +exec node server/index.js diff --git a/e2e/study.spec.js b/e2e/study.spec.js new file mode 100644 index 0000000..2f5e2a0 --- /dev/null +++ b/e2e/study.spec.js @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test'; +import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js'; + +test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => { + const { title } = await seedStudy(page); + await expect(page.getByRole('heading', { name: title })).toBeVisible(); + await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge + await resumeButton(page, title).click(); + await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible(); + + // keyboard: 2 jumps into Interpret, Escape leaves it + await page.keyboard.press('2'); + await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused(); + + // a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours + await page.keyboard.press('1'); + const observe = page.getByRole('textbox', { name: 'Observe' }); + const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' '); + await pasteInto(observe, pasted); + await observe.fill(`${NOTE_TEXT.observation} ${pasted}`); + await expect(page.getByText(/is this from ai\?/i)).toBeVisible(); + await expect(page.getByText(/24 words here were pasted/i)).toBeVisible(); + await page.getByRole('button', { name: 'No, I wrote it' }).click(); + await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0); + await expect(page.getByText(/were pasted/i)).toHaveCount(0); + + // either half of the right column can be folded + const resources = page.getByRole('button', { name: /commentary & resources/i }); + await resources.click(); + await expect(resources).toHaveAttribute('aria-expanded', 'false'); + await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other + await expect(resources).toHaveAttribute('aria-expanded', 'true'); + + // the reading list belongs to the study + await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open + await page.getByRole('tab', { name: /reading list/i }).click(); + await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John'); + await page.keyboard.press('Enter'); + await page.getByRole('checkbox', { name: /read: carson on john/i }).check(); + await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked(); +}); diff --git a/eslint.config.js b/eslint.config.js index c452fd7..36a575f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,10 +22,16 @@ export default [ }, }, { - files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'], + files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } }, rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] }, }, + { + // Playwright tests run in Node but hand functions to the browser page, which use browser globals + files: ['e2e/**/*.js'], + languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } }, + rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] }, + }, { files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'], languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } }, diff --git a/package-lock.json b/package-lock.json index c0dbe63..bb302c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -1635,6 +1636,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -6190,6 +6207,35 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index e430542..dfa9d07 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "coverage": "vitest run --coverage", "backup": "bash scripts/backup-data.sh", "restore": "bash scripts/restore-data.sh", - "lint": "eslint ." + "lint": "eslint .", + "test:e2e": "playwright test" }, "dependencies": { "@simplewebauthn/browser": "^13.3.0", @@ -37,6 +38,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.64.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..30ecc95 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome. +// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download). +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' }, + webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false }, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.js/ }, + { name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } }, + ], +}); diff --git a/vite.config.js b/vite.config.js index dde6473..a1dbcd8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,6 +18,7 @@ export default defineConfig({ }, }, test: { + exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e) environment: 'jsdom', environmentOptions: { jsdom: { url: 'http://localhost:3000/' },