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
66 changes: 66 additions & 0 deletions playwright/e2e/editor-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect } from '@playwright/test'
import { test } from '../support/fixtures/create-collectives.ts'
import { observeTextEditors } from '../support/helpers/textEditor.ts'

test('leaving during editor initialization does not affect the next page', async ({ user, page, collective }) => {
const pageErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(error.message))
await observeTextEditors(page)
const first = await collective.createPage({ title: 'Source', content: 'Source content', user, page })
const second = await collective.createPage({ title: 'Destination', content: 'Destination content', user, page })
await first.open(false)
await page.evaluate(() => {
(window as any).collectivesTextProbe.holdNext = true
})
await first.getModeButton(true).click()
await page.waitForFunction(() => {
return (window as any).collectivesTextProbe.release !== null
})
await page.evaluate(async (path) => {
await (window as any).OCA.Collectives.vueRouter.push(path)
}, second.getPageUrl().replace('/index.php/apps/collectives', ''))
await expect(second.getContent()).toContainText('Destination content')
await second.switchMode(true)
await expect(second.getContent(true)).toContainText('Destination content')
await page.evaluate(() => {
(window as any).collectivesTextProbe.release()
})
await page.waitForFunction((id) => {
return (window as any).collectivesTextProbe.calls.some((call: { fileId: number, writable: boolean, resolved: boolean }) => call.fileId === id && call.writable && call.resolved)
}, first.data.id)
await expect(second.getContent(true)).toContainText('Destination content')
await second.getContent(true).fill('Destination saved after navigation')
await second.switchMode(false)
await expect(second.getContent()).toContainText('Destination saved after navigation')
await page.reload()
await expect(second.getContent()).toContainText('Destination saved after navigation')
await first.open(false)
await expect(first.getContent()).toContainText('Source content')
expect(pageErrors).toEqual([])
})

test('editor initialization rejection can be retried without navigation', async ({ user, page, collective }) => {
const pageErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(error.message))
await observeTextEditors(page)
const cp = await collective.createPage({ title: 'Retry editor', content: 'Initial content', user, page })
await cp.open(false)
await page.evaluate(() => {
(window as any).collectivesTextProbe.failNext = true
})
await cp.getModeButton(true).click()
await expect(page.getByText('Could not load the editor. Please try again.')).toBeVisible()
await page.locator('.toastify').filter({ hasText: 'Could not load the editor.' }).getByRole('button', { name: 'Close', exact: true }).click()
await cp.switchMode(true)
await cp.getContent(true).fill('Saved after retry')
await cp.switchMode(false)
await expect(cp.getContent()).toContainText('Saved after retry')
await page.reload()
await expect(cp.getContent()).toContainText('Saved after retry')
expect(pageErrors).toEqual([])
})
149 changes: 149 additions & 0 deletions playwright/e2e/page-attachments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect } from '@playwright/test'
import { test } from '../support/fixtures/create-collectives.ts'
import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts'
import { webdavUrl } from '../support/helpers/urls.ts'
import { PageSidebarSection } from '../support/sections/PageSidebarSection.ts'

for (const action of ['Rename', 'Delete']) {
test(`${action} embedded attachment from fresh preview persists file and references`, async ({ user, page, collective }) => {
await observeTextEditors(page)
const cp = await collective.createPage({ title: `Attachment ${action}`, content: 'Image page', user, page })
const src = await cp.uploadImage({ filename: 'test.png', user, page })
await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page })
await cp.open(false)
await waitForTextEditors(page)
const sidebar = new PageSidebarSection(page)
const tab = await sidebar.openSidebarTab('Attachments')
const item = tab.locator('.attachment').filter({ hasText: 'test.png' })
await item.getByRole('button', { name: 'Actions', exact: true }).click()
await page.getByRole('menuitem', { name: action, exact: true }).click()
if (action === 'Rename') {
await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png')
await page.getByRole('button', { name: 'Rename attachment', exact: true }).click()
}
// Eventual persistence alone can hide a failed save confirmation.
const success = action === 'Rename' ? 'Renamed attachment' : 'Deleted attachment test.png'
await expect(page.locator('.toastify').filter({ hasText: success })).toBeVisible()
await expect(page.locator('.toastify').filter({ hasText: 'but the page could not be saved' })).toHaveCount(0)
const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName)
const oldUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src)
await expect.poll(async () => {
const response = await page.request.get(markdownUrl)
return response.ok() ? !(await response.text()).includes(src) : false
}).toBe(true)
await expect.poll(async () => (await page.request.get(oldUrl)).status()).toBe(404)
if (action === 'Rename') {
const newUrl = oldUrl.replace('test.png', 'renamed.png')
expect((await page.request.get(newUrl)).status()).toBe(200)
await expect.poll(async () => {
const response = await page.request.get(markdownUrl)
return response.ok() ? response.text() : 'waiting for readable Markdown'
}).toContain('renamed.png')
}
await page.reload()
await cp.waitForContent(false)
if (action === 'Rename') {
await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/)
} else {
await expect(cp.getContent().locator('img')).toHaveCount(0)
}
})
}

test('embedded attachment classification survives preview edit preview and reload', async ({ user, page, collective }) => {
const cp = await collective.createPage({ title: 'Attachment classification', content: 'Image page', user, page })
const src = await cp.uploadImage({ filename: 'test.png', user, page })
await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page })
await cp.open(false)
const tab = await new PageSidebarSection(page).openSidebarTab('Attachments')
const embedded = tab.locator('.attachment-list-embedded .attachment').filter({ hasText: 'test.png' })
await expect(embedded).toBeVisible()
await embedded.getByRole('button', { name: 'Actions', exact: true }).click()
await expect(page.getByRole('menuitem', { name: 'View in page', exact: true })).toBeVisible()
await page.keyboard.press('Escape')
await cp.switchMode(true)
await cp.switchMode(false)
await expect(embedded).toBeVisible()
await page.reload()
await expect(embedded).toBeVisible()
})

test('failed editor initialization prevents attachment deletion and permits retry', async ({ user, page, collective }) => {
await observeTextEditors(page)
const cp = await collective.createPage({ title: 'Initialization failure', content: 'Image page', user, page })
const src = await cp.uploadImage({ filename: 'test.png', user, page })
await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page })
await cp.open(false)
await page.evaluate(() => {
(window as any).collectivesTextProbe.failNext = true
})
const tab = await new PageSidebarSection(page).openSidebarTab('Attachments')
const item = tab.locator('.attachment').filter({ hasText: 'test.png' })
await item.getByRole('button', { name: 'Actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
await expect(page.getByText('Failed to delete attachment')).toBeVisible()
const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src)
expect((await page.request.get(fileUrl)).status()).toBe(200)
await item.getByRole('button', { name: 'Actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
await expect.poll(async () => (await page.request.get(fileUrl)).status()).toBe(404)
await page.reload()
await expect(cp.getContent().locator('img')).toHaveCount(0)
})

test('failed preflight save prevents attachment deletion', async ({ user, page, collective }) => {
await observeTextEditors(page)
const cp = await collective.createPage({ title: 'Save failure', content: 'Image page', user, page })
const src = await cp.uploadImage({ filename: 'test.png', user, page })
await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page })
await cp.open(false)
await page.evaluate(() => {
(window as any).collectivesTextProbe.failSave = true
})
const tab = await new PageSidebarSection(page).openSidebarTab('Attachments')
await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Delete', exact: true }).click()
await expect(page.getByText('Failed to delete attachment')).toBeVisible()
const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src)
expect((await page.request.get(fileUrl)).status()).toBe(200)
await page.reload()
await expect(cp.getContent().locator('img')).toBeVisible()
})

test('failed final save reports partial rename and can be saved again', async ({ user, page, collective }) => {
await observeTextEditors(page)
const cp = await collective.createPage({ title: 'Final save failure', content: 'Image page', user, page })
const src = await cp.uploadImage({ filename: 'test.png', user, page })
await cp.setContent({ content: `Image page\n\n![test](${src})`, user, page })
await cp.open(false)
await page.evaluate(() => {
(window as any).collectivesTextProbe.failSaveAfter = 1
})
const tab = await new PageSidebarSection(page).openSidebarTab('Attachments')
await tab.locator('.attachment').filter({ hasText: 'test.png' }).getByRole('button', { name: 'Actions', exact: true }).click()
await page.getByRole('menuitem', { name: 'Rename', exact: true }).click()
await page.getByRole('textbox', { name: 'Attachment name' }).fill('renamed.png')
await page.getByRole('button', { name: 'Rename attachment', exact: true }).click()
await expect(page.getByText('The attachment was renamed, but the page could not be saved. Keep the editor open and try saving again.')).toBeVisible()
await expect(page.locator('.toastify').filter({ hasText: /^Renamed attachment/ })).toHaveCount(0)
await page.locator('.toastify').filter({ hasText: 'The attachment was renamed, but the page could not be saved.' }).getByRole('button', { name: 'Close', exact: true }).click()
await expect(cp.getContent(true)).toBeVisible()
const fileUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, src.replace('test.png', 'renamed.png'))
expect((await page.request.get(fileUrl)).status()).toBe(200)
await page.evaluate(() => {
(window as any).collectivesTextProbe.failSaveAfter = -1
})
await cp.switchMode(false)
const markdownUrl = webdavUrl(user.account.userId, cp.data.collectivePath, cp.data.filePath, cp.data.fileName)
await expect.poll(async () => {
const response = await page.request.get(markdownUrl)
return response.ok() ? response.text() : 'waiting for readable Markdown'
}).toContain('renamed.png')
await page.reload()
await expect(cp.getContent().locator('img')).toHaveAttribute('src', /renamed\.png/)
})
31 changes: 31 additions & 0 deletions playwright/e2e/page-content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,41 @@ import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { expect, mergeTests } from '@playwright/test'
import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts'
import { test as editorTest } from '../support/fixtures/editor.ts'
import { observeTextEditors, waitForTextEditors } from '../support/helpers/textEditor.ts'

const test = mergeTests(createCollectiveTest, editorTest)

test.describe('Page content', () => {
test('preview does not lock the page for WebDAV writers', async ({ user, page, collective }) => {
await observeTextEditors(page)
const collectivePage = await collective.createPage({
title: 'Preview without a writable Text session',
content: 'Original content',
user,
page,
})
await collectivePage.open(false)
await expect(collectivePage.getContent()).toContainText('Original content')
await waitForTextEditors(page)
// A hidden writable editor would acquire a Text lock and reject this PUT.
await collectivePage.setContent({ content: 'Updated through WebDAV', user, page })
const writableCalls = await page.evaluate(() => {
return (window as any).collectivesTextProbe.calls.filter((call: { writable: boolean }) => call.writable).length
})
expect(writableCalls).toBe(0)
await page.reload()
await collectivePage.waitForContent(false)
await expect(collectivePage.getContent()).toContainText('Updated through WebDAV')
// Lazy initialization must still allow switching from reading to editing.
await collectivePage.switchMode(true)
await expect(collectivePage.getContent(true)).toContainText('Updated through WebDAV')
await collectivePage.getContent(true).fill('Edited after entering edit mode')
await collectivePage.switchMode(false)
await expect(collectivePage.getContent()).toContainText('Edited after entering edit mode')
await collectivePage.switchMode(true)
await expect(collectivePage.getContent(true)).toContainText('Edited after entering edit mode')
})

test('create whiteboard from attachments menu', async ({ user, page, collective, editor }) => {
test.slow()
await runOcc(['app:enable', '--force', 'whiteboard'])
Expand Down
131 changes: 131 additions & 0 deletions playwright/support/helpers/textEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Page } from '@playwright/test'

/**
* Observe the real Text API without replacing its session/network behavior.
* Test-controlled failures/delays are used only when explicitly armed.
*
* @param page Browser page before navigation.
*/
export async function observeTextEditors(page: Page) {
await page.addInitScript(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any
const probe = w.collectivesTextProbe = {
calls: [] as Array<{ writable: boolean, loaded: boolean, resolved: boolean, fileId: number }>,
failNext: false,
holdNext: false,
failSave: false,
failSaveAfter: -1,
release: null as null | (() => void),
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const instrument = (text: any) => {
// Text installs createEditor after assigning the OCA.Text namespace.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrap = (create: any) => async function(this: unknown, options: any) {
const writable = options.readOnly === false && options.useSession !== false
const call = { writable, loaded: false, resolved: false, fileId: options.fileId }
probe.calls.push(call)
if (writable && probe.failNext) {
probe.failNext = false
throw new Error('Injected editor initialization failure')
}
if (writable && probe.holdNext) {
probe.holdNext = false
await new Promise<void>((resolve) => {
probe.release = resolve
})
}
const instance = await create.call(this, {
...options,
onLoaded: () => {
call.loaded = true
options.onLoaded?.()
},
})
call.resolved = true
if (writable) {
const save = instance.save.bind(instance)
instance.save = () => {
if (probe.failSave || probe.failSaveAfter === 0) {
return Promise.resolve(false)
}
if (probe.failSaveAfter > 0) {
probe.failSaveAfter--
}
return save()
}
}
return instance
}
let wrapped = typeof text.createEditor === 'function' ? wrap(text.createEditor) : text.createEditor
Object.defineProperty(text, 'createEditor', {
configurable: true,
get: () => wrapped,
set: (value) => {
wrapped = wrap(value)
},
})
return text
}
// Core may replace window.OCA before Text installs its API.
const namespaces = new WeakSet<object>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const observeNamespace = (namespace: any) => {
if (namespaces.has(namespace)) {
return namespace
}
namespaces.add(namespace)
let text = namespace.Text
if (text) {
text = instrument(text)
}
Object.defineProperty(namespace, 'Text', {
configurable: true,
get: () => text,
set: (value) => {
text = instrument(value)
},
})
return namespace
}
let oca = observeNamespace(w.OCA || {})
Object.defineProperty(w, 'OCA', {
configurable: true,
get: () => oca,
set: (value) => {
oca = observeNamespace(value)
},
})
})
}

/**
* Wait for every editor started during mounting, including a hidden writer.
* This deliberately does not require absence of a writer: the baseline must
* reach the WebDAV PUT and fail there with 423, after acquiring its Text lock.
*
* @param page Browser page with the Text observer installed.
*/
export async function waitForTextEditors(page: Page) {
await page.waitForFunction(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const calls = (window as any).collectivesTextProbe.calls
return calls.length > 0 && calls.every((call: { loaded: boolean, resolved: boolean }) => call.loaded && call.resolved)
})
const hasWriter = await page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).collectivesTextProbe.calls.some((call: { writable: boolean }) => call.writable)
})
if (hasWriter) {
// Also wait for the editable document to mount in the hidden container;
// a ready callback alone does not prove that its DOM has rendered.
await page.locator('[data-cy-collectives="editor"] .ProseMirror[contenteditable="true"]').waitFor({ state: 'attached' })
}
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
}
Loading