/**
* SPDX-FileCopyrightText: 2025 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import type { Browser, Page } from '@playwright/test'
const fileIdPropfindBody = `
`
export async function openFilesApp(page: Page) {
await page.goto('apps/files')
await page.waitForURL(/apps\/files/)
const newButton = page.getByRole('button', { name: 'New' })
await expect(newButton).toBeVisible({ timeout: 30000 })
await expect(newButton).toBeEnabled({ timeout: 30000 })
}
export async function getCanvasForInteraction(page: Page) {
const interactive = page.locator('.excalidraw__canvas.interactive')
await interactive.first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => {})
if (await interactive.count()) {
return interactive.first()
}
return page.locator('.excalidraw__canvas').first()
}
export async function waitForCanvas(page: Page, { timeout = 60000 }: { timeout?: number } = {}) {
const loading = page.getByText('Loading whiteboard...')
if (await loading.count()) {
await expect(loading).toBeHidden({ timeout })
}
const canvas = page.locator('.excalidraw__canvas').first()
await expect(canvas).toBeVisible({ timeout })
await dismissRecordingNotice(page)
}
export async function createWhiteboard(page: Page, { name }: { name?: string } = {}): Promise {
const boardName = name ?? `Whiteboard ${Date.now()}`
const newButton = page.getByRole('button', { name: 'New' })
await expect(newButton).toBeVisible({ timeout: 30000 })
await newButton.click()
const menuItem = page.getByRole('menuitem', { name: 'New whiteboard' })
await expect(menuItem).toBeVisible({ timeout: 30000 })
await menuItem.click()
const nameField = page.getByRole('textbox', { name: /name/i })
if (await nameField.count()) {
await nameField.fill(boardName)
} else {
await page.keyboard.type(boardName)
}
const createButton = page.getByRole('button', { name: 'Create' }).first()
if (await createButton.count()) {
await createButton.click()
}
try {
await waitForCanvas(page, { timeout: 20000 })
} catch (error) {
await openFilesApp(page)
await openWhiteboardFromFiles(page, boardName)
}
return boardName
}
type Point = { x: number, y: number }
type OpenWhiteboardFromFilesOptions = {
preferSharedView?: boolean
}
export async function addTextElement(page: Page, text: string, point: Point = { x: 600, y: 400 }): Promise {
await page.getByTitle(/^Text/).locator('div').click()
const canvas = await getCanvasForInteraction(page)
let clickPoint = point
if (await canvas.count()) {
const box = await canvas.boundingBox()
if (box) {
clickPoint = {
x: Math.min(box.width - 10, Math.max(10, point.x)),
y: Math.min(box.height - 10, Math.max(10, point.y)),
}
await page.mouse.click(box.x + clickPoint.x, box.y + clickPoint.y, { force: true })
} else {
await canvas.click({ position: point, force: true })
}
} else {
await page.getByText('Drawing canvas').click({ position: point, force: true })
}
const textArea = page.locator('textarea').first()
for (let i = 0; i < 4; i++) {
if (await textArea.isVisible()) {
break
}
await page.waitForTimeout(300)
if (await canvas.count()) {
await canvas.click({ position: clickPoint, force: true })
}
}
await expect(textArea).toBeVisible({ timeout: 8000 })
await textArea.fill(text)
await finalizeTextEditing(page)
await expect(page.locator('.excalidraw-textEditorContainer textarea')).toBeHidden({ timeout: 5000 })
return point
}
export async function openWhiteboardFromFiles(page: Page, name: string, options: OpenWhiteboardFromFilesOptions = {}) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const viewOrder = options.preferSharedView
? ['apps/files/sharingin', 'apps/files?view=sharingin', 'apps/files/shareoverview', 'apps/files']
: ['apps/files', 'apps/files/shareoverview']
let activeViewId = 'files'
let activeDir = '/'
const attemptFindEntry = async () => {
const searchBox = page.getByRole('searchbox', { name: /search here/i }).first()
if (await searchBox.count()) {
await searchBox.fill(name)
await searchBox.press('Enter')
}
const candidates = [
page.locator(`[data-entryname="${name}"]`).first(),
page.locator(`[data-file="${name}"]`).first(),
page.getByRole('row', { name: new RegExp(escaped, 'i') }).first(),
]
for (let attempt = 0; attempt < 60; attempt++) {
for (const locator of candidates) {
if (await locator.count()) {
return locator
}
}
await page.waitForTimeout(500)
}
return null
}
const visitView = async (path: string) => {
await page.goto(path)
await page.waitForURL(/apps\/files/, { timeout: 20000 }).catch(() => {})
const currentUrl = new URL(await page.url())
const viewParam = currentUrl.searchParams.get('view')
const viewSegment = currentUrl.pathname.split('/').filter(Boolean).pop() || 'files'
activeViewId = viewParam || (viewSegment === 'files' ? 'files' : viewSegment)
activeDir = currentUrl.searchParams.get('dir') || '/'
return attemptFindEntry()
}
let entry: ReturnType | null = null
for (const path of viewOrder) {
entry = await visitView(path)
if (entry) {
break
}
}
if (!entry) {
throw new Error(`Whiteboard file not found: ${name}`)
}
await expect(entry).toBeVisible({ timeout: 15000 })
await entry.scrollIntoViewIfNeeded()
const resolvedFileId = await entry.evaluate((row) => {
const element = row as HTMLElement
const direct = element.getAttribute('data-cy-files-list-row-fileid')
|| element.getAttribute('data-fileid')
|| element.getAttribute('data-id')
if (direct) {
return direct
}
const nested = element.querySelector('[data-cy-files-list-row-fileid], [data-fileid], [data-id]') as HTMLElement | null
return nested?.getAttribute('data-cy-files-list-row-fileid')
|| nested?.getAttribute('data-fileid')
|| nested?.getAttribute('data-id')
|| null
})
const resolvedFileName = await entry.evaluate((row) => {
const element = row as HTMLElement
const direct = element.getAttribute('data-cy-files-list-row-name')
|| element.getAttribute('data-entryname')
|| element.getAttribute('data-file')
if (direct) {
return direct
}
const ariaLabel = element.getAttribute('aria-label') || ''
const ariaMatch = ariaLabel.match(/file \"([^\"]+)\"/)
if (ariaMatch?.[1]) {
return ariaMatch[1]
}
const text = element.textContent || ''
const textMatch = text.match(/([\w\s.-]+\.(whiteboard|excalidraw))/i)
if (textMatch?.[1]) {
return textMatch[1]
}
return null
})
const openViaViewer = async () => {
const fileNameToOpen = resolvedFileName || name
if (!fileNameToOpen) {
return false
}
const normalizedDir = activeDir && activeDir !== '/' ? activeDir.replace(/\/$/, '') : ''
const filePath = normalizedDir ? `${normalizedDir}/${fileNameToOpen}` : `/${fileNameToOpen}`
await page.waitForFunction(() => Boolean((window as any).OCA?.Viewer), { timeout: 10000 }).catch(() => {})
const result = await page.evaluate(({ path }) => {
const viewer = (window as any).OCA?.Viewer
if (!viewer) {
return { ok: false, reason: 'viewer-missing' }
}
const handlers = viewer.availableHandlers || []
const hasWhiteboard = Array.isArray(handlers) && handlers.some((handler) => handler?.id === 'whiteboard')
if (viewer.openWith && hasWhiteboard) {
viewer.openWith('whiteboard', { path })
return { ok: true }
}
if (viewer.open) {
viewer.open({ path })
return { ok: true }
}
return { ok: false, reason: 'open-missing' }
}, { path: filePath })
return Boolean(result?.ok)
}
const nameLink = entry.locator('[data-cy-files-list-row-name-link]').first()
if (await nameLink.count()) {
await nameLink.click()
} else {
const viewButton = entry.getByRole('button', { name: /view|open/i }).first()
if (await viewButton.count()) {
await viewButton.click()
} else {
const target = entry.getByRole('link', { name: new RegExp(escaped, 'i') }).first()
if (await target.count()) {
await target.click()
} else {
const nameCell = entry.locator('[data-cy-files-list-row-name]').first()
if (await nameCell.count()) {
await nameCell.click()
} else {
await entry.click()
}
}
}
}
try {
await waitForCanvas(page)
} catch (error) {
await entry.dblclick()
try {
await waitForCanvas(page)
} catch (retryError) {
const viewerOpened = await openViaViewer()
if (viewerOpened) {
try {
await waitForCanvas(page)
return
} catch {
// fallback below
}
}
const fallbackFileId = resolvedFileId || await resolveFileIdByDav(page, name)
if (!fallbackFileId) {
throw retryError
}
await openWhiteboardById(page, fallbackFileId, { viewId: activeViewId, dir: activeDir })
return
}
}
}
export async function newLoggedInPage(sourcePage: Page, browser: Browser) {
const baseOrigin = new URL(await sourcePage.url()).origin
const storageState = await sourcePage.context().storageState()
const context = await browser.newContext({
baseURL: `${baseOrigin}/index.php/`,
storageState,
})
const page = await context.newPage()
return page
}
export async function finalizeTextEditing(page: Page) {
const editor = page.locator('.excalidraw-textEditorContainer textarea').first()
if (await editor.count()) {
await editor.press('Escape')
await expect(editor).toBeHidden({ timeout: 5000 })
}
}
export async function dismissRecordingNotice(page: Page) {
const notice = page.locator('.recording-unavailable')
if (await notice.count()) {
const dismissButton = notice.getByRole('button', { name: 'Dismiss' })
if (await dismissButton.count()) {
await dismissButton.click({ timeout: 2000 }).catch(() => {})
}
await notice.waitFor({ state: 'detached', timeout: 5000 }).catch(() => {})
}
}
export async function getBoardAuth(page: Page): Promise<{ fileId: number, jwt: string }> {
await page.waitForFunction(() => {
const load = window.OCP?.InitialState?.loadState
return Boolean(load && load('whiteboard', 'file_id') && load('whiteboard', 'jwt'))
}, { timeout: 20000 })
const { fileId, jwt } = await page.evaluate(() => {
const load = window.OCP?.InitialState?.loadState
return {
fileId: load ? Number(load('whiteboard', 'file_id')) : null,
jwt: load ? String(load('whiteboard', 'jwt') || '') : null,
}
})
if (!fileId || !jwt) {
throw new Error('Whiteboard initial state missing identifiers')
}
return { fileId, jwt }
}
export async function openWhiteboardById(
page: Page,
fileId: number | string,
{ viewId = 'files', dir = '/' }: { viewId?: string, dir?: string } = {},
) {
const normalizedView = viewId.replace(/^\/+/, '').replace(/\/+$/, '') || 'files'
const dirParam = encodeURIComponent(dir || '/')
await page.goto(`apps/files/${normalizedView}/${fileId}?dir=${dirParam}&openfile=true`)
await waitForCanvas(page)
}
async function resolveFileIdByDav(page: Page, name: string): Promise {
const origin = new URL(await page.url()).origin
const userResponse = await page.request.get(`${origin}/ocs/v2.php/cloud/user?format=json`, {
headers: { 'OCS-APIREQUEST': 'true' },
})
if (!userResponse.ok()) {
return null
}
const userPayload = await userResponse.json().catch(() => null)
const userId = userPayload?.ocs?.data?.id
if (!userId) {
return null
}
const requestToken = await page.evaluate(() => (window as any).OC?.requestToken
|| (document.querySelector('head meta[name="requesttoken"]') as HTMLMetaElement | null)?.content
|| null)
const candidates = (() => {
const lower = name.toLowerCase()
if (lower.endsWith('.whiteboard') || lower.endsWith('.excalidraw')) {
return [name]
}
return [`${name}.whiteboard`, `${name}.excalidraw`, name]
})()
for (const candidate of candidates) {
const filePath = encodeURIComponent(candidate)
const response = await page.request.fetch(`${origin}/remote.php/dav/files/${userId}/${filePath}`, {
method: 'PROPFIND',
headers: {
Depth: '0',
Accept: 'application/xml',
'Content-Type': 'application/xml',
...(requestToken ? { requesttoken: requestToken } : {}),
'X-Requested-With': 'XMLHttpRequest',
},
data: fileIdPropfindBody,
})
if (!response.ok()) {
continue
}
const xml = await response.text()
const match = xml.match(/<(?:oc:)?fileid>([^<]+)<\/(?:oc:)?fileid>/)
if (match?.[1]) {
return match[1]
}
}
return null
}
export async function fetchBoardContent(page: Page, auth: { fileId: number | string, jwt: string }) {
const token = auth.jwt.startsWith('Bearer ') ? auth.jwt : `Bearer ${auth.jwt}`
const maxAttempts = 5
const retryDelayMs = 500
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await page.request.get(`apps/whiteboard/${auth.fileId}`, {
headers: { Authorization: token },
})
if (response.ok()) {
const body = await response.json()
return body.data
}
const status = response.status()
const text = await response.text().catch(() => '')
const isLock = status === 409 || status === 423 || text.includes('locked')
if (attempt < maxAttempts - 1 && isLock) {
await page.waitForTimeout(retryDelayMs)
continue
}
expect(response.ok()).toBeTruthy()
}
// Should never be reached, keep type expectations satisfied
throw new Error('Failed to fetch board content after retries')
}
export async function captureBoardAuthFromSave(
page: Page,
{ containsText }: { containsText?: string } = {},
): Promise<{ fileId: number, jwt: string, apiPath: string }> {
const saveResponse = await page.waitForResponse((response) => {
const request = response.request()
if (request.method() !== 'PUT') {
return false
}
if (!response.url().includes('/apps/whiteboard/')) {
return false
}
if (!containsText) {
return true
}
return (request.postData() || '').includes(containsText)
}, { timeout: 45000 })
const authHeader = saveResponse.request().headers()['authorization']
if (!authHeader) {
throw new Error('Missing Authorization header on whiteboard save')
}
const apiPath = new URL(saveResponse.url()).pathname.replace('/index.php/', '')
const parts = apiPath.split('/')
const fileId = Number(parts.pop())
if (!fileId || Number.isNaN(fileId)) {
throw new Error(`Could not parse fileId from ${apiPath}`)
}
return { fileId, jwt: authHeader, apiPath }
}
export async function resolveStoredFileName(page: Page, displayName: string) {
const fileRow = page.getByRole('row', { name: new RegExp(displayName) })
await expect(fileRow).toBeVisible({ timeout: 30000 })
const rawName = await fileRow.evaluate((row) => {
const element = row as HTMLElement
const dataEntry = element.getAttribute('data-entryname') || element.getAttribute('data-file')
if (dataEntry) {
return dataEntry
}
const ariaLabel = element.getAttribute('aria-label') || ''
const ariaMatch = ariaLabel.match(/file \"([^\"]+)\"/)
if (ariaMatch?.[1]) {
return ariaMatch[1]
}
const text = element.textContent || ''
const textMatch = text.match(/([\w\s.-]+\.whiteboard|[\w\s.-]+\.excalidraw)/i)
if (textMatch?.[1]) {
return textMatch[1]
}
return null
})
if (!rawName) {
throw new Error(`Could not resolve stored file name for ${displayName}`)
}
return rawName.trim().replace(/\s+(\.[^.]+)$/, '$1')
}
export async function createUserShare(page: Page, { fileName, shareWith, permissions }: { fileName: string, shareWith: string, permissions: number }) {
const requestToken = await page.evaluate(() => (window as any).OC?.requestToken
|| (document.querySelector('head meta[name="requesttoken"]') as HTMLMetaElement)?.content
|| null)
const baseOrigin = new URL(await page.url()).origin
const candidates = (() => {
const lower = fileName.toLowerCase()
if (lower.endsWith('.whiteboard') || lower.endsWith('.excalidraw')) {
return [fileName]
}
return [`${fileName}.whiteboard`, `${fileName}.excalidraw`, fileName]
})()
let lastApiError: string | null = null
for (const candidate of candidates) {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await page.request.post(`${baseOrigin}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, {
form: {
path: candidate,
shareType: '0',
shareWith,
permissions: String(permissions),
},
headers: {
'OCS-APIREQUEST': 'true',
...(requestToken ? { requesttoken: requestToken } : {}),
Accept: 'application/json',
},
})
const data = await response.json().catch(() => null)
const metaStatus = data?.ocs?.meta?.statuscode
const shareId = data?.ocs?.data?.id
if (metaStatus === 200 && shareId) {
return shareId
}
lastApiError = JSON.stringify({
status: response.status(),
meta: data?.ocs?.meta,
})
const altResponse = await page.request.post(`${baseOrigin}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, {
form: {
path: `/${candidate}`,
shareType: '0',
shareWith,
permissions: String(permissions),
},
headers: {
'OCS-APIREQUEST': 'true',
...(requestToken ? { requesttoken: requestToken } : {}),
Accept: 'application/json',
},
})
const altData = await altResponse.json().catch(() => null)
const altStatus = altData?.ocs?.meta?.statuscode
const altShareId = altData?.ocs?.data?.id
if (altStatus === 200 && altShareId) {
return altShareId
}
lastApiError = JSON.stringify({
status: altResponse.status(),
meta: altData?.ocs?.meta,
})
await page.waitForTimeout(500)
}
}
// UI fallback inside Files app
const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const row = page.locator(`[data-entryname="${fileName}"], [data-file="${fileName}"]`).first()
const fallbackRow = row.count().then(count => count > 0 ? row : page.getByRole('row', { name: new RegExp(escaped, 'i') }).first())
const targetRow = await fallbackRow
await expect(targetRow).toBeVisible({ timeout: 30000 })
const shareButton = async () => {
const primary = targetRow.getByRole('button', { name: /sharing options|share/i }).first()
if (await primary.count()) {
return primary
}
return targetRow.getByRole('button', { name: /actions/i }).first()
}
const buttonToClick = await shareButton()
await buttonToClick.click()
const sharingTab = page.getByRole('tab', { name: /Sharing/i }).first()
if (await sharingTab.count()) {
await sharingTab.click()
}
const shareInputCandidates = () => [
page.getByRole('textbox', { name: /Share|users or groups|Name or email|internal recipients|external recipients/i }).first(),
page.getByRole('combobox', { name: /Share|users or groups|internal recipients|external recipients|Name or email/i }).first(),
]
let shareInput: ReturnType | null = null
for (let attempt = 0; attempt < 5; attempt++) {
for (const candidate of shareInputCandidates()) {
if (await candidate.count()) {
shareInput = candidate
break
}
}
if (shareInput) {
break
}
await page.waitForTimeout(500)
}
if (!shareInput) {
throw new Error(`Could not find sharing input field${lastApiError ? ` (API: ${lastApiError})` : ''}`)
}
await expect(shareInput).toBeVisible({ timeout: 20000 })
await shareInput.fill(shareWith)
await page.waitForTimeout(300)
const suggestion = page.getByRole('option', { name: new RegExp(shareWith, 'i') }).first()
if (await suggestion.count()) {
await suggestion.click()
} else {
await page.keyboard.press('Enter')
}
const sharedEntry = page.getByText(shareWith, { exact: false }).first()
if (await sharedEntry.count()) {
await expect(sharedEntry).toBeVisible({ timeout: 20000 })
}
const saveShareButton = page.getByRole('button', { name: /save share/i }).first()
if (await saveShareButton.count()) {
await saveShareButton.click()
}
const canEditToggle = page.getByRole('checkbox', { name: /can edit/i }).first()
if (await canEditToggle.count()) {
const shouldEdit = permissions >= 15
const isChecked = await canEditToggle.isChecked()
if (shouldEdit !== isChecked) {
await canEditToggle.click()
}
}
return 'ui-fallback'
}