test: consolidate E2E tests — remove duplicates, fix timeouts, parallelize sharing

- Remove duplicate bold formatting test from notes-crud.spec.ts
- Trim formatting.spec.ts from 9 to 5 tests (remove italic, inline code,
  toolbar bold, toolbar visibility — same mechanism as bold shortcut)
- Remove hardcoded waitForTimeout(500) from search.spec.ts (Playwright
  assertions already retry with built-in timeouts)
- Restructure sharing.spec.ts: 9 serial tests → 5 parallel self-contained
  tests using collabPage fixture and API-based setup
- Add collabPage fixture to helpers/fixtures.ts for sharing tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-10 10:14:09 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 914b4fc772
commit 0b417bef0e
5 changed files with 143 additions and 259 deletions
+1 -49
View File
@@ -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();
+32 -2
View File
@@ -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 };
-19
View File
@@ -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');
});
});
-3
View File
@@ -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);
+110 -186
View File
@@ -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();
});
});