From fc391d675c435bf17b2c6fb241148f19412160ef Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Tue, 10 Mar 2026 16:21:09 +0100 Subject: [PATCH] docs: add E2E BDD testing best practices and improve test suite - Add docs/TESTING.md with 10 BDD testing best practices - Add createNote() helper to fixtures to DRY up state setup - Add createChecklistNote() helper in checklist tests - Rename scenario titles to describe outcomes, not actions - Replace imperative Given steps with createNote() helper - Fix fragile conditional in tag filtering test (silent pass) - Remove implementation details from Gherkin comments - Improve Given/When/Then rhythm consistency across all tests Co-Authored-By: Claude Opus 4.6 --- docs/TESTING.md | 142 +++++++++++++++++++++++++++++++++ tests/e2e/attachments.spec.ts | 10 +-- tests/e2e/auth.spec.ts | 7 +- tests/e2e/checklist.spec.ts | 70 ++++++++-------- tests/e2e/helpers/fixtures.ts | 13 +++ tests/e2e/notes-crud.spec.ts | 20 +---- tests/e2e/offline.spec.ts | 47 ++++------- tests/e2e/organization.spec.ts | 54 ++++--------- tests/e2e/search.spec.ts | 18 +---- tests/e2e/settings.spec.ts | 17 ++-- 10 files changed, 249 insertions(+), 149 deletions(-) create mode 100644 docs/TESTING.md diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..11b9e92 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,142 @@ +# E2E BDD Testing Best Practices + +This document defines the 10 best practices for writing E2E tests in Crumbs. All tests use Playwright with Gherkin-style BDD comments. + +## 1. Declarative scenarios, not imperative scripts + +Describe **what** the system does, not **how** the user clicks through the UI. + +``` +// Good: "When the user creates a note titled 'Shopping List'" +// Bad: "When the user clicks the + button, types 'Shopping List' in the title field, and clicks save" +``` + +If the UI changes (button moves, field renames), only the Playwright code changes — the scenario comment stays stable. + +## 2. Given describes state, not navigation + +`Given` sets up preconditions. It should read like a database fixture, not a user journey. + +``` +// Good: "Given a note titled 'Old Note' exists in the trash" +// Bad: "Given the user creates a note, then clicks trash, then confirms deletion" +``` + +Use API calls or helpers like `createNote()` in `Given` steps to avoid coupling tests to unrelated UI flows. + +## 3. One behavior per scenario + +Each `test()` should verify **one outcome**. If you need multiple `Then` assertions about different behaviors, split them. + +```ts +// Good: Two focused scenarios +test('Trashed note disappears from main view', ...) +test('Trashed note appears in trash view', ...) + +// Bad: One bloated scenario testing everything +test('Trash workflow', ...) // creates, trashes, checks main, checks trash, restores... +``` + +## 4. Scenario names describe outcomes, not actions + +Name tests by the **observable result**, not the user action that triggers it. + +```ts +// Good: test('Archived note no longer appears in the main view') +// Bad: test('User archives a note') +``` + +This makes test reports read like a behavior specification. + +## 5. Isolate test state completely + +Each test should start from a **known, clean state**. Use API-based setup/teardown or the shared `authenticatedPage` fixture. + +```ts +// Use API to create/reset state before each test +test.beforeEach(async ({ authenticatedPage: page }) => { + await page.request.put('/api/preferences', { data: { ... } }); +}); +``` + +This prevents the "test B fails because test A didn't clean up" problem — especially important since Playwright runs workers in parallel. + +## 6. Use `test.describe.serial` only when truly sequential + +Reserve `serial` for flows where state genuinely cascades (setup -> login -> session). For everything else, isolate with fixtures. Treat `serial` as a **code smell** — most tests should run in **any order**. + +## 7. Abstract repeated UI actions into helpers + +When multiple scenarios share the same UI interaction (e.g., "create a note"), extract it into a helper at the **intent level**. + +```ts +// helpers/fixtures.ts +export async function createNote(page, title, content?) { ... } + +// In tests — reads like BDD +await createNote(page, 'My Note', 'Hello'); +``` + +The shared `authenticatedPage` fixture and `noteCard()` locator are examples of this pattern. The `createNote()` helper abstracts the 4-step note creation flow used across most test files. + +## 8. Apply the resilience test to every comment + +Before committing a Gherkin comment, ask: *"Would this comment need to change if we redesigned the UI?"* + +- If **yes** — rewrite to remove implementation details +- If **no** — it's at the right abstraction level + +``` +// Fails (mentions hover + button): +// "When the user hovers the card and clicks the 3-dot menu" + +// Passes: +// "When the user opens the note actions menu" +``` + +## 9. Prefer `await expect(...).toBeVisible()` over arbitrary waits + +Playwright's auto-waiting is powerful. Never use `page.waitForTimeout()` — it's flaky and slow. Assert on the **expected end state** instead. + +```ts +// Good: Waits intelligently for the element +await expect(page.getByText('Note saved')).toBeVisible(); + +// Bad: Arbitrary delay +await page.waitForTimeout(2000); +``` + +The exception is `waitForLoadState('networkidle')` after authentication redirects, where the full page reload makes auto-waiting insufficient. + +## 10. Structure tests in the Given/When/Then rhythm + +Use comments to maintain the three-act structure. This makes tests scannable and self-documenting. + +```ts +test('Pinned note appears at the top of the list', async ({ authenticatedPage: page }) => { + // Given a note titled "Important" exists + await createNote(page, 'Important'); + + // When the user pins the note + await pinNote(page, 'Important'); + + // Then it appears before unpinned notes + const firstCard = page.locator('[data-testid="note-card"]').first(); + await expect(firstCard).toContainText('Important'); +}); +``` + +## Quick reference + +| Practice | Category | Goal | +|----------|----------|------| +| 1. Declarative scenarios | Language | Stable comments despite UI changes | +| 2. Given = state | Language | Decouple setup from UI flow | +| 3. One behavior per test | Structure | Focused, debuggable tests | +| 4. Outcome-based names | Language | Test reports read as specs | +| 5. Isolate state | Reliability | No cross-test pollution | +| 6. Avoid serial | Reliability | Parallel-safe by default | +| 7. Extract helpers | Structure | DRY, intent-level code | +| 8. Resilience test | Language | UI-change-proof comments | +| 9. Auto-wait assertions | Reliability | No flaky timeouts | +| 10. Given/When/Then | Structure | Scannable three-act tests | diff --git a/tests/e2e/attachments.spec.ts b/tests/e2e/attachments.spec.ts index 0e3d2b3..80ea138 100644 --- a/tests/e2e/attachments.spec.ts +++ b/tests/e2e/attachments.spec.ts @@ -72,7 +72,7 @@ test.describe('Image Attachments', () => { await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH); await expect(page.getByTestId('attachment-thumbnail')).toBeVisible(); - // When the user features the image by clicking the star + // When the user features the image await page.getByTestId('attachment-thumbnail').hover(); await page.getByTestId('toggle-featured').click(); @@ -91,8 +91,6 @@ test.describe('Image Attachments', () => { await page.getByTestId('image-toggle').click(); await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH); await expect(page.getByTestId('attachment-thumbnail')).toBeVisible(); - - // Feature the image await page.getByTestId('attachment-thumbnail').hover(); await page.getByTestId('toggle-featured').click(); await page.getByTestId('close-editor-btn').click(); @@ -155,7 +153,7 @@ test.describe('Image Attachments', () => { }); test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => { - // When the user opens a new note and clicks the image toggle + // When the user opens a new note and enables the image panel await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill('Auto-Save Test'); await page.getByTestId('image-toggle').click(); @@ -163,10 +161,8 @@ test.describe('Image Attachments', () => { // Then the upload panel is visible (note was auto-saved) await expect(page.getByTestId('image-upload')).toBeVisible(); - // Cleanup + // And the note persists in the list after closing await page.getByTestId('close-editor-btn').click(); - - // And the note exists in the list await expect(noteCard(page, 'Auto-Save Test')).toBeVisible(); }); }); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 9abd27d..ee55ff9 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -70,10 +70,10 @@ test.describe.serial('Authentication', () => { }); test.describe('Multi-user', () => { - test('admin can create a new user via API', async ({ authenticatedPage: page }) => { - // authenticatedPage fixture already handles setup/login + test('Scenario: Admin-created user is registered successfully', async ({ authenticatedPage: page }) => { + // Given the admin is authenticated - // Create a new user via admin API + // When the admin creates a new user const response = await page.request.post('/api/admin/users', { data: { email: 'user2@test.com', @@ -83,6 +83,7 @@ test.describe('Multi-user', () => { } }); + // Then the user is created successfully expect(response.status()).toBe(201); }); }); diff --git a/tests/e2e/checklist.spec.ts b/tests/e2e/checklist.spec.ts index 86b2cde..c99207d 100644 --- a/tests/e2e/checklist.spec.ts +++ b/tests/e2e/checklist.spec.ts @@ -1,7 +1,23 @@ import { test, expect, noteCard } from './helpers/fixtures.js'; +import type { Page } from '@playwright/test'; + +/** Create a checklist note via UI. Leaves the editor closed. */ +async function createChecklistNote(page: Page, title: string, items: string[]) { + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill(title); + await page.getByTestId('checklist-toggle').click(); + for (let i = 0; i < items.length; i++) { + await page.getByTestId('checklist-input').nth(i).fill(items[i]); + if (i < items.length - 1) { + await page.getByTestId('checklist-input').nth(i).press('Enter'); + } + } + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, title)).toBeVisible(); +} test.describe('Checklist', () => { - test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => { + test('Scenario: Checklist replaces the rich text editor when enabled', async ({ authenticatedPage: page }) => { // Given the user is creating a new note await page.getByTestId('new-note-btn').click(); @@ -15,16 +31,9 @@ test.describe('Checklist', () => { await expect(page.getByTestId('tiptap-editor')).not.toBeVisible(); }); - test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => { - // When the user creates a checklist note with an item "Buy milk" - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Shopping List'); - await page.getByTestId('checklist-toggle').click(); - await page.getByTestId('checklist-input').first().fill('Buy milk'); - await page.getByTestId('close-editor-btn').click(); - - // Then the note appears in the list - await expect(page.getByText('Shopping List')).toBeVisible(); + test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => { + // Given a checklist note with an item "Buy milk" exists + await createChecklistNote(page, 'Shopping List', ['Buy milk']); // When the user reopens the note await noteCard(page, 'Shopping List').click(); @@ -34,13 +43,9 @@ test.describe('Checklist', () => { await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk'); }); - test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => { - // Given a checklist note with an item "Buy milk" - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Tasks'); - await page.getByTestId('checklist-toggle').click(); - await page.getByTestId('checklist-input').first().fill('Buy milk'); - await page.getByTestId('close-editor-btn').click(); + test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => { + // Given a checklist note with an item "Buy milk" exists + await createChecklistNote(page, 'Tasks', ['Buy milk']); // When the user reopens the note and checks the item await noteCard(page, 'Tasks').click(); @@ -50,13 +55,13 @@ test.describe('Checklist', () => { await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done'); await page.getByTestId('close-editor-btn').click(); - // And reopening the note shows the item in the done section (expanded by default) + // And the done state persists after reopening await noteCard(page, 'Tasks').click(); await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked(); }); - test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => { - // Given the user is editing a checklist note + test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => { + // Given a checklist with one item await page.getByTestId('new-note-btn').click(); await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-input').first().fill('First item'); @@ -68,46 +73,45 @@ test.describe('Checklist', () => { await expect(page.getByTestId('checklist-input')).toHaveCount(2); }); - test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => { - // Given a checklist note with two items + test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => { + // Given a checklist with two items where the second is empty await page.getByTestId('new-note-btn').click(); await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-input').first().fill('First item'); await page.getByTestId('checklist-input').first().press('Enter'); await expect(page.getByTestId('checklist-input')).toHaveCount(2); - // When the user presses Backspace on the empty second item + // When the user presses Backspace on the empty item await page.getByTestId('checklist-input').nth(1).press('Backspace'); // Then only the first item remains await expect(page.getByTestId('checklist-input')).toHaveCount(1); }); - test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => { - // Given a checklist note with a completed item and an uncompleted item + test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => { + // Given a checklist note with two items await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill('Hide Done Test'); await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-input').first().fill('Done task'); await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').nth(1).fill('Pending task'); + + // When the user completes the first item await page.getByTestId('checklist-checkbox').first().click(); - // Then the completed item is separated from the active list + // Then only the pending item remains in the active list await expect(page.getByTestId('checklist-input')).toHaveCount(1); await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task'); - // And the "done" toggle shows the count - await expect(page.getByTestId('checklist-toggle-done')).toBeVisible(); + // And the done section shows the completed item await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done'); - - // Then the completed items are shown in the done section (expanded by default) await expect(page.getByTestId('checklist-done-section')).toBeVisible(); await expect(page.getByTestId('checklist-done-section')).toContainText('Done task'); }); - test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => { - // Given the user has checklist mode enabled + test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => { + // Given checklist mode is enabled on a new note await page.getByTestId('new-note-btn').click(); await page.getByTestId('checklist-toggle').click(); await expect(page.getByTestId('checklist')).toBeVisible(); diff --git a/tests/e2e/helpers/fixtures.ts b/tests/e2e/helpers/fixtures.ts index 8623413..5f8dd68 100644 --- a/tests/e2e/helpers/fixtures.ts +++ b/tests/e2e/helpers/fixtures.ts @@ -90,4 +90,17 @@ export async function setupAndLogin(page: Page) { await page.waitForLoadState('networkidle'); } +/** Create a note via the UI and close the editor. Use in Given steps for state setup. */ +export async function createNote(page: Page, title: string, content?: string) { + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill(title); + if (content) { + const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); + await editor.click(); + await editor.pressSequentially(content); + } + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, title)).toBeVisible(); +} + export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME }; diff --git a/tests/e2e/notes-crud.spec.ts b/tests/e2e/notes-crud.spec.ts index 445ed77..6de96ef 100644 --- a/tests/e2e/notes-crud.spec.ts +++ b/tests/e2e/notes-crud.spec.ts @@ -1,14 +1,9 @@ -import { test, expect, noteCard } from './helpers/fixtures.js'; +import { test, expect, noteCard, createNote } from './helpers/fixtures.js'; test.describe('Notes CRUD', () => { test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => { // When the user creates a note titled "My First Note" with content "Hello world!" - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('My First Note'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - await editor.click(); - await editor.pressSequentially('Hello world!'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'My First Note', 'Hello world!'); // Then the note is visible in the notes list await expect(noteCard(page, 'My First Note')).toBeVisible(); @@ -16,12 +11,7 @@ test.describe('Notes CRUD', () => { test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => { // Given a note titled "Original Title" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Original Title'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - await editor.click(); - await editor.pressSequentially('Original content'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Original Title', 'Original content'); // When the user changes the title to "Updated Title" await noteCard(page, 'Original Title').click(); @@ -35,9 +25,7 @@ test.describe('Notes CRUD', () => { test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => { // Given a note titled "Delete Me" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Delete Me'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Delete Me'); // When the user trashes the note const card = noteCard(page, 'Delete Me'); diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts index 9386fdb..0f03b5f 100644 --- a/tests/e2e/offline.spec.ts +++ b/tests/e2e/offline.spec.ts @@ -1,7 +1,7 @@ -import { test, expect, noteCard } from './helpers/fixtures.js'; +import { test, expect, noteCard, createNote } from './helpers/fixtures.js'; test.describe('Offline Support', () => { - test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => { + test('Scenario: Sync status indicator is visible on the main page', async ({ authenticatedPage: page }) => { // Given the user is authenticated // Then the sync status indicator is displayed await expect(page.getByTestId('sync-indicator')).toBeVisible(); @@ -9,9 +9,7 @@ test.describe('Offline Support', () => { test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => { // When the user creates a note titled "Offline Test Note" - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Offline Test Note'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Offline Test Note'); // Then the note appears in the list await expect(page.getByText('Offline Test Note')).toBeVisible(); @@ -19,10 +17,7 @@ test.describe('Offline Support', () => { test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => { // Given a note titled "Offline Edit" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Offline Edit'); - await page.getByTestId('close-editor-btn').click(); - await expect(page.getByText('Offline Edit')).toBeVisible(); + await createNote(page, 'Offline Edit'); // When the network is unavailable await page.context().setOffline(true); @@ -33,10 +28,10 @@ test.describe('Offline Support', () => { await page.getByTestId('note-title-input').fill('Offline Edited'); await page.getByTestId('close-editor-btn').click(); - // Then the note "Offline Edited" appears in the list + // Then the updated note appears in the list await expect(page.getByText('Offline Edited')).toBeVisible(); - // And a "Saved offline" toast is shown + // And an offline save confirmation is shown await expect(page.getByText('Saved offline')).toBeVisible(); }); @@ -49,27 +44,25 @@ test.describe('Offline Support', () => { await page.getByTestId('note-title-input').fill('Offline New'); await page.getByTestId('close-editor-btn').click(); - // Then the note "Offline New" appears in the list + // Then the note appears in the list await expect(page.getByText('Offline New')).toBeVisible(); - // And a "Saved offline" toast is shown + // And an offline save confirmation is shown await expect(page.getByText('Saved offline')).toBeVisible(); }); test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => { - // When the network is unavailable + // Given the network is unavailable await page.context().setOffline(true); - // And the user creates a note titled "Survive Reload" + // And a note titled "Survive Reload" is created offline await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill('Survive Reload'); await page.getByTestId('close-editor-btn').click(); await expect(page.getByText('Survive Reload')).toBeVisible(); - // When the network is restored (triggers 'online' event + navigator.onLine = true) + // When the network is restored and sync completes await page.context().setOffline(false); - - // And the sync completes await page.waitForResponse('**/api/sync'); // And the user reloads the page @@ -81,27 +74,19 @@ test.describe('Offline Support', () => { test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => { // Given a note titled "Cached Note" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Cached Note'); - await page.getByTestId('close-editor-btn').click(); - await expect(page.getByText('Cached Note')).toBeVisible(); + await createNote(page, 'Cached Note'); - // When the API is unavailable (simulating service worker serving cached shell) + // When the API becomes unavailable and the page is reloaded await page.route('**/api/**', (route) => route.abort()); - - // And the user reloads the page await page.reload(); - // Then the note is still visible (loaded from IDB cache) + // Then the note is still visible from the local cache await expect(page.getByText('Cached Note')).toBeVisible(); }); test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => { // Given a note titled "Offline Trash" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Offline Trash'); - await page.getByTestId('close-editor-btn').click(); - await expect(page.getByText('Offline Trash')).toBeVisible(); + await createNote(page, 'Offline Trash'); // When the network is unavailable await page.context().setOffline(true); @@ -114,7 +99,7 @@ test.describe('Offline Support', () => { // Then the note is no longer visible await expect(page.getByText('Offline Trash')).not.toBeVisible(); - // And a "Saved offline" toast is shown + // And an offline save confirmation is shown await expect(page.getByText('Saved offline')).toBeVisible(); }); }); diff --git a/tests/e2e/organization.spec.ts b/tests/e2e/organization.spec.ts index ae38a30..cf91b5c 100644 --- a/tests/e2e/organization.spec.ts +++ b/tests/e2e/organization.spec.ts @@ -1,11 +1,9 @@ -import { test, expect, noteCard } from './helpers/fixtures.js'; +import { test, expect, noteCard, createNote } from './helpers/fixtures.js'; test.describe('Organization Features', () => { test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => { // Given a note titled "Pin Me" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Pin Me'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Pin Me'); // When the user pins the note const pinCard = noteCard(page, 'Pin Me'); @@ -18,9 +16,7 @@ test.describe('Organization Features', () => { test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => { // Given a note titled "Archive Me" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Archive Me'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Archive Me'); // When the user archives the note const archiveCard = noteCard(page, 'Archive Me'); @@ -31,58 +27,42 @@ test.describe('Organization Features', () => { await expect(page.getByText('Archive Me')).not.toBeVisible(); }); - test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => { - // Given the user is creating a note titled "Colored Note" + test('Scenario: Note card reflects the selected color', async ({ authenticatedPage: page }) => { + // Given the user is creating a new note await page.getByTestId('new-note-btn').click(); await page.getByTestId('note-title-input').fill('Colored Note'); - // When the user sets the note color to "Coral" + // When the user selects the "Coral" color await page.getByTestId('color-picker-toggle').click(); await page.getByTestId('color-coral').click(); await page.getByTestId('close-editor-btn').click(); - // Then the note is saved and visible in the notes list + // Then the note is visible in the notes list await expect(noteCard(page, 'Colored Note')).toBeVisible(); }); test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => { // Given a note tagged #important and an untagged note exist - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Tagged Note'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - await editor.click(); - await editor.pressSequentially('This is #important'); - await page.getByTestId('close-editor-btn').click(); - - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Untagged Note'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Tagged Note', 'This is #important'); + await createNote(page, 'Untagged Note'); // When the user filters by the #important tag const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' }); - if (await tagChip.isVisible()) { - await tagChip.click(); + await expect(tagChip).toBeVisible(); + await tagChip.click(); - // Then only the tagged note is visible - await expect(page.getByText('Tagged Note').first()).toBeVisible(); - } + // Then only the tagged note is visible + await expect(page.getByText('Tagged Note').first()).toBeVisible(); }); }); test.describe('Note Sorting', () => { test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => { - // Given two notes exist with different update times - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Sort-First'); - await page.getByTestId('close-editor-btn').click(); - await expect(noteCard(page, 'Sort-First')).toBeVisible(); + // Given two notes exist created at different times + await createNote(page, 'Sort-First'); + await createNote(page, 'Sort-Second'); - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Sort-Second'); - await page.getByTestId('close-editor-btn').click(); - await expect(noteCard(page, 'Sort-Second')).toBeVisible(); - - // Then Sort-Second (most recently updated) appears before Sort-First + // Then the most recently created note appears before the older one const sortFirst = noteCard(page, 'Sort-First'); const sortSecond = noteCard(page, 'Sort-Second'); const firstBox = await sortSecond.boundingBox(); diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts index 7a17a9a..e8c741b 100644 --- a/tests/e2e/search.spec.ts +++ b/tests/e2e/search.spec.ts @@ -1,15 +1,10 @@ -import { test, expect, noteCard } from './helpers/fixtures.js'; +import { test, expect, createNote } from './helpers/fixtures.js'; test.describe('Search', () => { test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => { // Given a note titled "Grocery Errands" and a note titled "Work Meeting" exist - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Grocery Errands'); - await page.getByTestId('close-editor-btn').click(); - - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Work Meeting'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Grocery Errands'); + await createNote(page, 'Work Meeting'); // When the user searches for "Grocery" await page.getByTestId('search-input').fill('Grocery'); @@ -20,12 +15,7 @@ test.describe('Search', () => { test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => { // Given a note titled "Recipe" with content "Pasta with tomato sauce" exists - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Recipe'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - await editor.click(); - await editor.pressSequentially('Pasta with tomato sauce'); - await page.getByTestId('close-editor-btn').click(); + await createNote(page, 'Recipe', 'Pasta with tomato sauce'); // When the user searches for "tomato" await page.getByTestId('search-input').fill('tomato'); diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index 49b2937..1b3f5c7 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -128,33 +128,34 @@ test.describe('Settings — API Key Management', () => { await expect(keyItem).not.toBeVisible(); }); - test('Scenario: Settings page is accessible from sidebar', async ({ + test('Scenario: Sidebar settings link opens the preferences page', async ({ authenticatedPage: page }) => { - // Given the user is on the main page - // When the user clicks the Settings link in the sidebar + // When the user navigates to settings from the sidebar await page.getByTestId('settings-link').click(); - // Then the settings page redirects to preferences + // Then the preferences page is displayed await expect(page).toHaveURL('/settings/preferences'); await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); }); - test('Scenario: Settings subpages are navigable', async ({ + test('Scenario: Settings tabs navigate between subpages', async ({ authenticatedPage: page }) => { // Given the user is on the settings page await page.goto('/settings'); - // When the user clicks the API nav link + // When the user selects the API tab await page.getByRole('link', { name: 'API' }).click(); - // Then the API settings page is shown + // Then the API settings are displayed await expect(page).toHaveURL('/settings/mcp'); await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible(); - // When the user clicks back to Profile + // When the user selects the Profile tab await page.getByRole('link', { name: 'Profile' }).click(); + + // Then the profile settings are displayed await expect(page).toHaveURL('/settings/profile'); }); });