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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 14:00:15 -04:00
1 parent eccc5e509d
commit 3ee1535c87
15 files changed
+316 -5

No files matched your search

+8
View File
@@ -13,3 +13,11 @@ coverage/
.env .env
.session-secret .session-secret
server/data/backups/ server/data/backups/
# Playwright
e2e/.auth.json
test-results/
playwright-report/
# a local demo video, not part of the app
demo/
+8 -1
View File
@@ -151,10 +151,17 @@ Private / personal use.
## Development ## Development
```bash ```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) 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. 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). 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).
+3 -2
View File
@@ -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. "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 - [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`). "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, - [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
creating a study and syncing; so far everything is tested in simulation. 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). - [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
## Teaching together ## Teaching together
+12
View File
@@ -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' });
});
+37
View File
@@ -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/);
});
+51
View File
@@ -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);
}
+34
View File
@@ -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();
});
+36
View File
@@ -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();
});
+9
View File
@@ -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
+43
View File
@@ -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();
});
+7 -1
View File
@@ -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 } }, languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] }, 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/**'], files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } }, languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
+46
View File
@@ -27,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -1635,6 +1636,22 @@
"node": ">=20.0.0" "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": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -6190,6 +6207,35 @@
"node": ">= 6" "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": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+3 -1
View File
@@ -15,7 +15,8 @@
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh", "backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh", "restore": "bash scripts/restore-data.sh",
"lint": "eslint ." "lint": "eslint .",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
@@ -37,6 +38,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
+18
View File
@@ -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' } },
],
});
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
}, },
}, },
test: { test: {
exclude: ['e2e/**', 'node_modules/**', 'dist/**'], // e2e/ holds the Playwright browser tests (npm run test:e2e)
environment: 'jsdom', environment: 'jsdom',
environmentOptions: { environmentOptions: {
jsdom: { url: 'http://localhost:3000/' }, jsdom: { url: 'http://localhost:3000/' },