diff --git a/playwright/e2e/note-actions.spec.ts b/playwright/e2e/note-actions.spec.ts index c39796919..8c3d5ffee 100644 --- a/playwright/e2e/note-actions.spec.ts +++ b/playwright/e2e/note-actions.spec.ts @@ -3,18 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, TestInfo } from '@playwright/test' +import type { 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 -} +import { createNote, newNoteButton, noteRow, openNoteActions, uniqueTitle } from '../support/note.ts' test.describe('Note actions', () => { test.beforeEach(async ({ page }) => { diff --git a/playwright/e2e/note-sidebar.spec.ts b/playwright/e2e/note-sidebar.spec.ts new file mode 100644 index 000000000..cbe135afd --- /dev/null +++ b/playwright/e2e/note-sidebar.spec.ts @@ -0,0 +1,249 @@ +/** + * 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, createNoteRevisions, newNoteButton, openNoteActions, setNoteMode, uniqueTitle } from '../support/note.ts' +import { NoteEditor } from '../support/sections/NoteEditor.ts' + +interface EventBusWindow extends Window { + _nc_event_bus: { + emit: (name: string, payload: unknown) => void + } +} + +interface NodeLike { + mtime: Date + clone: () => NodeLike +} + +function sidebar(page: Page): Locator { + return page.locator('[data-cy-notes-share-sidebar]') +} + +function tabButton(page: Page, tabId: string): Locator { + return sidebar(page).locator(`#tab-button-${tabId}`) +} + +function versionsList(page: Page): Locator { + return sidebar(page).locator('[data-files-versions-versions-list]') +} + +// scoped to the list rather than the sidebar: the sharing tab's element reports +// itself as its own shadow root, which sends a piercing query into a loop +function versionEntries(page: Page): Locator { + return page.locator('[data-files-versions-versions-list] [data-files-versions-version]') +} + +function subname(page: Page): Locator { + return sidebar(page).locator('.app-sidebar-header__subname') +} + +async function openSidebarFromActions(page: Page, noteId: number, action: string): Promise { + await openNoteActions(page, noteId) + await page.getByRole('menuitem', { name: action, exact: true }).click() + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) +} + +test.describe('Note sidebar', () => { + test.beforeEach(async ({ page }) => { + await login(page) + await page.goto('/index.php/apps/notes/') + await expect(newNoteButton(page)).toBeVisible() + }) + + test('opens the versions tab from the actions menu', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('versions', testInfo)) + + await openSidebarFromActions(page, noteId, 'Versions') + + await expect(tabButton(page, 'files_versions')).toHaveAttribute('aria-selected', 'true') + await expect(versionsList(page)).toBeAttached({ timeout: 15000 }) + }) + + test('reloads the versions list when the note is updated', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-reload', testInfo)) + + await openSidebarFromActions(page, noteId, 'Versions') + await expect(versionEntries(page).first()).toBeVisible({ timeout: 15000 }) + + let reloads = 0 + page.on('request', (request) => { + if (request.method() === 'PROPFIND' && request.url().includes('/remote.php/dav/versions/')) { + reloads += 1 + } + }) + + // what files_versions hands out once it has restored a version + await page.evaluate(() => { + const tab = document.querySelector('files-versions_sidebar-tab') as unknown as { node: NodeLike } + const node = tab.node.clone() + node.mtime = new Date(node.mtime.getTime() - 60000) + ;(window as unknown as EventBusWindow)._nc_event_bus.emit('files:node:updated', node) + }) + + await expect.poll(() => reloads, { timeout: 15000 }).toBeGreaterThan(0) + }) + + test('keeps the editor behind a spinner while a restored version loads', async ({ page, request }) => { + const noteId = await createNoteRevisions(request, [ + 'Restore spinner\n\nrevision one', + 'Restore spinner\n\nrevision two', + ]) + // the editor has to hold this note, not whichever one was open before + await page.goto(`/index.php/apps/notes/note/${noteId}`) + + // hold the restore long enough to observe what the editor does meanwhile + await page.route('**/remote.php/dav/versions/**', async (route) => { + if (route.request().method() === 'MOVE') { + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + await route.continue() + }) + + await openSidebarFromActions(page, noteId, 'Versions') + + const entries = versionEntries(page) + await expect(entries.nth(1)).toBeVisible({ timeout: 15000 }) + + const editor = page.locator('.text-editor, .note-editor') + const spinner = page.locator('#app-content-vue.loading, .text-editor-wrapper.loading') + await expect(editor).toBeVisible() + + await entries.last().hover() + await entries.last().locator('.action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Restore version' }).click() + + // the editor is gone while the restore runs, so it cannot be typed into + await expect(spinner).toBeVisible() + await expect(editor).toBeHidden() + + await expect(editor).toBeVisible({ timeout: 20000 }) + await new NoteEditor(page).expectText('Restore spinner\n\nrevision one') + }) + + test('gives the editor back when a restore fails', async ({ page, request }) => { + const noteId = await createNoteRevisions(request, [ + 'Restore failure\n\nrevision one', + 'Restore failure\n\nrevision two', + ]) + await page.goto(`/index.php/apps/notes/note/${noteId}`) + + await page.route('**/remote.php/dav/versions/**', async (route) => { + if (route.request().method() === 'MOVE') { + await new Promise((resolve) => setTimeout(resolve, 1000)) + await route.fulfill({ status: 500 }) + return + } + await route.continue() + }) + + await openSidebarFromActions(page, noteId, 'Versions') + + const entries = versionEntries(page) + await expect(entries.nth(1)).toBeVisible({ timeout: 15000 }) + + const editor = page.locator('.text-editor, .note-editor') + await expect(editor).toBeVisible() + + await entries.last().hover() + await entries.last().locator('.action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Restore version' }).click() + + await expect(page.locator('#app-content-vue.loading, .text-editor-wrapper.loading')).toBeVisible() + + // a failed restore must not leave the editor behind the spinner + await expect(editor).toBeVisible({ timeout: 15000 }) + }) + + test('shows the size, the modification date and the owner of the note', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-subname', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(subname(page)).toBeVisible({ timeout: 15000 }) + await expect(subname(page)).toContainText(/\d+(\.\d+)?\s?(B|KB|MB|GB)/) + await expect(subname(page).locator('[data-timestamp]')).toBeVisible() + await expect(subname(page).locator('.user-bubble__content')).toContainText('admin') + }) + + test('renders the allow-listed tabs only', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-tabs', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(tabButton(page, 'sharing')).toBeVisible() + await expect(tabButton(page, 'files_versions')).toBeVisible() + await expect(sidebar(page).getByRole('tab')).toHaveCount(2) + }) + + test('switches between the sharing and versions tabs', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-switch', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + + await tabButton(page, 'files_versions').click() + await expect(tabButton(page, 'files_versions')).toHaveAttribute('aria-selected', 'true') + await expect(versionsList(page)).toBeAttached({ timeout: 15000 }) + + await tabButton(page, 'sharing').click() + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible() + }) + + test('fills the sharing icon only while its tab is active', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-icons', testInfo)) + + await openSidebarFromActions(page, noteId, 'Share') + + await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toBeVisible() + await expect(tabButton(page, 'sharing').locator('.share-variant-outline-icon')).toHaveCount(0) + + await tabButton(page, 'files_versions').click() + + await expect(tabButton(page, 'sharing').locator('.share-variant-outline-icon')).toBeVisible() + await expect(tabButton(page, 'sharing').locator('.share-variant-icon')).toHaveCount(0) + }) + + test('falls back to the first tab when the requested one is unavailable', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-fallback', testInfo)) + + await page.evaluate((id) => { + (window as unknown as EventBusWindow)._nc_event_bus + .emit('notes:sidebar:open', { noteId: id, tab: 'not-a-note-sidebar-tab' }) + }, noteId) + + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + }) + + // The editor's own actions menu only exists in the markdown editor; the rich + // editor brings its own menu bar. + test.describe('markdown editor', () => { + test.beforeEach(async ({ page, request }) => { + await setNoteMode(request, 'edit') + await page.reload() + }) + + test.afterEach(async ({ request }) => { + await setNoteMode(request, 'rich') + }) + + test('opens the sidebar from the editor actions menu', async ({ page }, testInfo: TestInfo) => { + await createNote(page, uniqueTitle('sidebar-editor-menu', testInfo)) + + await page.locator('.action-buttons .action-item__menutoggle').first().click() + await page.getByRole('menuitem', { name: 'Open sidebar', exact: true }).click() + + await expect(sidebar(page)).toBeVisible({ timeout: 15000 }) + await expect(tabButton(page, 'sharing')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) + }) + }) +}) diff --git a/playwright/support/note.ts b/playwright/support/note.ts index 83eb612a6..351672c2e 100644 --- a/playwright/support/note.ts +++ b/playwright/support/note.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator, Page, TestInfo } from '@playwright/test' +import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' import { expect } from '@playwright/test' import { NoteEditor } from './sections/NoteEditor.ts' @@ -12,6 +12,66 @@ export function uniqueTitle(prefix: string, testInfo: TestInfo): string { return `Playwright ${prefix} ${testInfo.parallelIndex}-${Date.now()}` } +function apiUser(): string { + return process.env.NC_USER ?? 'admin' +} + +function apiHeaders(): Record { + const password = process.env.NC_PASS ?? 'admin' + return { Authorization: `Basic ${Buffer.from(`${apiUser()}:${password}`).toString('base64')}` } +} + +/** + * Create a note and rewrite it through WebDAV until it has one version per + * given revision. Writing goes around the app on purpose, so the note keeps its + * file name instead of being retitled from the changed content. + * + * @param request The request fixture to use + * @param revisions The contents to write, oldest first + * @return The id of the created note + */ +export async function createNoteRevisions(request: APIRequestContext, revisions: string[]): Promise { + expect(revisions.length, 'revisions to write').toBeGreaterThan(0) + + const created = await request.post('/index.php/apps/notes/api/v1/notes', { + headers: apiHeaders(), + data: { content: revisions[0] }, + }) + expect(created.ok(), 'creating the note').toBeTruthy() + + const note = await created.json() + const path = note.internalPath.split('/').map(encodeURIComponent).join('/') + + for (const content of revisions.slice(1)) { + // recent versions are thinned out to one per two seconds + await new Promise((resolve) => setTimeout(resolve, 3500)) + const written = await request.put(`/remote.php/dav/files/${apiUser()}${path}`, { + headers: apiHeaders(), + data: content, + }) + expect(written.ok(), 'writing a revision').toBeTruthy() + } + + return note.id +} + +/** + * Switch the editor the app renders: `rich`, `edit` or `preview`. + * + * Takes the isolated `request` fixture rather than `page.request`, whose basic + * auth would replace the session cookie the browser is logged in with. + * + * @param request The request fixture to use + * @param mode The editor mode to switch to + */ +export async function setNoteMode(request: APIRequestContext, mode: string): Promise { + const response = await request.put('/index.php/apps/notes/api/v1/settings', { + headers: apiHeaders(), + data: { noteMode: mode }, + }) + expect(response.ok(), `switching to the ${mode} editor`).toBeTruthy() +} + export function currentNoteId(page: Page): number | null { const match = page.url().match(/\/note\/(\d+)(?:\?.*)?$/) return match ? Number(match[1]) : null @@ -26,6 +86,13 @@ export function noteRow(page: Page, noteId: number): Locator { .locator('xpath=ancestor::li[1]') } +export 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 +} + export async function waitForNoteRoute(page: Page, previousNoteId: number | null): Promise { await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId) @@ -43,9 +110,7 @@ export async function waitForNoteRoute(page: Page, previousNoteId: number | null * @param page The page object to use */ export async function deleteAllNotes(page: Page): Promise { - const user = process.env.NC_USER ?? 'admin' - const password = process.env.NC_PASS ?? 'admin' - const headers = { Authorization: `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}` } + const headers = apiHeaders() const response = await page.request.get('/index.php/apps/notes/api/v1/notes', { headers }) expect(response.ok()).toBeTruthy() diff --git a/src/components/NoteItem.vue b/src/components/NoteItem.vue index 535bfd6e0..4f3aba371 100644 --- a/src/components/NoteItem.vue +++ b/src/components/NoteItem.vue @@ -42,6 +42,13 @@ {{ t('notes', 'Share') }} + + + {{ t('notes', 'Versions') }} + +