From 0282101f335d5e846b12b1d5f0e424aacaa14e57 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 9 Aug 2026 23:20:21 +0200 Subject: [PATCH] test(playwright): migrate propfind tests from Cypress to Playwright Signed-off-by: Jonas Assisted-by: OpenCode:claude-fable-5 --- cypress/e2e/propfind.spec.js | 114 ------------------------ cypress/support/commands.js | 68 -------------- package-lock.json | 28 ++++++ package.json | 1 + playwright/e2e/propfind.spec.ts | 102 +++++++++++++++++++++ playwright/support/fixtures/settings.ts | 20 +++++ playwright/support/fixtures/webdav.ts | 105 ++++++++++++++++++++++ 7 files changed, 256 insertions(+), 182 deletions(-) delete mode 100644 cypress/e2e/propfind.spec.js create mode 100644 playwright/e2e/propfind.spec.ts create mode 100644 playwright/support/fixtures/settings.ts create mode 100644 playwright/support/fixtures/webdav.ts diff --git a/cypress/e2e/propfind.spec.js b/cypress/e2e/propfind.spec.js deleted file mode 100644 index 9ef8a470cf4..00000000000 --- a/cypress/e2e/propfind.spec.js +++ /dev/null @@ -1,114 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { randUser } from '../utils/index.js' - -const user = randUser() - -// Retries fail because folders / files already exist. -describe('Text PROPFIND extension ', { retries: 0 }, function() { - const PROPERTY_WORKSPACE = 'nc:rich-workspace' - const PROPERTY_WORKSPACE_FILE = 'nc:rich-workspace-file' - const PROPERTY_WORKSPACE_FLAT = 'nc:rich-workspace-flat' - const PROPERTY_WORKSPACE_FILE_FLAT = 'nc:rich-workspace-file-flat' - - before(function() { - cy.createUser(user) - }) - - beforeEach(function() { - cy.login(user) - cy.deleteFile('/Readme.md') - cy.deleteFile('/workspace-flat') - cy.deleteFile('/workspace') - }) - - describe('with workspaces enabled', function() { - beforeEach(function() { - cy.configureText('workspace_enabled', 1) - }) - - it('always adds rich workspace property', function() { - const properties = [ - PROPERTY_WORKSPACE_FLAT, - PROPERTY_WORKSPACE_FILE_FLAT, - ] - cy.uploadFile('empty.md', 'text/markdown', '/Readme.md') - cy.visit('/apps/dashboard') - cy.propfindFolder('/', 0, properties).should( - 'have.property', - PROPERTY_WORKSPACE_FLAT, - '', - ) - cy.uploadFile('test.md', 'text/markdown', '/Readme.md') - cy.propfindFolder('/', 0, properties).should( - 'have.property', - PROPERTY_WORKSPACE_FLAT, - '## Hello world\n', - ) - cy.deleteFile('/Readme.md') - cy.propfindFolder('/', 0, properties).should( - 'have.property', - PROPERTY_WORKSPACE_FLAT, - '', - ) - }) - - it('never adds rich workspace property to nested folders for flat properties', function() { - const properties = [ - PROPERTY_WORKSPACE_FLAT, - PROPERTY_WORKSPACE_FILE_FLAT, - ] - cy.visit('/apps/dashboard') - cy.createFolder('/workspace-flat') - cy.propfindFolder('/', 1, properties) - .then((results) => results.pop()) - .should('have.property', PROPERTY_WORKSPACE_FLAT, '') - cy.uploadFile('test.md', 'text/markdown', '/workspace-flat/Readme.md') - cy.propfindFolder('/', 1, properties) - .then((results) => results.pop()) - .should('have.property', PROPERTY_WORKSPACE_FLAT, '') - }) - - // Android app relies on this to detect rich workspace availability in subfolders properly - it('adds rich workspace property to nested folders for the default properties', function() { - const properties = [PROPERTY_WORKSPACE, PROPERTY_WORKSPACE_FILE] - cy.createFolder('/workspace') - cy.visit('/apps/dashboard') - cy.propfindFolder('/', 1, properties) - .then((results) => results.pop()) - .should('have.property', PROPERTY_WORKSPACE, '') - cy.uploadFile('test.md', 'text/markdown', '/workspace/Readme.md') - cy.propfindFolder('/', 1, properties) - .then((results) => results.pop()) - .should('have.property', PROPERTY_WORKSPACE, '## Hello world\n') - }) - }) - - describe('with workspaces disabled', function() { - beforeEach(function() { - cy.configureText('workspace_enabled', 0) - }) - - it('does not return a rich workspace property', function() { - // FIXME: Ideally we do not need a page context for those tests at all - // For now the dashboard avoids that we have failing requests due to conflicts when updating the file - cy.visit('/apps/dashboard') - cy.propfindFolder('/', 1, [ - PROPERTY_WORKSPACE_FLAT, - PROPERTY_WORKSPACE_FILE_FLAT, - ]).should('not.have.property', PROPERTY_WORKSPACE_FLAT) - cy.uploadFile('test.md', 'text/markdown', '/Readme.md') - cy.propfindFolder('/', 1, [ - PROPERTY_WORKSPACE_FLAT, - PROPERTY_WORKSPACE_FILE_FLAT, - ]).should('not.have.property', PROPERTY_WORKSPACE_FLAT) - cy.createFolder('/without-workspace') - cy.propfindFolder('/', 1) - .then((results) => results.pop()) - .should('not.have.property', PROPERTY_WORKSPACE_FLAT) - }) - }) -}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index b384730920b..69f4e67477c 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -237,74 +237,6 @@ Cypress.Commands.add('getFileContent', (path) => { .then((response) => response.data) }) -Cypress.Commands.add('propfindFolder', (path, depth = 0, properties = null) => { - const defaultProperties = ` - - ` - - const propsXml = properties - ? properties.map((p) => `<${p} />`).join('\n') - : defaultProperties - - const rootPath = `${url}/remote.php/webdav/` - const requestPath = path === '/' ? rootPath : `${rootPath}${path}` - - return axios - .request({ - method: 'PROPFIND', - url: requestPath, - headers: { - Depth: depth, - 'Content-Type': 'application/xml', - }, - data: ` - - - ${propsXml} - -`, - }) - .then((response) => { - const parser = new DOMParser() - const xmlDoc = parser.parseFromString(response.data, 'text/xml') - const responses = xmlDoc.querySelectorAll('d\\:response, response') - const results = Array.from(responses).map((resp) => { - const props = {} - const propStats = resp.querySelectorAll('d\\:propstat, propstat') - propStats.forEach((propStat) => { - const status - = propStat.querySelector('d\\:status, status')?.textContent - - // Skip properties with 404 status ( not found) - if (status?.includes('404')) { - return - } - - const propElements = resp.querySelectorAll('d\\:prop > *, prop > * ') - - propElements.forEach((prop) => { - const tagName = prop.localName - const namespace = prop.namespaceURI - - let key = tagName - if (namespace === 'http://nextcloud.org/ns') { - key = `nc:${tagName}` - } else if (namespace === 'http://owncloud.org/ns') { - key = `oc:${tagName}` - } - - props[key] = prop.textContent || '' - }) - }) - return props - }) - - return depth > 0 ? results : results[0] || {} - }) -}) - Cypress.Commands.add('reloadFileList', () => { cy.get('[title="Reload current directory"] button').click() return cy.get('button').contains('Reload content').click() diff --git a/package-lock.json b/package-lock.json index da03be7f0e9..3f2cc71d79a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "@nextcloud/eslint-config": "^9.0.1", "@nextcloud/vite-config": "^2.5.4", "@playwright/test": "^1.62.1", + "@types/jsdom": "^30.0.0", "@types/markdown-it": "^14.1.2", "@types/markdown-it-footnote": "^3.0.4", "@vitejs/plugin-vue": "^6.0.8", @@ -5908,6 +5909,26 @@ "@types/sizzle": "*" } }, + "node_modules/@types/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^8.9.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.10.0.tgz", + "integrity": "sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsesc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", @@ -6010,6 +6031,13 @@ "integrity": "sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==", "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", diff --git a/package.json b/package.json index f83f6abc0f7..c65266567e2 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@nextcloud/eslint-config": "^9.0.1", "@nextcloud/vite-config": "^2.5.4", "@playwright/test": "^1.62.1", + "@types/jsdom": "^30.0.0", "@types/markdown-it": "^14.1.2", "@types/markdown-it-footnote": "^3.0.4", "@vitejs/plugin-vue": "^6.0.8", diff --git a/playwright/e2e/propfind.spec.ts b/playwright/e2e/propfind.spec.ts new file mode 100644 index 00000000000..e719b60a1b2 --- /dev/null +++ b/playwright/e2e/propfind.spec.ts @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect } from '@playwright/test' +import { createFolder, uploadFile } from '../support/fixtures/Node.ts' +import { test } from '../support/fixtures/random-user.ts' +import { setTextSetting } from '../support/fixtures/settings.ts' +import { + deleteWebDAVResource, + PROPERTY_WORKSPACE, + PROPERTY_WORKSPACE_FILE, + PROPERTY_WORKSPACE_FILE_FLAT, + PROPERTY_WORKSPACE_FLAT, + propfindFolder, +} from '../support/fixtures/webdav.ts' + +test.describe('Text PROPFIND extension', () => { + test.describe('with workspaces enabled', () => { + test.beforeEach(async ({ user }) => { + await setTextSetting(user, 'workspace_enabled', 1) + }) + + test('always adds rich workspace property', async ({ page, user }) => { + const properties = [PROPERTY_WORKSPACE_FLAT, PROPERTY_WORKSPACE_FILE_FLAT] + + await page.goto('/apps/dashboard') + await user.uploadFile({ name: 'Readme.md', content: '' }) + + const [root1] = await propfindFolder(user, '/', 0, properties) + expect(root1).toHaveProperty(PROPERTY_WORKSPACE_FLAT, '') + + await user.uploadFile({ name: 'Readme.md', content: '## Hello world\n' }) + const [root2] = await propfindFolder(user, '/', 0, properties) + expect(root2).toHaveProperty(PROPERTY_WORKSPACE_FLAT, '## Hello world\n') + + await deleteWebDAVResource(user, '/Readme.md') + const [root3] = await propfindFolder(user, '/', 0, properties) + expect(root3).toHaveProperty(PROPERTY_WORKSPACE_FLAT, '') + }) + + test('never adds rich workspace property to nested folders for flat properties', async ({ page, user }) => { + const properties = [PROPERTY_WORKSPACE_FLAT, PROPERTY_WORKSPACE_FILE_FLAT] + + await page.goto('/apps/dashboard') + await createFolder({ name: 'workspace-flat', owner: user }) + + const results1 = await propfindFolder(user, '/', 1, properties) + const folder1 = results1.find((r) => r['d:href']?.endsWith('/workspace-flat/')) + expect(folder1).toHaveProperty(PROPERTY_WORKSPACE_FLAT, '') + + await uploadFile({ name: 'workspace-flat/Readme.md', content: '## Hello world\n', owner: user }) + const results2 = await propfindFolder(user, '/', 1, properties) + const folder2 = results2.find((r) => r['d:href']?.endsWith('/workspace-flat/')) + expect(folder2).toHaveProperty(PROPERTY_WORKSPACE_FLAT, '') + }) + + // Android app relies on this to detect rich workspace availability in subfolders properly + test('adds rich workspace property to nested folders for the default properties', async ({ page, user }) => { + const properties = [PROPERTY_WORKSPACE, PROPERTY_WORKSPACE_FILE] + + await page.goto('/apps/dashboard') + await createFolder({ name: 'workspace', owner: user }) + + const results1 = await propfindFolder(user, '/', 1, properties) + const folder1 = results1.find((r) => r['d:href']?.endsWith('/workspace/')) + expect(folder1).toHaveProperty(PROPERTY_WORKSPACE, '') + + await uploadFile({ name: 'workspace/Readme.md', content: '## Hello world\n', owner: user }) + const results2 = await propfindFolder(user, '/', 1, properties) + const folder2 = results2.find((r) => r['d:href']?.endsWith('/workspace/')) + expect(folder2).toHaveProperty(PROPERTY_WORKSPACE, '## Hello world\n') + }) + }) + + test.describe('with workspaces disabled', () => { + test.beforeEach(async ({ user }) => { + await setTextSetting(user, 'workspace_enabled', 0) + }) + + test('does not return a rich workspace property', async ({ page, user }) => { + await page.goto('/apps/dashboard') + + const results1 = await propfindFolder(user, '/', 1, [PROPERTY_WORKSPACE_FLAT, PROPERTY_WORKSPACE_FILE_FLAT]) + for (const result of results1) { + expect(result).not.toHaveProperty(PROPERTY_WORKSPACE_FLAT) + } + + await user.uploadFile({ name: 'Readme.md', content: '## Hello world\n' }) + const results2 = await propfindFolder(user, '/', 1, [PROPERTY_WORKSPACE_FLAT, PROPERTY_WORKSPACE_FILE_FLAT]) + for (const result of results2) { + expect(result).not.toHaveProperty(PROPERTY_WORKSPACE_FLAT) + } + + await createFolder({ name: 'without-workspace', owner: user }) + const results3 = await propfindFolder(user, '/', 1) + const folder = results3.find((r) => r['d:href']?.endsWith('/without-workspace/')) + expect(folder).not.toHaveProperty(PROPERTY_WORKSPACE) + }) + }) +}) diff --git a/playwright/support/fixtures/settings.ts b/playwright/support/fixtures/settings.ts new file mode 100644 index 00000000000..bc1418f9649 --- /dev/null +++ b/playwright/support/fixtures/settings.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from './User.ts' + +/** + * Set a user-level configuration value for the Text app. + * + * @param user The user to do the request + * @param key The setting key to set + * @param value The value + */ +export async function setTextSetting(user: User, key: string, value: number | string): Promise { + await user.request.post('/index.php/apps/text/settings', { + data: { key, value }, + failOnStatusCode: true, + }) +} diff --git a/playwright/support/fixtures/webdav.ts b/playwright/support/fixtures/webdav.ts new file mode 100644 index 00000000000..ac25c117e1e --- /dev/null +++ b/playwright/support/fixtures/webdav.ts @@ -0,0 +1,105 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { User } from './User.ts' + +import { JSDOM } from 'jsdom' + +/** A parsed set of WebDAV properties for a single resource. Includes `d:href` for path-based lookup. */ +export type PropfindResult = Record + +export const PROPERTY_WORKSPACE = 'nc:rich-workspace' +export const PROPERTY_WORKSPACE_FILE = 'nc:rich-workspace-file' +export const PROPERTY_WORKSPACE_FLAT = 'nc:rich-workspace-flat' +export const PROPERTY_WORKSPACE_FILE_FLAT = 'nc:rich-workspace-file-flat' + +const DAV_NS = 'DAV:' +const NC_NS = 'http://nextcloud.org/ns' +const OC_NS = 'http://owncloud.org/ns' + +/** + * Delete a WebDAV resource (file or folder). Silently ignores 404. + * + * @param user The user to do the request + * @param path The WebDAV path to the file or folder + */ +export async function deleteWebDAVResource(user: User, path: string): Promise { + await user.request.delete(`/remote.php/webdav${path}`, { failOnStatusCode: false }) +} + +/** + * Send a PROPFIND request and return one parsed result per d:response. + * + * The first entry is always the requested resource itself; subsequent entries + * are its children (depth > 0). Each entry contains a `d:href` key so callers + * can locate a specific resource by path without relying on response ordering: + * + * const folder = results.find(r => r['d:href']?.endsWith('/my-folder/')) + * + * Only properties from `200 OK` propstats are included; `404 Not Found` propstats + * are silently skipped. + * + * @param user The user to do the request + * @param path The WebDAV path to query; use '/' for the user root + * @param depth The Depth header value (0 or 1) + * @param properties DAV properties to request, e.g. ['nc:rich-workspace-flat']. Defaults to nc:rich-workspace and nc:rich-workspace-file. + */ +export async function propfindFolder( + user: User, + path: string, + depth: number, + properties: string[] | null = null, +): Promise { + const defaultProperties = [PROPERTY_WORKSPACE, PROPERTY_WORKSPACE_FILE] + const props = properties ?? defaultProperties + const propsXml = props.map((p) => `<${p} />`).join('\n\t\t') + + const requestPath = `/remote.php/webdav${path}` + const response = await user.request.fetch(requestPath, { + method: 'PROPFIND', + headers: { + Depth: String(depth), + 'Content-Type': 'application/xml', + }, + data: ` + + + ${propsXml} + +`, + failOnStatusCode: false, + }) + + const body = await response.text() + const xmlDoc = new JSDOM(body, { contentType: 'text/xml' }).window.document + const responses = xmlDoc.getElementsByTagNameNS(DAV_NS, 'response') + + return Array.from(responses).map((resp) => { + const entry: PropfindResult = {} + entry['d:href'] = resp.getElementsByTagNameNS(DAV_NS, 'href')[0]?.textContent ?? '' + + Array.from(resp.getElementsByTagNameNS(DAV_NS, 'propstat')).forEach((propStat) => { + const status = propStat.getElementsByTagNameNS(DAV_NS, 'status')[0]?.textContent + if (status?.includes('404')) { + return + } + + const prop = propStat.getElementsByTagNameNS(DAV_NS, 'prop')[0] + Array.from(prop?.children ?? []).forEach((child) => { + const ns = child.namespaceURI + const local = child.localName + const key = ns === NC_NS + ? `nc:${local}` + : ns === OC_NS + ? `oc:${local}` + : local + entry[key] = child.textContent || '' + }) + }) + return entry + }) +}