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 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-10 23:39:29 +01:00
co-authored by Claude Opus 4.6
parent 2995479b1d
commit fc391d675c
10 changed files with 249 additions and 149 deletions
+142
View File
@@ -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 |
+3 -7
View File
@@ -72,7 +72,7 @@ test.describe('Image Attachments', () => {
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH); await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible(); 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('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click(); await page.getByTestId('toggle-featured').click();
@@ -91,8 +91,6 @@ test.describe('Image Attachments', () => {
await page.getByTestId('image-toggle').click(); await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH); await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail')).toBeVisible(); await expect(page.getByTestId('attachment-thumbnail')).toBeVisible();
// Feature the image
await page.getByTestId('attachment-thumbnail').hover(); await page.getByTestId('attachment-thumbnail').hover();
await page.getByTestId('toggle-featured').click(); await page.getByTestId('toggle-featured').click();
await page.getByTestId('close-editor-btn').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 }) => { 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('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Auto-Save Test'); await page.getByTestId('note-title-input').fill('Auto-Save Test');
await page.getByTestId('image-toggle').click(); await page.getByTestId('image-toggle').click();
@@ -163,10 +161,8 @@ test.describe('Image Attachments', () => {
// Then the upload panel is visible (note was auto-saved) // Then the upload panel is visible (note was auto-saved)
await expect(page.getByTestId('image-upload')).toBeVisible(); await expect(page.getByTestId('image-upload')).toBeVisible();
// Cleanup // And the note persists in the list after closing
await page.getByTestId('close-editor-btn').click(); await page.getByTestId('close-editor-btn').click();
// And the note exists in the list
await expect(noteCard(page, 'Auto-Save Test')).toBeVisible(); await expect(noteCard(page, 'Auto-Save Test')).toBeVisible();
}); });
}); });
+4 -3
View File
@@ -70,10 +70,10 @@ test.describe.serial('Authentication', () => {
}); });
test.describe('Multi-user', () => { test.describe('Multi-user', () => {
test('admin can create a new user via API', async ({ authenticatedPage: page }) => { test('Scenario: Admin-created user is registered successfully', async ({ authenticatedPage: page }) => {
// authenticatedPage fixture already handles setup/login // 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', { const response = await page.request.post('/api/admin/users', {
data: { data: {
email: 'user2@test.com', email: 'user2@test.com',
@@ -83,6 +83,7 @@ test.describe('Multi-user', () => {
} }
}); });
// Then the user is created successfully
expect(response.status()).toBe(201); expect(response.status()).toBe(201);
}); });
}); });
+37 -33
View File
@@ -1,7 +1,23 @@
import { test, expect, noteCard } from './helpers/fixtures.js'; 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.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 // Given the user is creating a new note
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
@@ -15,16 +31,9 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('tiptap-editor')).not.toBeVisible(); await expect(page.getByTestId('tiptap-editor')).not.toBeVisible();
}); });
test('Scenario: Checklist item can be added and persisted', async ({ authenticatedPage: page }) => { test('Scenario: Checklist item persists after closing and reopening the note', async ({ authenticatedPage: page }) => {
// When the user creates a checklist note with an item "Buy milk" // Given a checklist note with an item "Buy milk" exists
await page.getByTestId('new-note-btn').click(); await createChecklistNote(page, 'Shopping List', ['Buy milk']);
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();
// When the user reopens the note // When the user reopens the note
await noteCard(page, 'Shopping List').click(); await noteCard(page, 'Shopping List').click();
@@ -34,13 +43,9 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk'); await expect(page.getByTestId('checklist-input').first()).toHaveValue('Buy milk');
}); });
test('Scenario: Checking an item marks it as completed', async ({ authenticatedPage: page }) => { test('Scenario: Checked item moves to the done section', async ({ authenticatedPage: page }) => {
// Given a checklist note with an item "Buy milk" // Given a checklist note with an item "Buy milk" exists
await page.getByTestId('new-note-btn').click(); await createChecklistNote(page, 'Tasks', ['Buy milk']);
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();
// When the user reopens the note and checks the item // When the user reopens the note and checks the item
await noteCard(page, 'Tasks').click(); await noteCard(page, 'Tasks').click();
@@ -50,13 +55,13 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done'); await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done');
await page.getByTestId('close-editor-btn').click(); 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 noteCard(page, 'Tasks').click();
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked(); await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
}); });
test('Scenario: New checklist item is added by pressing Enter', async ({ authenticatedPage: page }) => { test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given the user is editing a checklist note // Given a checklist with one item
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item'); await page.getByTestId('checklist-input').first().fill('First item');
@@ -68,46 +73,45 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-input')).toHaveCount(2); await expect(page.getByTestId('checklist-input')).toHaveCount(2);
}); });
test('Scenario: Empty checklist item is removed by pressing Backspace', async ({ authenticatedPage: page }) => { test('Scenario: Backspace on empty item removes it from the list', async ({ authenticatedPage: page }) => {
// Given a checklist note with two items // Given a checklist with two items where the second is empty
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('First item'); await page.getByTestId('checklist-input').first().fill('First item');
await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').first().press('Enter');
await expect(page.getByTestId('checklist-input')).toHaveCount(2); 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'); await page.getByTestId('checklist-input').nth(1).press('Backspace');
// Then only the first item remains // Then only the first item remains
await expect(page.getByTestId('checklist-input')).toHaveCount(1); await expect(page.getByTestId('checklist-input')).toHaveCount(1);
}); });
test('Scenario: Completed checklist items are separated and can be expanded', async ({ authenticatedPage: page }) => { test('Scenario: Completed items are separated into a done section', async ({ authenticatedPage: page }) => {
// Given a checklist note with a completed item and an uncompleted item // Given a checklist note with two items
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Hide Done Test'); await page.getByTestId('note-title-input').fill('Hide Done Test');
await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-toggle').click();
await page.getByTestId('checklist-input').first().fill('Done task'); await page.getByTestId('checklist-input').first().fill('Done task');
await page.getByTestId('checklist-input').first().press('Enter'); await page.getByTestId('checklist-input').first().press('Enter');
await page.getByTestId('checklist-input').nth(1).fill('Pending task'); await page.getByTestId('checklist-input').nth(1).fill('Pending task');
// When the user completes the first item
await page.getByTestId('checklist-checkbox').first().click(); 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')).toHaveCount(1);
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task'); await expect(page.getByTestId('checklist-input').first()).toHaveValue('Pending task');
// And the "done" toggle shows the count // And the done section shows the completed item
await expect(page.getByTestId('checklist-toggle-done')).toBeVisible();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('1 done'); 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')).toBeVisible();
await expect(page.getByTestId('checklist-done-section')).toContainText('Done task'); await expect(page.getByTestId('checklist-done-section')).toContainText('Done task');
}); });
test('Scenario: Switching back from checklist mode restores the text area', async ({ authenticatedPage: page }) => { test('Scenario: Disabling checklist mode restores the rich text editor', async ({ authenticatedPage: page }) => {
// Given the user has checklist mode enabled // Given checklist mode is enabled on a new note
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
await page.getByTestId('checklist-toggle').click(); await page.getByTestId('checklist-toggle').click();
await expect(page.getByTestId('checklist')).toBeVisible(); await expect(page.getByTestId('checklist')).toBeVisible();
+13
View File
@@ -90,4 +90,17 @@ export async function setupAndLogin(page: Page) {
await page.waitForLoadState('networkidle'); 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 }; export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME };
+4 -16
View File
@@ -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.describe('Notes CRUD', () => {
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => { 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!" // When the user creates a note titled "My First Note" with content "Hello world!"
await page.getByTestId('new-note-btn').click(); await createNote(page, 'My First Note', 'Hello world!');
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();
// Then the note is visible in the notes list // Then the note is visible in the notes list
await expect(noteCard(page, 'My First Note')).toBeVisible(); 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 }) => { test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
// Given a note titled "Original Title" exists // Given a note titled "Original Title" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Original Title', 'Original content');
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();
// When the user changes the title to "Updated Title" // When the user changes the title to "Updated Title"
await noteCard(page, 'Original Title').click(); 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 }) => { test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists // Given a note titled "Delete Me" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Delete Me');
await page.getByTestId('note-title-input').fill('Delete Me');
await page.getByTestId('close-editor-btn').click();
// When the user trashes the note // When the user trashes the note
const card = noteCard(page, 'Delete Me'); const card = noteCard(page, 'Delete Me');
+16 -31
View File
@@ -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.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 // Given the user is authenticated
// Then the sync status indicator is displayed // Then the sync status indicator is displayed
await expect(page.getByTestId('sync-indicator')).toBeVisible(); 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 }) => { test('Scenario: Created note is immediately visible in the notes list', async ({ authenticatedPage: page }) => {
// When the user creates a note titled "Offline Test Note" // When the user creates a note titled "Offline Test Note"
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Offline Test Note');
await page.getByTestId('note-title-input').fill('Offline Test Note');
await page.getByTestId('close-editor-btn').click();
// Then the note appears in the list // Then the note appears in the list
await expect(page.getByText('Offline Test Note')).toBeVisible(); 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 }) => { test('Scenario: Edited note persists in the UI when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Edit" exists // Given a note titled "Offline Edit" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Offline Edit');
await page.getByTestId('note-title-input').fill('Offline Edit');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Edit')).toBeVisible();
// When the network is unavailable // When the network is unavailable
await page.context().setOffline(true); 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('note-title-input').fill('Offline Edited');
await page.getByTestId('close-editor-btn').click(); 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(); 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(); 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('note-title-input').fill('Offline New');
await page.getByTestId('close-editor-btn').click(); 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(); 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(); await expect(page.getByText('Saved offline')).toBeVisible();
}); });
test('Scenario: Offline note survives a page reload after reconnecting', async ({ authenticatedPage: page }) => { 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); 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('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Survive Reload'); await page.getByTestId('note-title-input').fill('Survive Reload');
await page.getByTestId('close-editor-btn').click(); await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Survive Reload')).toBeVisible(); 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); await page.context().setOffline(false);
// And the sync completes
await page.waitForResponse('**/api/sync'); await page.waitForResponse('**/api/sync');
// And the user reloads the page // 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 }) => { test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
// Given a note titled "Cached Note" exists // Given a note titled "Cached Note" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Cached Note');
await page.getByTestId('note-title-input').fill('Cached Note');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Cached Note')).toBeVisible();
// 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()); await page.route('**/api/**', (route) => route.abort());
// And the user reloads the page
await page.reload(); 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(); await expect(page.getByText('Cached Note')).toBeVisible();
}); });
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => { test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Trash" exists // Given a note titled "Offline Trash" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Offline Trash');
await page.getByTestId('note-title-input').fill('Offline Trash');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Offline Trash')).toBeVisible();
// When the network is unavailable // When the network is unavailable
await page.context().setOffline(true); await page.context().setOffline(true);
@@ -114,7 +99,7 @@ test.describe('Offline Support', () => {
// Then the note is no longer visible // Then the note is no longer visible
await expect(page.getByText('Offline Trash')).not.toBeVisible(); 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(); await expect(page.getByText('Saved offline')).toBeVisible();
}); });
}); });
+17 -37
View File
@@ -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.describe('Organization Features', () => {
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => { test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
// Given a note titled "Pin Me" exists // Given a note titled "Pin Me" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Pin Me');
await page.getByTestId('note-title-input').fill('Pin Me');
await page.getByTestId('close-editor-btn').click();
// When the user pins the note // When the user pins the note
const pinCard = noteCard(page, 'Pin Me'); 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 }) => { test('Scenario: Archived note is removed from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Archive Me" exists // Given a note titled "Archive Me" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Archive Me');
await page.getByTestId('note-title-input').fill('Archive Me');
await page.getByTestId('close-editor-btn').click();
// When the user archives the note // When the user archives the note
const archiveCard = noteCard(page, 'Archive Me'); const archiveCard = noteCard(page, 'Archive Me');
@@ -31,58 +27,42 @@ test.describe('Organization Features', () => {
await expect(page.getByText('Archive Me')).not.toBeVisible(); await expect(page.getByText('Archive Me')).not.toBeVisible();
}); });
test('Scenario: Note color is changed via the color picker', async ({ authenticatedPage: page }) => { test('Scenario: Note card reflects the selected color', async ({ authenticatedPage: page }) => {
// Given the user is creating a note titled "Colored Note" // Given the user is creating a new note
await page.getByTestId('new-note-btn').click(); await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Colored Note'); 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-picker-toggle').click();
await page.getByTestId('color-coral').click(); await page.getByTestId('color-coral').click();
await page.getByTestId('close-editor-btn').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(); await expect(noteCard(page, 'Colored Note')).toBeVisible();
}); });
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => { test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
// Given a note tagged #important and an untagged note exist // Given a note tagged #important and an untagged note exist
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Tagged Note', 'This is #important');
await page.getByTestId('note-title-input').fill('Tagged Note'); await createNote(page, 'Untagged 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();
// When the user filters by the #important tag // When the user filters by the #important tag
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' }); const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
if (await tagChip.isVisible()) { await expect(tagChip).toBeVisible();
await tagChip.click(); await tagChip.click();
// Then only the tagged note is visible // Then only the tagged note is visible
await expect(page.getByText('Tagged Note').first()).toBeVisible(); await expect(page.getByText('Tagged Note').first()).toBeVisible();
}
}); });
}); });
test.describe('Note Sorting', () => { test.describe('Note Sorting', () => {
test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => { test('Scenario: Most recently updated note appears first', async ({ authenticatedPage: page }) => {
// Given two notes exist with different update times // Given two notes exist created at different times
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Sort-First');
await page.getByTestId('note-title-input').fill('Sort-First'); await createNote(page, 'Sort-Second');
await page.getByTestId('close-editor-btn').click();
await expect(noteCard(page, 'Sort-First')).toBeVisible();
await page.getByTestId('new-note-btn').click(); // Then the most recently created note appears before the older one
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
const sortFirst = noteCard(page, 'Sort-First'); const sortFirst = noteCard(page, 'Sort-First');
const sortSecond = noteCard(page, 'Sort-Second'); const sortSecond = noteCard(page, 'Sort-Second');
const firstBox = await sortSecond.boundingBox(); const firstBox = await sortSecond.boundingBox();
+4 -14
View File
@@ -1,15 +1,10 @@
import { test, expect, noteCard } from './helpers/fixtures.js'; import { test, expect, createNote } from './helpers/fixtures.js';
test.describe('Search', () => { test.describe('Search', () => {
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => { 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 // Given a note titled "Grocery Errands" and a note titled "Work Meeting" exist
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Grocery Errands');
await page.getByTestId('note-title-input').fill('Grocery Errands'); await createNote(page, 'Work Meeting');
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();
// When the user searches for "Grocery" // When the user searches for "Grocery"
await page.getByTestId('search-input').fill('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 }) => { test('Scenario: Searching by content returns the matching note', async ({ authenticatedPage: page }) => {
// Given a note titled "Recipe" with content "Pasta with tomato sauce" exists // Given a note titled "Recipe" with content "Pasta with tomato sauce" exists
await page.getByTestId('new-note-btn').click(); await createNote(page, 'Recipe', 'Pasta with tomato sauce');
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();
// When the user searches for "tomato" // When the user searches for "tomato"
await page.getByTestId('search-input').fill('tomato'); await page.getByTestId('search-input').fill('tomato');
+9 -8
View File
@@ -128,33 +128,34 @@ test.describe('Settings — API Key Management', () => {
await expect(keyItem).not.toBeVisible(); 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 authenticatedPage: page
}) => { }) => {
// Given the user is on the main page // When the user navigates to settings from the sidebar
// When the user clicks the Settings link in the sidebar
await page.getByTestId('settings-link').click(); 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).toHaveURL('/settings/preferences');
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); 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 authenticatedPage: page
}) => { }) => {
// Given the user is on the settings page // Given the user is on the settings page
await page.goto('/settings'); 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(); 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).toHaveURL('/settings/mcp');
await expect(page.getByRole('heading', { name: 'API Keys' })).toBeVisible(); 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(); await page.getByRole('link', { name: 'Profile' }).click();
// Then the profile settings are displayed
await expect(page).toHaveURL('/settings/profile'); await expect(page).toHaveURL('/settings/profile');
}); });
}); });