diff --git a/tests/e2e/user-journeys.spec.ts b/tests/e2e/user-journeys.spec.ts new file mode 100644 index 0000000..8da4e38 --- /dev/null +++ b/tests/e2e/user-journeys.spec.ts @@ -0,0 +1,420 @@ +/** + * E2E tests for core user journeys + * + * Tests cover the five main user workflows: + * 1. Create - Quick capture new entries + * 2. Edit - Modify existing entries + * 3. Search - Find entries by text + * 4. Organize - Tags and pinning + * 5. Delete - Remove entries + */ +import { test, expect, testData } from './index'; + +test.describe('Create workflow', () => { + test('can create a new entry via quick capture', async ({ page, seededDb }) => { + await page.goto('/'); + + // Fill in quick capture form + const contentInput = page.locator('textarea[name="content"]'); + await contentInput.fill('New test entry from E2E'); + + // Select task type + const typeSelect = page.locator('select[name="type"]'); + await typeSelect.selectOption('task'); + + // Submit the form + const addButton = page.locator('button[type="submit"]:has-text("Add")'); + await addButton.click(); + + // Wait for entry to appear in list + await expect(page.locator('text=New test entry from E2E')).toBeVisible({ timeout: 5000 }); + }); + + test('created entry persists after page reload', async ({ page, seededDb }) => { + await page.goto('/'); + + const uniqueContent = `Persistence test ${Date.now()}`; + + // Create an entry + const contentInput = page.locator('textarea[name="content"]'); + await contentInput.fill(uniqueContent); + + const addButton = page.locator('button[type="submit"]:has-text("Add")'); + await addButton.click(); + + // Wait for entry to appear + await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 }); + + // Reload page + await page.reload(); + + // Verify entry still exists + await expect(page.locator(`text=${uniqueContent}`)).toBeVisible({ timeout: 5000 }); + }); + + test('can create entry with optional title', async ({ page, seededDb }) => { + await page.goto('/'); + + // Fill in title and content + const titleInput = page.locator('input[name="title"]'); + await titleInput.fill('My Test Title'); + + const contentInput = page.locator('textarea[name="content"]'); + await contentInput.fill('Content with a title'); + + const addButton = page.locator('button[type="submit"]:has-text("Add")'); + await addButton.click(); + + // Wait for entry to appear with the content + await expect(page.locator('text=Content with a title')).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('Edit workflow', () => { + test('can expand and edit an existing entry', async ({ page, seededDb }) => { + await page.goto('/'); + + // Find seeded entry by content and click to expand + const entryContent = testData.entries[0].content; // "Buy groceries for the week" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + await expect(entryCard).toBeVisible(); + + // Click to expand (the clickable area with role="button") + await entryCard.locator('[role="button"]').click(); + + // Wait for edit textarea to appear + const editTextarea = entryCard.locator('textarea'); + await expect(editTextarea).toBeVisible({ timeout: 5000 }); + + // Modify content + await editTextarea.fill('Buy groceries for the week - updated'); + + // Auto-save triggers after 400ms, wait for save indicator + await page.waitForTimeout(500); + + // Collapse the card + await entryCard.locator('[role="button"]').click(); + + // Verify updated content is shown + await expect(page.locator('text=Buy groceries for the week - updated')).toBeVisible({ + timeout: 5000 + }); + }); + + test('edited changes persist after reload', async ({ page, seededDb }) => { + await page.goto('/'); + + // Find and edit an entry + const entryContent = testData.entries[3].content; // "Meeting notes with stakeholders" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + await entryCard.locator('[role="button"]').click(); + + const editTextarea = entryCard.locator('textarea'); + await expect(editTextarea).toBeVisible({ timeout: 5000 }); + + const updatedContent = 'Meeting notes - edited in E2E test'; + await editTextarea.fill(updatedContent); + + // Wait for auto-save + await page.waitForTimeout(600); + + // Reload page + await page.reload(); + + // Verify changes persisted + await expect(page.locator(`text=${updatedContent}`)).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('Search workflow', () => { + test('can search entries by text', async ({ page, seededDb }) => { + await page.goto('/'); + + // Type in search bar + const searchInput = page.locator('input[placeholder*="Search"]'); + await searchInput.fill('groceries'); + + // Wait for debounced search (300ms + render time) + await page.waitForTimeout(500); + + // Verify matching entry is visible + await expect(page.locator('text=Buy groceries for the week')).toBeVisible(); + + // Verify non-matching entries are hidden + await expect(page.locator('text=Meeting notes with stakeholders')).not.toBeVisible(); + }); + + test('search shows "no results" message when nothing matches', async ({ page, seededDb }) => { + await page.goto('/'); + + const searchInput = page.locator('input[placeholder*="Search"]'); + await searchInput.fill('xyznonexistent123'); + + // Wait for debounced search + await page.waitForTimeout(500); + + // Should show no results message + await expect(page.locator('text=No entries match your search')).toBeVisible(); + }); + + test('clearing search shows all entries again', async ({ page, seededDb }) => { + await page.goto('/'); + + // First, search for something specific + const searchInput = page.locator('input[placeholder*="Search"]'); + await searchInput.fill('groceries'); + await page.waitForTimeout(500); + + // Verify filtered + await expect(page.locator('text=Meeting notes')).not.toBeVisible(); + + // Clear search + await searchInput.clear(); + await page.waitForTimeout(500); + + // Verify all entries are visible again (at least our seeded ones) + await expect(page.locator('text=Buy groceries')).toBeVisible(); + await expect(page.locator('text=Meeting notes')).toBeVisible(); + }); +}); + +test.describe('Organize workflow', () => { + test('can filter entries by type (tasks vs thoughts)', async ({ page, seededDb }) => { + await page.goto('/'); + + // Click "Tasks" filter button + const tasksButton = page.locator('button:has-text("Tasks")'); + await tasksButton.click(); + + // Wait for filter to apply + await page.waitForTimeout(300); + + // Tasks should be visible + await expect(page.locator('text=Buy groceries for the week')).toBeVisible(); + + // Thoughts should be hidden + await expect(page.locator('text=Meeting notes with stakeholders')).not.toBeVisible(); + }); + + test('can filter entries by tag', async ({ page, seededDb }) => { + await page.goto('/'); + + // Open tag filter dropdown (Svelecte component) + const tagFilter = page.locator('.filter-tag-input'); + await tagFilter.click(); + + // Select "work" tag from dropdown + await page.locator('text=work').first().click(); + + // Wait for filter to apply + await page.waitForTimeout(300); + + // Entries with "work" tag should be visible + await expect( + page.locator('text=Important pinned thought about project architecture') + ).toBeVisible(); + await expect(page.locator('text=Meeting notes with stakeholders')).toBeVisible(); + + // Entries without "work" tag should be hidden + await expect(page.locator('text=Buy groceries for the week')).not.toBeVisible(); + }); + + test('pinned entries appear in Pinned section', async ({ page, seededDb }) => { + await page.goto('/'); + + // The seeded entry "Important pinned thought about project architecture" is pinned + // Verify Pinned section exists and contains this entry + await expect(page.locator('h2:has-text("Pinned")')).toBeVisible(); + await expect( + page.locator('text=Important pinned thought about project architecture') + ).toBeVisible(); + }); + + test('can toggle pin on an entry', async ({ page, seededDb }) => { + await page.goto('/'); + + // Find an unpinned entry and expand it + const entryContent = testData.entries[3].content; // "Meeting notes with stakeholders" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + await entryCard.locator('[role="button"]').click(); + + // Find and click the pin button (should have pin icon) + const pinButton = entryCard.locator('button[aria-label*="pin" i], button:has-text("Pin")'); + if ((await pinButton.count()) > 0) { + await pinButton.first().click(); + await page.waitForTimeout(300); + + // Verify the entry now appears in Pinned section + await expect( + page.locator('h2:has-text("Pinned") + div').locator(`text=${entryContent}`) + ).toBeVisible(); + } + }); +}); + +test.describe('Delete workflow', () => { + test('can delete an entry via swipe (mobile)', async ({ page, seededDb }) => { + // This test simulates mobile swipe-to-delete + await page.goto('/'); + + const entryContent = testData.entries[4].content; // "Review pull request for feature branch" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + await expect(entryCard).toBeVisible(); + + // Simulate swipe left (touchstart, touchmove, touchend) + const box = await entryCard.boundingBox(); + if (box) { + // Touch start + await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); + + // Swipe left + await entryCard.evaluate((el) => { + // Dispatch touch events to trigger swipe + const touchStart = new TouchEvent('touchstart', { + bubbles: true, + cancelable: true, + touches: [ + new Touch({ + identifier: 0, + target: el, + clientX: 200, + clientY: 50 + }) + ] + }); + const touchMove = new TouchEvent('touchmove', { + bubbles: true, + cancelable: true, + touches: [ + new Touch({ + identifier: 0, + target: el, + clientX: 50, // Swipe 150px left + clientY: 50 + }) + ] + }); + const touchEnd = new TouchEvent('touchend', { + bubbles: true, + cancelable: true, + touches: [] + }); + + el.dispatchEvent(touchStart); + el.dispatchEvent(touchMove); + el.dispatchEvent(touchEnd); + }); + + // Wait for delete confirmation to appear + await page.waitForTimeout(300); + + // Click confirm delete if visible + const confirmDelete = page.locator('button:has-text("Delete"), button:has-text("Confirm")'); + if ((await confirmDelete.count()) > 0) { + await confirmDelete.first().click(); + } + } + }); + + test('deleted entry is removed from list', async ({ page, seededDb }) => { + await page.goto('/'); + + // Use a known entry we can delete + const entryContent = testData.entries[1].content; // "Completed task from yesterday" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + await expect(entryCard).toBeVisible(); + + // Expand the entry to find delete button + await entryCard.locator('[role="button"]').click(); + await page.waitForTimeout(200); + + // Try to find a delete button in expanded view + // If the entry has a delete button accessible via UI (not just swipe) + const deleteButton = entryCard.locator( + 'button[aria-label*="delete" i], button:has-text("Delete")' + ); + if ((await deleteButton.count()) > 0) { + await deleteButton.first().click(); + + // Wait for deletion + await page.waitForTimeout(500); + + // Verify entry is no longer visible + await expect(page.locator(`text=${entryContent}`)).not.toBeVisible(); + } + }); + + test('deleted entry does not appear after reload', async ({ page, seededDb }) => { + await page.goto('/'); + + // Note: This test depends on the previous test having deleted an entry + // In a real scenario, we'd delete in this test first + // For now, let's verify the seeded data is present, delete it, then reload + + const entryContent = testData.entries[1].content; + const entryCard = page.locator(`article:has-text("${entryContent}")`); + + // If the entry exists, try to delete it + if ((await entryCard.count()) > 0) { + // Expand and try to delete + await entryCard.locator('[role="button"]').click(); + await page.waitForTimeout(200); + + const deleteButton = entryCard.locator( + 'button[aria-label*="delete" i], button:has-text("Delete")' + ); + if ((await deleteButton.count()) > 0) { + await deleteButton.first().click(); + await page.waitForTimeout(500); + + // Reload and verify + await page.reload(); + await expect(page.locator(`text=${entryContent}`)).not.toBeVisible(); + } + } + }); +}); + +test.describe('Task completion workflow', () => { + test('can mark task as complete via checkbox', async ({ page, seededDb }) => { + await page.goto('/'); + + // Find a task entry (has checkbox) + const entryContent = testData.entries[0].content; // "Buy groceries for the week" + const entryCard = page.locator(`article:has-text("${entryContent}")`); + + // Find and click the completion checkbox + const checkbox = entryCard.locator('button[type="submit"][aria-label*="complete" i]'); + await expect(checkbox).toBeVisible(); + await checkbox.click(); + + // Wait for the update + await page.waitForTimeout(500); + + // Verify the task is now shown as complete (strikethrough or checkmark) + // The checkbox should now have a green background + await expect(checkbox).toHaveClass(/bg-green-500/); + }); + + test('completed task has strikethrough styling', async ({ page, seededDb }) => { + await page.goto('/'); + + // Find the already-completed seeded task + const completedEntry = testData.entries[1]; // "Completed task from yesterday" - status: done + + // Need to enable "show completed" to see it + // Click the toggle in the header + const completedToggle = page.locator('button:has-text("Show completed"), label:has-text("completed") input'); + if ((await completedToggle.count()) > 0) { + await completedToggle.first().click(); + await page.waitForTimeout(300); + } + + // Verify the completed task has strikethrough class + const entryCard = page.locator(`article:has-text("${completedEntry.content}")`); + if ((await entryCard.count()) > 0) { + const titleElement = entryCard.locator('h3'); + await expect(titleElement).toHaveClass(/line-through/); + } + }); +});