diff --git a/playwright/e2e/basic.spec.ts b/playwright/e2e/basic.spec.ts index d2944a526..8a49dbe4f 100644 --- a/playwright/e2e/basic.spec.ts +++ b/playwright/e2e/basic.spec.ts @@ -3,28 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Page } from '@playwright/test' - import { expect, test } from '@playwright/test' import { login } from '../support/login.ts' +import { currentNoteId, newNoteButton, waitForNoteRoute } from '../support/note.ts' import { NoteEditor } from '../support/sections/NoteEditor.ts' -function currentNoteId(page: Page): number | null { - const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/) - return match ? Number(match[1]) : null -} - -async function waitForNoteRoute(page: Page, previousNoteId: number | null): Promise { - await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) - - const noteId = currentNoteId(page) - if (noteId === null || noteId === previousNoteId) { - throw new Error('Expected to navigate to a note route') - } - - return noteId -} - test.describe('Basic checks', () => { test.beforeEach(async ({ page }) => { await login(page) @@ -38,9 +21,8 @@ test.describe('Basic checks', () => { test('Create note and type', async ({ page }) => { await page.goto('/index.php/apps/notes/') const previousNoteId = currentNoteId(page) - const newNoteButton = page.getByRole('button', { name: 'New note', exact: true }) - await expect(newNoteButton).toBeVisible() - await newNoteButton.click() + await expect(newNoteButton(page)).toBeVisible() + await newNoteButton(page).click() await waitForNoteRoute(page, previousNoteId) const editor = new NoteEditor(page) @@ -51,7 +33,7 @@ test.describe('Basic checks', () => { test('Open share sidebar from note actions', async ({ page }) => { await page.goto('/index.php/apps/notes/') const previousNoteId = currentNoteId(page) - await page.getByRole('button', { name: 'New note', exact: true }).click() + await newNoteButton(page).click() const noteId = await waitForNoteRoute(page, previousNoteId) const editor = new NoteEditor(page) @@ -68,4 +50,48 @@ test.describe('Basic checks', () => { await expect(page.locator('[data-cy-notes-share-sidebar]')).toBeVisible({ timeout: 15000 }) await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) }) + + test('persists note content after a reload', async ({ page }) => { + await page.goto('/index.php/apps/notes/') + const previousNoteId = currentNoteId(page) + await newNoteButton(page).click() + const noteId = await waitForNoteRoute(page, previousNoteId) + + const editor = new NoteEditor(page) + const content = `Persistence check ${Date.now()}` + + const savedResponse = page.waitForResponse((response) => response.url().includes(`/notes/${noteId}`) + && response.request().method() === 'PUT') + await editor.type(content) + await savedResponse + + await page.reload() + await expect(page).toHaveURL(new RegExp(`/note/${noteId}(\\?.*)?$`)) + await editor.expectText(content) + }) + + test('filters notes with the search field', async ({ page }) => { + await page.goto('/index.php/apps/notes/') + + const uniqueWord = `Findme${Date.now()}` + const previousNoteId = currentNoteId(page) + await newNoteButton(page).click() + const noteId = await waitForNoteRoute(page, previousNoteId) + + const editor = new NoteEditor(page) + await editor.type(uniqueWord) + await editor.expectText(uniqueWord) + + const noteLink = page.locator(`a[href$="/note/${noteId}"]`).first() + + const searchField = page.getByRole('textbox', { name: 'Search for notes', exact: true }) + await searchField.fill('this text matches no note at all') + await expect(noteLink).toHaveCount(0) + + await searchField.fill(uniqueWord) + await expect(noteLink).toBeVisible() + + await searchField.fill('') + await expect(noteLink).toBeVisible() + }) }) diff --git a/playwright/e2e/category-actions.spec.ts b/playwright/e2e/category-actions.spec.ts index ef3a9c683..e17c4ac3b 100644 --- a/playwright/e2e/category-actions.spec.ts +++ b/playwright/e2e/category-actions.spec.ts @@ -7,6 +7,7 @@ import type { Locator, Page, TestInfo } from '@playwright/test' import { expect, test } from '@playwright/test' import { login } from '../support/login.ts' +import { createNote, currentNoteId, newNoteButton, noteRow, uniqueTitle, waitForNoteRoute } from '../support/note.ts' function appNavigation(page: Page): Locator { return page.getByRole('navigation').filter({ @@ -18,10 +19,6 @@ function newCategoryButton(page: Page): Locator { return page.getByRole('button', { name: 'New category', exact: true }) } -function contentNewNoteButton(page: Page): Locator { - return page.getByRole('button', { name: 'New note', exact: true }) -} - function notesSearchField(page: Page): Locator { return page.getByRole('textbox', { name: 'Search for notes', exact: true }) } @@ -46,20 +43,11 @@ async function expectNavigationItemActive(page: Page, name: string): Promise { await page.goto('/index.php/apps/notes/') await expect(newCategoryButton(page)).toBeVisible() - await expect(contentNewNoteButton(page)).toHaveCount(1) - await expect(contentNewNoteButton(page)).toBeVisible() + await expect(newNoteButton(page)).toHaveCount(1) + await expect(newNoteButton(page)).toBeVisible() } async function createCategory(page: Page, name: string): Promise { @@ -76,25 +64,14 @@ async function createCategory(page: Page, name: string): Promise { await expectNavigationItemActive(page, name) } -async function waitForNewNoteRoute(page: Page, previousNoteId: number | null): Promise { - await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) - - const noteId = currentNoteId(page) - if (noteId === null || noteId === previousNoteId) { - throw new Error('Expected a new note route after creating a note') - } - - return noteId -} - async function ensureNotesView(page: Page): Promise { if (await notesSearchField(page).isVisible()) { return } const previousNoteId = currentNoteId(page) - await contentNewNoteButton(page).click() - await waitForNewNoteRoute(page, previousNoteId) + await newNoteButton(page).click() + await waitForNoteRoute(page, previousNoteId) await expect(notesSearchField(page)).toBeVisible() } @@ -104,7 +81,7 @@ async function createNoteInSelectedCategory(page: Page, category: string): Promi const previousNoteId = currentNoteId(page) await notesViewNewNoteButton(page).click() - const noteId = await waitForNewNoteRoute(page, previousNoteId) + const noteId = await waitForNoteRoute(page, previousNoteId) await expect(navigationRow(page, category).locator('.app-navigation-entry__counter-wrapper')).toContainText('1') return noteId @@ -128,7 +105,7 @@ test.describe('Category actions', () => { }) test('renames a category from the actions menu', async ({ page }, testInfo: TestInfo) => { - const category = uniqueCategoryName('rename', testInfo) + const category = uniqueTitle('rename', testInfo) const renamedCategory = `${category} renamed` const noteId = await (async () => { await createCategory(page, category) @@ -151,7 +128,7 @@ test.describe('Category actions', () => { }) test('deletes a category from the actions menu', async ({ page }, testInfo: TestInfo) => { - const category = uniqueCategoryName('delete', testInfo) + const category = uniqueTitle('delete', testInfo) await createCategory(page, category) const noteId = await createNoteInSelectedCategory(page, category) const deletedNoteUrl = new RegExp(`/note/${noteId}(\\?.*)?$`) @@ -169,3 +146,25 @@ test.describe('Category actions', () => { await expect(page).not.toHaveURL(deletedNoteUrl) }) }) + +test.describe('Drag and drop', () => { + test.beforeEach(async ({ page }) => { + await login(page) + await page.goto('/index.php/apps/notes/') + await expect(newCategoryButton(page)).toBeVisible() + }) + + test('moves a note into a category by dragging it', async ({ page }, testInfo: TestInfo) => { + const category = uniqueTitle('drag-target', testInfo) + const title = uniqueTitle('drag-note', testInfo) + + await createCategory(page, category) + const noteId = await createNote(page, title) + + await noteRow(page, noteId).dragTo(navigationRow(page, category)) + + await expect(navigationRow(page, category).locator('.app-navigation-entry__counter-wrapper')).toContainText('1') + await navigationRow(page, category).getByRole('link').click() + await expect(noteRow(page, noteId)).toBeVisible() + }) +}) diff --git a/playwright/e2e/note-actions.spec.ts b/playwright/e2e/note-actions.spec.ts new file mode 100644 index 000000000..ebd4e7b80 --- /dev/null +++ b/playwright/e2e/note-actions.spec.ts @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page, TestInfo } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { login } from '../support/login.ts' +import { createNote, newNoteButton, noteRow, uniqueTitle } from '../support/note.ts' + +async function openNoteActions(page: Page, noteId: number): Promise { + const row = noteRow(page, noteId) + await row.hover() + await row.locator('.action-item__menutoggle').click() + return row +} + +test.describe('Note actions', () => { + test.beforeEach(async ({ page }) => { + await login(page) + await page.goto('/index.php/apps/notes/') + await expect(newNoteButton(page)).toBeVisible() + }) + + test('toggles favorite from the actions menu', async ({ page }, testInfo: TestInfo) => { + const title = uniqueTitle('favorite', testInfo) + const noteId = await createNote(page, title) + + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: 'Add to favorites' }).click() + + await openNoteActions(page, noteId) + await expect(page.getByRole('menuitem', { name: 'Remove from favorites' })).toBeVisible() + await page.keyboard.press('Escape') + + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: 'Remove from favorites' }).click() + + await openNoteActions(page, noteId) + await expect(page.getByRole('menuitem', { name: 'Add to favorites' })).toBeVisible() + }) + + test('renames a note from the actions menu', async ({ page }, testInfo: TestInfo) => { + const title = uniqueTitle('rename', testInfo) + const renamedTitle = `${title} renamed` + const noteId = await createNote(page, title) + + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: 'Rename' }).click() + + const renameInput = page.getByRole('dialog', { name: 'Actions' }).getByRole('textbox') + await expect(renameInput).toBeVisible() + await renameInput.fill(renamedTitle) + await renameInput.press('Enter') + + await expect(noteRow(page, noteId)).toContainText(renamedTitle) + }) + + test('deletes a note and undoes the deletion', async ({ page }, testInfo: TestInfo) => { + const title = uniqueTitle('delete', testInfo) + const autotitleSettled = page.waitForResponse((response) => response.url().includes('/autotitle')).catch(() => null) + const noteId = await createNote(page, title) + await autotitleSettled + + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: 'Delete note' }).click() + + await expect(noteRow(page, noteId)).toHaveCount(0) + + const undoButton = page.getByRole('button', { name: 'Undo Delete', exact: true }) + await expect(undoButton).toBeVisible() + + const undoRequest = page.waitForResponse((response) => response.url().includes('/notes/undo') && response.request().method() === 'POST') + await undoButton.click() + await undoRequest + + await expect(page.getByRole('link', { name: title })).toBeVisible() + }) +}) diff --git a/playwright/support/note.ts b/playwright/support/note.ts new file mode 100644 index 000000000..0ef776fbd --- /dev/null +++ b/playwright/support/note.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Locator, Page, TestInfo } from '@playwright/test' + +import { expect } from '@playwright/test' +import { NoteEditor } from './sections/NoteEditor.ts' + +export function uniqueTitle(prefix: string, testInfo: TestInfo): string { + return `Playwright ${prefix} ${testInfo.parallelIndex}-${Date.now()}` +} + +export function currentNoteId(page: Page): number | null { + const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/) + return match ? Number(match[1]) : null +} + +export function newNoteButton(page: Page): Locator { + return page.getByRole('button', { name: 'New note', exact: true }) +} + +export function noteRow(page: Page, noteId: number): Locator { + return page.locator(`a[href$="/note/${noteId}"]`).first() + .locator('xpath=ancestor::li[1]') +} + +export async function waitForNoteRoute(page: Page, previousNoteId: number | null): Promise { + await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) + + const noteId = currentNoteId(page) + if (noteId === null || noteId === previousNoteId) { + throw new Error('Expected to navigate to a note route') + } + + return noteId +} + +export async function createNote(page: Page, title: string): Promise { + const previousNoteId = currentNoteId(page) + await newNoteButton(page).click() + const noteId = await waitForNoteRoute(page, previousNoteId) + + const editor = new NoteEditor(page) + await editor.type(title) + await editor.expectText(title) + + return noteId +}