Skip to content
2 changes: 1 addition & 1 deletion playwright/e2e/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ test.describe('Basic checks', () => {
await noteItem.locator('.action-item__menutoggle').click()
await page.getByRole('menuitem', { name: 'Share', exact: true }).click()

await expect(page.locator('[data-cy-notes-share-sidebar]')).toBeVisible({ timeout: 15000 })
await expect(page.locator('[data-cy-notes-sidebar]')).toBeVisible({ timeout: 15000 })
await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 })
})

Expand Down
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
268 changes: 268 additions & 0 deletions playwright/e2e/note-sidebar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/**
* 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-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('lines the tab icons up with each other', async ({ page }, testInfo: TestInfo) => {
const noteId = await createNote(page, uniqueTitle('sidebar-align', testInfo))

await openSidebarFromActions(page, noteId, 'Share')
await expect(tabButton(page, 'files_versions')).toBeVisible()

const icons = await page.evaluate(() => {
const box = (id: string) => {
const selector = `#tab-button-${id} :is(.icon-vue, .material-design-icon)`
const { y, height } = document.querySelector(selector)!.getBoundingClientRect()
return { y, height }
}
return { sharing: box('sharing'), versions: box('files_versions') }
})

expect(icons.versions.y).toBeCloseTo(icons.sharing.y, 0)
expect(icons.versions.height).toBeCloseTo(icons.sharing.height, 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 })
})
})
})
2 changes: 1 addition & 1 deletion playwright/e2e/zen-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function shareButton(page: Page): Locator {
}

function shareSidebar(page: Page): Locator {
return page.locator('[data-cy-notes-share-sidebar]')
return page.locator('[data-cy-notes-sidebar]')
}

async function expectZenMode(page: Page, active: boolean): Promise<void> {
Expand Down
Loading
Loading