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:
1 parent
eccc5e509d
commit
3ee1535c87
15 files changed
+316
-5
No files matched your search
@@ -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/
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Executable
+9
@@ -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
|
||||
@@ -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
@@ -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' } },
|
||||
|
||||
Generated
+46
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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/' },
|
||||
|
||||
Reference in new issue
Block a user