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
+ })
+}