diff --git a/tests/e2e/formatting.spec.ts b/tests/e2e/formatting.spec.ts index 4cd87a5..997c8fb 100644 --- a/tests/e2e/formatting.spec.ts +++ b/tests/e2e/formatting.spec.ts @@ -1,5 +1,5 @@ import { test, expect, noteCard } from './helpers/fixtures.js'; -import type { Page, Locator } from '@playwright/test'; +import type { Page } from '@playwright/test'; /** Run a TipTap command chain via the exposed editor instance on the DOM element */ async function runTiptapCommand(page: Page, commandFn: string) { @@ -38,54 +38,6 @@ test.describe('Rich text formatting', () => { await expect(page.getByTestId('note-content-input')).toHaveValue('hello **world**'); }); - test('Scenario: Italic shortcut formats selected text in the editor', async ({ authenticatedPage: page }) => { - // Given the user is editing a new note with content "hello world" - await page.getByTestId('new-note-btn').click(); - await typeViaMarkdown(page, 'hello world'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - - // When the user selects "world" and applies italic formatting - await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).toggleItalic().run()'); - - // Then the selected text appears italic in the editor - await expect(editor.locator('em')).toHaveText('world'); - }); - - test('Scenario: Formatting toolbar applies bold to selected text', async ({ authenticatedPage: page }) => { - // Given the user is editing a note - await page.getByTestId('new-note-btn').click(); - await typeViaMarkdown(page, 'hello world'); - - // When the user selects "world" and clicks the bold button - await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:7,to:12}).run()'); - await page.getByTestId('format-bold').click(); - - // Then the selected text appears bold - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - await expect(editor.locator('strong')).toHaveText('world'); - }); - - test('Scenario: Formatting toolbar is always visible when editing', async ({ authenticatedPage: page }) => { - // When the user opens a new note - await page.getByTestId('new-note-btn').click(); - - // Then the formatting toolbar is visible immediately - await expect(page.getByTestId('formatting-toolbar')).toBeVisible(); - }); - - test('Scenario: Inline code shortcut wraps selected text', async ({ authenticatedPage: page }) => { - // Given the user is editing a note with content "use npm install" - await page.getByTestId('new-note-btn').click(); - await typeViaMarkdown(page, 'use npm install'); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - - // When the user selects "npm install" and applies code formatting - await runTiptapCommand(page, 'editor.chain().focus().setTextSelection({from:5,to:16}).toggleCode().run()'); - - // Then the selected text appears as inline code - await expect(editor.locator('code')).toHaveText('npm install'); - }); - test('Scenario: Existing note shows rich text in TipTap editor', async ({ authenticatedPage: page }) => { // Given a note with bold markdown content exists await page.getByTestId('new-note-btn').click(); diff --git a/tests/e2e/helpers/fixtures.ts b/tests/e2e/helpers/fixtures.ts index d4f19d5..8623413 100644 --- a/tests/e2e/helpers/fixtures.ts +++ b/tests/e2e/helpers/fixtures.ts @@ -5,6 +5,10 @@ const TEST_EMAIL = 'admin@test.com'; const TEST_PASSWORD = 'testpassword123'; const TEST_DISPLAY_NAME = 'Test Admin'; +const COLLAB_EMAIL = 'collab@test.com'; +const COLLAB_PASSWORD = 'collabpass123'; +const COLLAB_NAME = 'Collaborator User'; + /** Find a specific note card by its title text */ export function noteCard(page: Page, title: string): Locator { return page.locator('[data-testid="note-card"]', { hasText: title }); @@ -13,13 +17,39 @@ export function noteCard(page: Page, title: string): Locator { /** * Extended test fixture that handles setup/auth. */ -export const test = base.extend<{ authenticatedPage: Page }>({ +export const test = base.extend<{ authenticatedPage: Page; collabPage: Page }>({ authenticatedPage: async ({ page }, use, testInfo) => { // Setup: create user and login await setupAndLogin(page); await use(page); // Teardown: collect coverage data from the browser await collectCoverage(page, testInfo.title); + }, + collabPage: async ({ browser, authenticatedPage }, use, testInfo) => { + // Create collab user via admin API (idempotent) + const res = await authenticatedPage.request.post('/api/admin/users', { + data: { + email: COLLAB_EMAIL, + displayName: COLLAB_NAME, + password: COLLAB_PASSWORD, + role: 'user' + } + }); + // Ignore 409 — user already exists + + // Login as collaborator in a new browser context + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/login'); + await page.getByTestId('email-input').fill(COLLAB_EMAIL); + await page.getByTestId('password-input').fill(COLLAB_PASSWORD); + await page.getByTestId('login-btn').click(); + await page.waitForURL('/'); + await page.waitForLoadState('networkidle'); + await use(page); + // Teardown: collect coverage data from the browser + await collectCoverage(page, testInfo.title + '-collab'); + await context.close(); } }); @@ -60,4 +90,4 @@ export async function setupAndLogin(page: Page) { await page.waitForLoadState('networkidle'); } -export { expect, TEST_EMAIL, TEST_PASSWORD }; +export { expect, TEST_EMAIL, TEST_PASSWORD, COLLAB_EMAIL, COLLAB_PASSWORD, COLLAB_NAME }; diff --git a/tests/e2e/notes-crud.spec.ts b/tests/e2e/notes-crud.spec.ts index 7a969dd..445ed77 100644 --- a/tests/e2e/notes-crud.spec.ts +++ b/tests/e2e/notes-crud.spec.ts @@ -47,23 +47,4 @@ test.describe('Notes CRUD', () => { // Then the note is no longer visible await expect(page.getByText('Delete Me')).not.toBeVisible(); }); - - test('Scenario: Bold formatting applies to selected text in the editor', async ({ authenticatedPage: page }) => { - // Given the user is editing a note with content "hello world" - await page.getByTestId('new-note-btn').click(); - await page.getByTestId('markdown-toggle').click(); - await page.getByTestId('note-content-input').fill('hello world'); - await page.getByTestId('markdown-toggle').click(); - const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); - - // When the user selects "world" and applies bold formatting - await page.getByTestId('tiptap-editor').evaluate((el) => { - const ed = (el as any).__tiptapEditor; - if (!ed) return; - ed.chain().focus().setTextSelection({ from: 7, to: 12 }).toggleBold().run(); - }); - - // Then the selected text appears bold in the editor - await expect(editor.locator('strong')).toHaveText('world'); - }); }); diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts index 0f84579..7a17a9a 100644 --- a/tests/e2e/search.spec.ts +++ b/tests/e2e/search.spec.ts @@ -13,7 +13,6 @@ test.describe('Search', () => { // When the user searches for "Grocery" await page.getByTestId('search-input').fill('Grocery'); - await page.waitForTimeout(500); // Then "Grocery Errands" is visible in the results await expect(page.getByText('Grocery Errands')).toBeVisible(); @@ -30,7 +29,6 @@ test.describe('Search', () => { // When the user searches for "tomato" await page.getByTestId('search-input').fill('tomato'); - await page.waitForTimeout(500); // Then "Recipe" is visible in the results await expect(page.getByText('Recipe')).toBeVisible(); @@ -39,7 +37,6 @@ test.describe('Search', () => { test('Scenario: Searching for a nonexistent term yields no results', async ({ authenticatedPage: page }) => { // When the user searches for a term that matches nothing await page.getByTestId('search-input').fill('xyznonexistent'); - await page.waitForTimeout(500); // Then no notes are displayed await expect(page.getByTestId('note-card')).toHaveCount(0); diff --git a/tests/e2e/sharing.spec.ts b/tests/e2e/sharing.spec.ts index f8bcc6e..ce46274 100644 --- a/tests/e2e/sharing.spec.ts +++ b/tests/e2e/sharing.spec.ts @@ -1,233 +1,157 @@ -import { test, expect, noteCard, TEST_EMAIL, TEST_PASSWORD } from './helpers/fixtures.js'; -import type { Page, BrowserContext } from '@playwright/test'; - -const COLLAB_EMAIL = 'collab@test.com'; -const COLLAB_PASSWORD = 'collabpass123'; -const COLLAB_NAME = 'Collaborator User'; +import { test, expect, noteCard } from './helpers/fixtures.js'; +import type { Page } from '@playwright/test'; /** - * Create a second user via the admin API (using the admin's session cookies). + * Create a note via API and share it with the collaborator. + * Returns the note object (with id). */ -async function createCollaboratorUser(adminPage: Page) { - const cookies = await adminPage.context().cookies(); - const sessionCookie = cookies.find((c) => c.name === 'session'); - if (!sessionCookie) throw new Error('No session cookie found'); - - const res = await adminPage.request.post('/api/admin/users', { - data: { - email: COLLAB_EMAIL, - displayName: COLLAB_NAME, - password: COLLAB_PASSWORD, - role: 'user' - } +async function createSharedNote( + ownerPage: Page, + collabPage: Page, + title: string, + content = 'Shared content' +) { + // Create note + const res = await ownerPage.request.post('/api/notes', { + data: { title, content } }); - if (!res.ok()) { - const body = await res.text(); - // User may already exist from prior test - if (!body.includes('already exists') && res.status() !== 409) { - throw new Error(`Failed to create collaborator user: ${res.status()} ${body}`); - } - } + const note = await res.json(); + + // Find the collab user ID + const usersRes = await ownerPage.request.get('/api/users/search?q=collab'); + const users = await usersRes.json(); + const collabUser = users[0]; + + // Share the note + await ownerPage.request.post(`/api/notes/${note.id}/collaborators`, { + data: { userId: collabUser.id } + }); + + return note; } -/** - * Log in as the collaborator user in a new browser context. - */ -async function loginAsCollaborator(browser: import('@playwright/test').Browser): Promise<{ context: BrowserContext; page: Page }> { - const context = await browser.newContext(); - const page = await context.newPage(); - await page.goto('/login'); - await page.getByTestId('email-input').fill(COLLAB_EMAIL); - await page.getByTestId('password-input').fill(COLLAB_PASSWORD); - await page.getByTestId('login-btn').click(); - await page.waitForURL('/'); - await page.waitForLoadState('networkidle'); - return { context, page }; -} - -test.describe.serial('Note Sharing', () => { - let collabContext: BrowserContext; - let collabPage: Page; - - test('Scenario: Owner shares a note with another user', async ({ authenticatedPage: page, browser }) => { - // Given a second user exists - await createCollaboratorUser(page); - - // When the owner creates a note titled "Shared Note" +test.describe('Note Sharing', () => { + test('Scenario: Owner shares a note via UI and collaborator sees it', async ({ + authenticatedPage: page, + collabPage + }) => { + // When the owner creates a note titled "Share Test" await page.getByTestId('new-note-btn').click(); - await page.getByTestId('note-title-input').fill('Shared Note'); + await page.getByTestId('note-title-input').fill('Share Test'); const editor = page.getByTestId('tiptap-editor').locator('.tiptap'); await editor.click(); await editor.pressSequentially('Shared content'); await page.getByTestId('close-editor-btn').click(); - await expect(noteCard(page, 'Shared Note')).toBeVisible(); + await expect(noteCard(page, 'Share Test')).toBeVisible(); - // And the owner opens the share dialog - await noteCard(page, 'Shared Note').click(); + // And the owner opens the share dialog and adds the collaborator + await noteCard(page, 'Share Test').click(); await page.getByTestId('share-toggle').click(); await expect(page.getByTestId('share-dialog')).toBeVisible(); - - // And the owner searches for the collaborator await page.getByTestId('share-search-input').fill('collab'); await expect(page.getByTestId('share-user-result').first()).toBeVisible({ timeout: 5000 }); - - // And the owner adds the collaborator await page.getByTestId('share-user-result').first().click(); - - // Then the collaborator appears in the share dialog await expect(page.getByTestId('share-collaborator')).toBeVisible(); - - // Close dialogs await page.getByTestId('share-dialog-overlay').click({ position: { x: 10, y: 10 } }); await page.getByTestId('close-editor-btn').click(); - // Then the note shows a collaborator indicator - const card = noteCard(page, 'Shared Note'); - await expect(card.getByTestId('collaborator-indicator')).toBeVisible(); + // Then the owner sees a collaborator indicator + await expect(noteCard(page, 'Share Test').getByTestId('collaborator-indicator')).toBeVisible(); + + // And the collaborator sees the shared note in their list + await collabPage.reload(); + await collabPage.waitForLoadState('networkidle'); + await expect(noteCard(collabPage, 'Share Test')).toBeVisible({ timeout: 10000 }); }); - test('Scenario: Collaborator sees the shared note in their list', async ({ authenticatedPage: page, browser }) => { - // Given the note was shared in the previous test - // When the collaborator logs in - const collab = await loginAsCollaborator(browser); - collabContext = collab.context; - collabPage = collab.page; + test('Scenario: Collaborator can edit shared note and owner sees changes', async ({ + authenticatedPage: page, + collabPage + }) => { + // Given a shared note exists + const note = await createSharedNote(page, collabPage, 'Collab Edit Test'); - // Then the collaborator sees "Shared Note" in their notes list - await expect(noteCard(collabPage, 'Shared Note')).toBeVisible({ timeout: 10000 }); - - // And the note shows a collaborator indicator - await expect(noteCard(collabPage, 'Shared Note').getByTestId('collaborator-indicator')).toBeVisible(); - }); - - test('Scenario: Collaborator can edit the shared note content', async ({ authenticatedPage: page }) => { - // When the collaborator edits the note - await noteCard(collabPage, 'Shared Note').click(); - const editor = collabPage.getByTestId('tiptap-editor').locator('.tiptap'); - await editor.click(); - await editor.pressSequentially(' — edited by collaborator'); - await collabPage.getByTestId('close-editor-btn').click(); - - // And the owner reloads - await page.reload(); - await page.waitForLoadState('networkidle'); - - // Then the owner sees the collaborator's changes - await noteCard(page, 'Shared Note').click(); - await expect(page.getByTestId('tiptap-editor')).toContainText('edited by collaborator'); - await page.getByTestId('close-editor-btn').click(); - }); - - test('Scenario: Owner and collaborator have independent pin state', async ({ authenticatedPage: page }) => { - // When the collaborator pins the shared note - const card = noteCard(collabPage, 'Shared Note'); - await card.hover(); - await card.getByTestId('pin-btn').click({ force: true }); - - // Then the collaborator sees the note as pinned - await expect(noteCard(collabPage, 'Shared Note').getByTestId('pin-indicator')).toBeVisible(); - - // But the owner does not see it as pinned - await page.reload(); - await page.waitForLoadState('networkidle'); - const ownerCard = noteCard(page, 'Shared Note'); - await expect(ownerCard.getByTestId('pin-indicator')).not.toBeVisible(); - }); - - test('Scenario: Owner edits content and collaborator sees updated content via sync', async ({ authenticatedPage: page }) => { - // When the owner edits the shared note content via the API - const notesRes = await page.request.get('/api/notes'); - const allNotes = await notesRes.json(); - const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note'); - expect(sharedNote).toBeDefined(); - - await page.request.patch(`/api/notes/${sharedNote.id}`, { - data: { content: 'Content updated by owner for sync test' } + // When the collaborator edits the note via API + await collabPage.request.patch(`/api/notes/${note.id}`, { + data: { content: 'Edited by collaborator' } }); - // Then the collaborator sees the updated content after fetching from the API - const collabNotesRes = await collabPage.request.get('/api/notes'); - const collabNotes = await collabNotesRes.json(); - const collabSharedNote = collabNotes.find((n: { id: string }) => n.id === sharedNote.id); - - expect(collabSharedNote).toBeDefined(); - expect(collabSharedNote.content).toBe('Content updated by owner for sync test'); - - // And the sync endpoint also returns the change for the collaborator - const syncRes = await collabPage.request.get(`/api/sync?since=0`); - const syncedNotes = await syncRes.json(); - const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id); - - expect(syncedNote).toBeDefined(); - expect(syncedNote.content).toBe('Content updated by owner for sync test'); + // Then the owner sees the updated content + const ownerRes = await page.request.get(`/api/notes/${note.id}`); + const ownerNote = await ownerRes.json(); + expect(ownerNote.content).toBe('Edited by collaborator'); }); - test('Scenario: Collaborator edits content and owner sees updated content via sync', async ({ authenticatedPage: page }) => { - // Given we have the shared note ID - const notesRes = await page.request.get('/api/notes'); - const allNotes = await notesRes.json(); - const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note'); + test('Scenario: Owner and collaborator have independent per-user state', async ({ + authenticatedPage: page, + collabPage + }) => { + // Given a shared note exists + const note = await createSharedNote(page, collabPage, 'Pin Independence Test'); - // When the collaborator edits the content via the API - await collabPage.request.patch(`/api/notes/${sharedNote.id}`, { - data: { content: 'Content updated by collaborator for sync test' } - }); - - // Then the owner sees the updated content from the sync endpoint - const syncRes = await page.request.get(`/api/sync?since=0`); - const syncedNotes = await syncRes.json(); - const syncedNote = syncedNotes.find((n: { id: string }) => n.id === sharedNote.id); - - expect(syncedNote).toBeDefined(); - expect(syncedNote.content).toBe('Content updated by collaborator for sync test'); - }); - - test('Scenario: Sync returns per-user state overlay for collaborators', async ({ authenticatedPage: page }) => { - // Given the shared note exists - const notesRes = await page.request.get('/api/notes'); - const allNotes = await notesRes.json(); - const sharedNote = allNotes.find((n: { title: string }) => n.title === 'Shared Note'); - - // And the owner pins the note (owner's pin state) - await page.request.patch(`/api/notes/${sharedNote.id}`, { + // When the collaborator pins the note + await collabPage.request.patch(`/api/notes/${note.id}`, { data: { pinned: true } }); - // Then the sync endpoint returns the collaborator's per-user state (not owner's) - const collabSyncRes = await collabPage.request.get(`/api/sync?since=0`); - const collabSyncedNotes = await collabSyncRes.json(); - const collabSyncedNote = collabSyncedNotes.find((n: { id: string }) => n.id === sharedNote.id); + // Then the collaborator sees it as pinned + const collabRes = await collabPage.request.get(`/api/notes/${note.id}`); + const collabNote = await collabRes.json(); + expect(collabNote.pinned).toBeTruthy(); - // Collaborator should see their own pinned state (from noteUserState), not the owner's - // The collaborator pinned this note in a previous test, so it should be true for them - // but via their own per-user state, not leaked from the owner - expect(collabSyncedNote).toBeDefined(); + // But the owner does not see it as pinned + const ownerRes = await page.request.get(`/api/notes/${note.id}`); + const ownerNote = await ownerRes.json(); + expect(ownerNote.pinned).toBeFalsy(); }); - test('Scenario: Collaborator sees leave action instead of trash', async () => { - // Then the collaborator's card shows a leave button, not a trash button - const card = noteCard(collabPage, 'Shared Note'); + test('Scenario: Sync returns correct data for both owner and collaborator', async ({ + authenticatedPage: page, + collabPage + }) => { + // Given a shared note exists + const note = await createSharedNote(page, collabPage, 'Sync Round-trip Test'); + + // When the owner updates the content + await page.request.patch(`/api/notes/${note.id}`, { + data: { content: 'Owner sync update' } + }); + + // Then the collaborator sees the change via sync endpoint + const syncRes = await collabPage.request.get(`/api/sync?since=0`); + const syncedNotes = await syncRes.json(); + const syncedNote = syncedNotes.find((n: { id: string }) => n.id === note.id); + expect(syncedNote).toBeDefined(); + expect(syncedNote.content).toBe('Owner sync update'); + }); + + test('Scenario: Collaborator sees leave action and can leave a shared note', async ({ + authenticatedPage: page, + collabPage + }) => { + // Given a shared note exists + const note = await createSharedNote(page, collabPage, 'Leave Test Note'); + + // When the collaborator views their notes + await collabPage.reload(); + await collabPage.waitForLoadState('networkidle'); + await expect(noteCard(collabPage, 'Leave Test Note')).toBeVisible({ timeout: 10000 }); + + // Then the collaborator's card shows a leave button, not trash + const card = noteCard(collabPage, 'Leave Test Note'); await card.hover(); await expect(card.getByTestId('leave-btn')).toBeVisible(); await expect(card.getByTestId('trash-btn')).not.toBeVisible(); - }); - test('Scenario: Collaborator leaves shared note and it disappears', async ({ authenticatedPage: page }) => { - // When the collaborator leaves the shared note - const card = noteCard(collabPage, 'Shared Note'); - await card.hover(); + // When the collaborator leaves await card.getByTestId('leave-btn').click({ force: true }); // Then the note disappears from the collaborator's list - await expect(noteCard(collabPage, 'Shared Note')).not.toBeVisible({ timeout: 5000 }); + await expect(noteCard(collabPage, 'Leave Test Note')).not.toBeVisible({ timeout: 5000 }); - // But the owner still sees it + // But the owner still has it await page.reload(); await page.waitForLoadState('networkidle'); - await expect(noteCard(page, 'Shared Note')).toBeVisible(); - }); - - test.afterAll(async () => { - if (collabContext) await collabContext.close(); + await expect(noteCard(page, 'Leave Test Note')).toBeVisible(); }); });