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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user