mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b36326e65a | ||
|
|
26eff09d7d | ||
|
|
53aa32e07f | ||
|
|
51bfda3aec | ||
|
|
617c121221 | ||
|
|
5a9c6d2a36 | ||
|
|
7c2dad47cc | ||
|
|
370b0bc815 | ||
|
|
fc391d675c | ||
|
|
2995479b1d |
@@ -62,6 +62,8 @@ jobs:
|
|||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.58.0-noble
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -76,21 +78,6 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
|
||||||
id: playwright-cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Install Playwright System Dependencies
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
|
||||||
run: npx playwright install-deps chromium
|
|
||||||
|
|
||||||
- name: Build (with coverage instrumentation)
|
- name: Build (with coverage instrumentation)
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
env:
|
env:
|
||||||
@@ -102,6 +89,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
VITE_COVERAGE: 'true'
|
VITE_COVERAGE: 'true'
|
||||||
|
HOME: /root
|
||||||
|
|
||||||
- name: Merge E2E coverage
|
- name: Merge E2E coverage
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
crumbs:
|
crumbs:
|
||||||
image: ghcr.io/bretzel-app/crumbs:0.17.0
|
image: ghcr.io/bretzel-app/crumbs:0.19.2
|
||||||
container_name: crumbs
|
container_name: crumbs
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
+142
@@ -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 |
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "crumbs",
|
"name": "crumbs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.19.1",
|
"version": "0.19.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bretzel (https://bretzel.app)",
|
"author": "Bretzel (https://bretzel.app)",
|
||||||
|
|||||||
@@ -138,17 +138,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if note.checklistMode && checklistItems.length > 0}
|
{#if note.checklistMode && checklistItems.length > 0}
|
||||||
<ul class="space-y-1" data-testid="note-checklist-preview">
|
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview">
|
||||||
{#each sortedChecklistItems.slice(0, 8) as item}
|
{#each sortedChecklistItems.slice(0, 8) as item}
|
||||||
<li class="flex items-center gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
<li class="flex items-start gap-2 text-sm {item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
onclick={(e) => { e.stopPropagation(); toggleChecklistItem(item); }}
|
||||||
class="h-3.5 w-3.5 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)] cursor-pointer"
|
||||||
data-testid="card-checklist-checkbox"
|
data-testid="card-checklist-checkbox"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{item.text}</span>
|
<span class="break-words min-w-0">{item.text}</span>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{#if checklistItems.length > 8}
|
{#if checklistItems.length > 8}
|
||||||
|
|||||||
@@ -142,7 +142,24 @@
|
|||||||
// but we only want to close if the press also started there.
|
// but we only want to close if the press also started there.
|
||||||
let mousedownOnOverlay = false;
|
let mousedownOnOverlay = false;
|
||||||
|
|
||||||
|
// On mobile, the tap that opens the editor produces synthetic mouse events
|
||||||
|
// (mousedown → mouseup → click) at the original touch coordinates. If those
|
||||||
|
// coordinates land on the overlay backdrop, the editor would close instantly.
|
||||||
|
// Guard against this by ignoring mousedowns that arrive within the first
|
||||||
|
// animation frame after mount.
|
||||||
|
let overlayReady = false;
|
||||||
|
$effect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
overlayReady = true;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
overlayReady = false;
|
||||||
|
cancelAnimationFrame(id);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function handleOverlayMousedown(e: MouseEvent) {
|
function handleOverlayMousedown(e: MouseEvent) {
|
||||||
|
if (!overlayReady) return;
|
||||||
mousedownOnOverlay = e.target === e.currentTarget;
|
mousedownOnOverlay = e.target === e.currentTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
+18
-32
@@ -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,28 +44,27 @@ 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
|
||||||
|
const syncResponse = page.waitForResponse('**/api/sync');
|
||||||
await page.context().setOffline(false);
|
await page.context().setOffline(false);
|
||||||
|
await syncResponse;
|
||||||
// And the sync completes
|
|
||||||
await page.waitForResponse('**/api/sync');
|
|
||||||
|
|
||||||
// And the user reloads the page
|
// And the user reloads the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -81,27 +75,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 +100,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
+67
-22
@@ -1,7 +1,20 @@
|
|||||||
import { test, expect } from './helpers/fixtures.js';
|
import { test, expect } from './helpers/fixtures.js';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
/** Reset all preferences to defaults via API so each test starts clean */
|
const DEFAULT_PREFS = {
|
||||||
|
defaultNoteMode: 'richtext',
|
||||||
|
defaultNoteColor: 'default',
|
||||||
|
hideFooter: false,
|
||||||
|
sidebarDefaultState: 'open'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset preferences to defaults on both server and client localStorage.
|
||||||
|
*
|
||||||
|
* Server reset ensures GET /api/preferences returns defaults.
|
||||||
|
* localStorage reset ensures initPreferences() picks up correct values
|
||||||
|
* even if the sync request is blocked or returns stale data.
|
||||||
|
*/
|
||||||
async function resetPreferences(page: Page) {
|
async function resetPreferences(page: Page) {
|
||||||
await page.request.put('/api/preferences', {
|
await page.request.put('/api/preferences', {
|
||||||
data: {
|
data: {
|
||||||
@@ -11,6 +24,31 @@ async function resetPreferences(page: Page) {
|
|||||||
sidebarDefaultState: 'open'
|
sidebarDefaultState: 'open'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await page.evaluate(
|
||||||
|
(prefs) => localStorage.setItem('crumbs-preferences', JSON.stringify(prefs)),
|
||||||
|
DEFAULT_PREFS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a URL with reliable preference state.
|
||||||
|
*
|
||||||
|
* page.goto() triggers a full page reload which re-runs initPreferences():
|
||||||
|
* 1. loadFromLocalStorage() — synchronous, returns correct value
|
||||||
|
* 2. syncPreferencesFromServer() — async, can return stale data from
|
||||||
|
* parallel test workers that share the same user's server-side prefs
|
||||||
|
*
|
||||||
|
* This helper blocks the one GET /api/preferences sync request so
|
||||||
|
* initPreferences() falls back to localStorage (which is always correct
|
||||||
|
* from either resetPreferences or updatePreference on the settings page).
|
||||||
|
*/
|
||||||
|
async function gotoWithStablePrefs(page: Page, url: string) {
|
||||||
|
await page.route(
|
||||||
|
'**/api/preferences',
|
||||||
|
(route) => route.abort(),
|
||||||
|
{ times: 1 }
|
||||||
|
);
|
||||||
|
await page.goto(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Settings — Preferences', () => {
|
test.describe('Settings — Preferences', () => {
|
||||||
@@ -30,13 +68,13 @@ test.describe('Settings — Preferences', () => {
|
|||||||
await resetPreferences(page);
|
await resetPreferences(page);
|
||||||
|
|
||||||
// Given the user sets default note mode to Markdown
|
// Given the user sets default note mode to Markdown
|
||||||
await page.goto('/settings/preferences');
|
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||||
await page.getByTestId('pref-mode-markdown').click();
|
await page.getByTestId('pref-mode-markdown').click();
|
||||||
await putResponse;
|
await putResponse;
|
||||||
|
|
||||||
// When the user creates a new note
|
// When the user creates a new note
|
||||||
await page.goto('/');
|
await gotoWithStablePrefs(page, '/');
|
||||||
await page.getByTestId('new-note-btn').click();
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
|
||||||
// Then the note editor opens in markdown mode (textarea visible)
|
// Then the note editor opens in markdown mode (textarea visible)
|
||||||
@@ -49,23 +87,23 @@ test.describe('Settings — Preferences', () => {
|
|||||||
await resetPreferences(page);
|
await resetPreferences(page);
|
||||||
|
|
||||||
// Given the footer is visible
|
// Given the footer is visible
|
||||||
await page.goto('/');
|
await gotoWithStablePrefs(page, '/');
|
||||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||||
|
|
||||||
// When the user enables the hide footer preference
|
// When the user enables the hide footer preference
|
||||||
await page.goto('/settings/preferences');
|
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||||
await page.getByTestId('pref-hide-footer').check();
|
await page.getByTestId('pref-hide-footer').check();
|
||||||
|
|
||||||
// Then the footer is hidden
|
// Then the footer is hidden
|
||||||
await page.goto('/');
|
await gotoWithStablePrefs(page, '/');
|
||||||
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
await expect(page.getByTestId('app-footer')).not.toBeVisible();
|
||||||
|
|
||||||
// When the user disables the hide footer preference
|
// When the user disables the hide footer preference
|
||||||
await page.goto('/settings/preferences');
|
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||||
await page.getByTestId('pref-hide-footer').uncheck();
|
await page.getByTestId('pref-hide-footer').uncheck();
|
||||||
|
|
||||||
// Then the footer is visible again
|
// Then the footer is visible again
|
||||||
await page.goto('/');
|
await gotoWithStablePrefs(page, '/');
|
||||||
await expect(page.getByTestId('app-footer')).toBeVisible();
|
await expect(page.getByTestId('app-footer')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,12 +113,13 @@ test.describe('Settings — Preferences', () => {
|
|||||||
await resetPreferences(page);
|
await resetPreferences(page);
|
||||||
|
|
||||||
// Given the user changes default note mode to Markdown
|
// Given the user changes default note mode to Markdown
|
||||||
await page.goto('/settings/preferences');
|
await gotoWithStablePrefs(page, '/settings/preferences');
|
||||||
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT');
|
||||||
await page.getByTestId('pref-mode-markdown').click();
|
await page.getByTestId('pref-mode-markdown').click();
|
||||||
await putResponse;
|
await putResponse;
|
||||||
|
|
||||||
// When the page is reloaded
|
// When the page is reloaded (block sync to prevent parallel-test interference)
|
||||||
|
await page.route('**/api/preferences', (route) => route.abort(), { times: 1 });
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
// Then the Markdown button is still selected (has primary styling)
|
// Then the Markdown button is still selected (has primary styling)
|
||||||
@@ -93,9 +132,12 @@ test.describe('Settings — API Key Management', () => {
|
|||||||
test('Scenario: Created API key appears in the keys list', async ({
|
test('Scenario: Created API key appears in the keys list', async ({
|
||||||
authenticatedPage: page
|
authenticatedPage: page
|
||||||
}) => {
|
}) => {
|
||||||
|
// Use a unique name to avoid strict-mode violations from parallel test runs
|
||||||
|
const keyName = `Test Key ${Date.now()}`;
|
||||||
|
|
||||||
// When the user navigates to the MCP settings and creates an API key
|
// When the user navigates to the MCP settings and creates an API key
|
||||||
await page.goto('/settings/mcp');
|
await page.goto('/settings/mcp');
|
||||||
await page.getByTestId('api-key-name-input').fill('Settings Test Key');
|
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||||
await page.getByTestId('create-api-key-btn').click();
|
await page.getByTestId('create-api-key-btn').click();
|
||||||
|
|
||||||
// Then the key is shown once for copying
|
// Then the key is shown once for copying
|
||||||
@@ -104,20 +146,22 @@ test.describe('Settings — API Key Management', () => {
|
|||||||
expect(keyValue).toMatch(/^crumbs_/);
|
expect(keyValue).toMatch(/^crumbs_/);
|
||||||
|
|
||||||
// And it appears in the keys list
|
// And it appears in the keys list
|
||||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Settings Test Key' });
|
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||||
await expect(keyItem).toBeVisible();
|
await expect(keyItem).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Scenario: Revoked API key disappears from the keys list', async ({
|
test('Scenario: Revoked API key disappears from the keys list', async ({
|
||||||
authenticatedPage: page
|
authenticatedPage: page
|
||||||
}) => {
|
}) => {
|
||||||
|
const keyName = `Revoke Key ${Date.now()}`;
|
||||||
|
|
||||||
// Given an API key exists
|
// Given an API key exists
|
||||||
await page.goto('/settings/mcp');
|
await page.goto('/settings/mcp');
|
||||||
await page.getByTestId('api-key-name-input').fill('Revoke Me');
|
await page.getByTestId('api-key-name-input').fill(keyName);
|
||||||
await page.getByTestId('create-api-key-btn').click();
|
await page.getByTestId('create-api-key-btn').click();
|
||||||
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
await expect(page.getByTestId('created-key-display')).toBeVisible();
|
||||||
|
|
||||||
const keyItem = page.getByTestId('api-key-item').filter({ hasText: 'Revoke Me' });
|
const keyItem = page.getByTestId('api-key-item').filter({ hasText: keyName });
|
||||||
await expect(keyItem).toBeVisible();
|
await expect(keyItem).toBeVisible();
|
||||||
|
|
||||||
// When the user revokes the key
|
// When the user revokes the key
|
||||||
@@ -128,33 +172,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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -253,7 +253,7 @@
|
|||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<span class="footer-version">v0.19.1</span>
|
<span class="footer-version">v0.19.3</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user