Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions playwright/e2e/note-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locator> {
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 }) => {
Expand Down
249 changes: 249 additions & 0 deletions playwright/e2e/note-sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
})
})
})
73 changes: 69 additions & 4 deletions playwright/support/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, string> {
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<number> {
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<void> {
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
Expand All @@ -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<Locator> {
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<number> {
await expect.poll(() => currentNoteId(page)).not.toBe(previousNoteId)

Expand All @@ -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<void> {
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()
Expand Down
Loading
Loading