test(systemtags): migrate from Cypress to PlayWright

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-06-11 00:54:32 +02:00
parent 2b7415ef4d
commit ab96b85ac6
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
16 changed files with 576 additions and 647 deletions

View file

@ -105,10 +105,10 @@ jobs:
matrix:
# Run multiple copies of the current job in parallel
# Please increase the number or runners as your tests suite grows (0 based index for e2e tests)
containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7']
containers: ['setup', '0', '1', '2', '3', '4', '5', '6']
# Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions
# Always align this number with the total of e2e runners (max. index + 1)
total-containers: [8]
total-containers: [7]
services:
mysql:

View file

@ -1,468 +0,0 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile, selectAllFiles, selectRowForFile, triggerSelectionAction } from '../files/FilesUtils.ts'
import { createShare } from '../files_sharing/FilesSharingUtils.ts'
let tags = {} as Record<string, number>
const files = [
'file1.txt',
'file2.txt',
'file3.txt',
'file4.txt',
'file5.txt',
]
describe('Systemtags: Files bulk action', { testIsolation: false }, () => {
let user1: User
let user2: User
before(() => {
cy.createRandomUser().then((_user1) => {
user1 = _user1
cy.createRandomUser().then((_user2) => {
user2 = _user2
})
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
})
resetTags()
})
after(() => {
resetTags()
cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0')
})
it('Can assign tag to selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectRowForFile('file2.txt')
selectRowForFile('file4.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const tag = Object.keys(tags)[3]!
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file2.txt', [tag])
expectInlineTagForFile('file4.txt', [tag])
})
it('Can assign multiple tags to selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.get('[data-cy-systemtags-picker-tag-color]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const prevTag = Object.keys(tags)[3]!
const tag1 = Object.keys(tags)[1]!
const tag2 = Object.keys(tags)[2]!
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 2)
cy.get('@assignTagData.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file2.txt', [prevTag, tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
expectInlineTagForFile('file4.txt', [prevTag, tag1, tag2])
expectInlineTagForFile('file5.txt', [tag1, tag2])
})
it('Can remove tag from selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectRowForFile('file1.txt')
selectRowForFile('file3.txt')
selectRowForFile('file4.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const firstTag = Object.keys(tags)[3]!
const tag1 = Object.keys(tags)[1]!
const tag2 = Object.keys(tags)[2]!
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1])
expectInlineTagForFile('file2.txt', [firstTag, tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1])
expectInlineTagForFile('file4.txt', [firstTag, tag1])
expectInlineTagForFile('file5.txt', [tag1, tag2])
})
it('Can remove multiple tags from selection', () => {
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
cy.get('[data-cy-systemtags-picker-tag] input:indeterminate').should('exist')
.click({ force: true, multiple: true })
// indeterminate became checked
cy.get('[data-cy-systemtags-picker-tag] input:checked').should('exist')
.click({ force: true, multiple: true })
// now all are unchecked
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 3)
cy.get('@assignTagData.all').should('have.length', 3)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file2.txt', [])
expectInlineTagForFile('file3.txt', [])
expectInlineTagForFile('file4.txt', [])
expectInlineTagForFile('file5.txt', [])
})
it('Can assign and remove multiple tags as a secondary user', () => {
// Create new users
cy.createRandomUser().then((_user1) => {
user1 = _user1
cy.createRandomUser().then((_user2) => {
user2 = _user2
})
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData1')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData1')
const tag1 = Object.keys(tags)[0]!
const tag2 = Object.keys(tags)[3]!
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData1')
cy.wait('@assignTagData1')
cy.get('@getTagData1.all').should('have.length', 2)
cy.get('@assignTagData1.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file2.txt', [tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
expectInlineTagForFile('file4.txt', [tag1, tag2])
expectInlineTagForFile('file5.txt', [tag1, tag2])
createShare('file1.txt', user2.userId)
createShare('file3.txt', user2.userId)
cy.login(user2)
cy.visit('/apps/files')
getRowForFile('file1.txt').should('be.visible')
getRowForFile('file3.txt').should('be.visible')
expectInlineTagForFile('file1.txt', [tag1, tag2])
expectInlineTagForFile('file3.txt', [tag1, tag2])
selectRowForFile('file1.txt')
selectRowForFile('file3.txt')
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData2')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData2')
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag1]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get(`[data-cy-systemtags-picker-tag=${tags[tag2]}]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData2')
cy.wait('@assignTagData2')
cy.get('@getTagData2.all').should('have.length', 2)
cy.get('@assignTagData2.all').should('have.length', 2)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file3.txt', [])
cy.login(user1)
cy.visit('/apps/files')
expectInlineTagForFile('file1.txt', [])
expectInlineTagForFile('file3.txt', [])
})
it('Can create tag and assign files to it', () => {
cy.createRandomUser().then((user1) => {
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 5)
cy.intercept('POST', '/remote.php/dav/systemtags').as('createTag')
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
const newTag = randomBytes(8).toString('base64').slice(0, 6)
cy.get('[data-cy-systemtags-picker-input]').type(newTag)
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 0)
cy.get('[data-cy-systemtags-picker-button-create]').should('be.visible')
cy.get('[data-cy-systemtags-picker-button-create]').click()
cy.wait('@createTag')
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 6)
// Verify the new tag is selected by default
cy.get('[data-cy-systemtags-picker-tag]').contains(newTag)
.parents('[data-cy-systemtags-picker-tag]')
.findByRole('checkbox', { hidden: true }).should('be.checked')
// Apply changes
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('@getTagData.all').should('have.length', 1)
cy.get('@assignTagData.all').should('have.length', 1)
cy.get('[data-cy-systemtags-picker]').should('not.exist')
expectInlineTagForFile('file1.txt', [newTag])
expectInlineTagForFile('file2.txt', [newTag])
expectInlineTagForFile('file3.txt', [newTag])
expectInlineTagForFile('file4.txt', [newTag])
expectInlineTagForFile('file5.txt', [newTag])
})
})
it('Cannot create tag if restriction is in place', () => {
let tagId: string
cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 1')
cy.runOccCommand('tag:add testTag public --output json').then(({ stdout }) => {
const tag = JSON.parse(stdout)
tagId = tag.id
})
cy.createRandomUser().then((user1) => {
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.findByRole('textbox', { name: 'Search or create tag' }).should('not.exist')
cy.findByRole('textbox', { name: 'Search tag' }).should('be.visible')
cy.get('[data-cy-systemtags-picker-input]').type('testTag')
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1)
cy.get('[data-cy-systemtags-picker-button-create]').should('not.exist')
cy.get('[data-cy-systemtags-picker-tag-color]').should('not.exist')
// Assign the tag
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
cy.get('[data-cy-systemtags-picker]').should('not.exist')
// Finally, reset the restriction
cy.runOccCommand('config:app:set systemtags restrict_creation_to_admin --value 0')
})
})
it('Can search for tags with insensitive case', () => {
let tagId: string
resetTags()
cy.runOccCommand('tag:add TESTTAG public --output json').then(({ stdout }) => {
const tag = JSON.parse(stdout)
tagId = tag.id
})
cy.createRandomUser().then((user1) => {
files.forEach((file) => {
cy.uploadContent(user1, new Blob([]), 'text/plain', '/' + file)
})
cy.login(user1)
cy.visit('/apps/files')
files.forEach((file) => {
getRowForFile(file).should('be.visible')
})
selectAllFiles()
triggerTagManagementDialogAction()
cy.findByRole('textbox', { name: 'Search or create tag' }).should('be.visible')
cy.findByRole('textbox', { name: 'Search tag' }).should('not.exist')
cy.get('[data-cy-systemtags-picker-input]').type('testtag')
cy.get('[data-cy-systemtags-picker-tag]').should('have.length', 1)
cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
.findByRole('checkbox').should('not.be.checked')
// Assign the tag
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/*/files').as('getTagData')
cy.intercept('PROPPATCH', '/remote.php/dav/systemtags/*/files').as('assignTagData')
cy.get(`[data-cy-systemtags-picker-tag="${tagId}"]`).should('be.visible')
.findByRole('checkbox').click({ force: true })
cy.get('[data-cy-systemtags-picker-button-submit]').click()
cy.wait('@getTagData')
cy.wait('@assignTagData')
expectInlineTagForFile('file1.txt', ['TESTTAG'])
expectInlineTagForFile('file2.txt', ['TESTTAG'])
expectInlineTagForFile('file3.txt', ['TESTTAG'])
expectInlineTagForFile('file4.txt', ['TESTTAG'])
expectInlineTagForFile('file5.txt', ['TESTTAG'])
cy.get('[data-cy-systemtags-picker]').should('not.exist')
})
})
})
function resetTags() {
tags = {}
for (let i = 0; i < 5; i++) {
tags[randomBytes(8).toString('base64').slice(0, 6)] = 0
}
// delete any existing tags
cy.runOccCommand('tag:list --output=json').then((output) => {
Object.keys(JSON.parse(output.stdout)).forEach((id) => {
cy.runOccCommand(`tag:delete ${id}`)
})
})
// create tags
Object.keys(tags).forEach((tag) => {
cy.runOccCommand(`tag:add ${tag} public --output=json`).then((output) => {
tags[tag] = JSON.parse(output.stdout).id as number
})
})
cy.log('Using tags', tags)
}
function expectInlineTagForFile(file: string, tags: string[]) {
getRowForFile(file)
.find('[data-systemtags-fileid]')
.findAllByRole('listitem')
.should('have.length', tags.length)
.each((tag) => {
expect(tag.text()).to.be.oneOf(tags)
})
}
function triggerTagManagementDialogAction() {
cy.intercept('PROPFIND', '/remote.php/dav/systemtags/').as('getTagsList')
triggerSelectionAction('systemtags:bulk')
cy.wait('@getTagsList')
cy.get('[data-cy-systemtags-picker]').should('be.visible')
}

View file

@ -1,78 +0,0 @@
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile } from '../files/FilesUtils.ts'
import { addTagToFile } from './utils.ts'
describe('Systemtags: Files integration', { testIsolation: true }, () => {
let user: User
beforeEach(() => cy.createRandomUser().then(($user) => {
user = $user
cy.mkdir(user, '/folder')
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
cy.login(user)
cy.visit('/apps/files')
}))
it('See first assigned tag in the file list', () => {
const tag = randomBytes(8).toString('base64')
addTagToFile('file.txt', tag)
cy.reload()
getRowForFile('file.txt')
.findByRole('list', { name: /collaborative tags/i })
.findByRole('listitem')
.should('be.visible')
.and('contain.text', tag)
})
it('See two assigned tags are also shown in the file list', () => {
const tag1 = randomBytes(5).toString('base64')
const tag2 = randomBytes(5).toString('base64')
addTagToFile('file.txt', tag1)
addTagToFile('file.txt', tag2)
cy.reload()
getRowForFile('file.txt')
.findByRole('list', { name: /collaborative tags/i })
.children()
.should('have.length', 2)
.should('contain.text', tag1)
.should('contain.text', tag2)
})
it('See three assigned tags result in overflow entry', () => {
const tag1 = randomBytes(4).toString('base64')
const tag2 = randomBytes(4).toString('base64')
const tag3 = randomBytes(4).toString('base64')
addTagToFile('file.txt', tag1)
addTagToFile('file.txt', tag2)
addTagToFile('file.txt', tag3)
cy.reload()
getRowForFile('file.txt')
.findByRole('list', { name: /collaborative tags/i })
.children()
.then(($children) => {
expect($children.length).to.eq(4)
expect($children.get(0)).be.visible
expect($children.get(1)).be.visible
// not visible - just for accessibility
expect($children.get(2)).not.be.visible
expect($children.get(3)).not.be.visible
// Text content
expect($children.get(1)).contain.text('+2')
// Remove the '+x' element
const elements = [$children.get(0), ...$children.get().slice(2)]
.map((el) => el.innerText.trim())
expect(elements).to.have.members([tag1, tag2, tag3])
})
})
})

View file

@ -1,41 +0,0 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile, triggerActionForFile } from '../files/FilesUtils.ts'
import { createNewTagInDialog } from './utils.ts'
describe('Systemtags: Files sidebar integration', { testIsolation: true }, () => {
let user: User
beforeEach(() => cy.createRandomUser().then(($user) => {
user = $user
cy.mkdir(user, '/folder')
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
cy.login(user)
}))
it('Can assign tags using the sidebar', () => {
const tag = randomBytes(8).toString('base64')
cy.visit('/apps/files')
getRowForFile('file.txt').should('be.visible')
triggerActionForFile('file.txt', 'details')
cy.get('[data-cy-sidebar]')
.should('be.visible')
.findByRole('button', { name: 'Actions' })
.should('be.visible')
.click()
cy.findByRole('menuitem', { name: 'Add tags' })
.click()
createNewTagInDialog(tag)
})
})

View file

@ -1,42 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { User } from '@nextcloud/e2e-test-server/cypress'
import { randomBytes } from 'crypto'
import { getRowForFile } from '../files/FilesUtils.ts'
import { addTagToFile } from './utils.ts'
describe('Systemtags: Files view', { testIsolation: true }, () => {
let user: User
beforeEach(() => cy.createRandomUser().then(($user) => {
user = $user
cy.mkdir(user, '/folder')
cy.uploadContent(user, new Blob([]), 'text/plain', '/file.txt')
cy.login(user)
cy.visit('/apps/files')
}))
it('See first assigned tag in the file list', () => {
const tag = randomBytes(8).toString('base64')
addTagToFile('folder', tag)
// open the tags view
cy.visit('/apps/files/tags').then(() => {
// see the tag
getRowForFile('folder').should('not.exist')
getRowForFile('file.txt').should('not.exist')
cy.findByRole('cell', { name: tag })
.should('be.visible')
.click()
// see that the tag has its content
getRowForFile('folder').should('be.visible')
getRowForFile('file.txt').should('not.exist')
})
})
})

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { runOcc } from '@nextcloud/e2e-test-server'
import { expect } from '@playwright/test'
import { test } from '../../support/fixtures/systemtags-files-page.ts'
import { uploadContent } from '../../support/utils/dav.ts'
import { clearTags, createTag } from '../../support/utils/systemtags.ts'
test.beforeAll(async () => await runOcc(['config:app:set', 'systemtags', 'restrict_creation_to_admin', '--value', '1']))
test.afterAll(async () => await runOcc(['config:app:delete', 'systemtags', 'restrict_creation_to_admin']))
test.afterAll(async () => await clearTags())
test.beforeEach(async ({ filesListPage, page, user }) => {
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file1.txt')
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file2.txt')
await filesListPage.open()
})
test('Cannot create tag if restriction is in place', async ({ filesListPage }) => {
const tag = crypto.randomUUID()
await createTag(tag, 'public')
await filesListPage.expectInlineTagsForFile('file1.txt', [])
await filesListPage.selectAll()
const picker = await filesListPage.openTagPickerForSelection()
// When restricted, the input label changes and create/color buttons are absent
await expect(picker.getByLabel('Search or create tag')).toHaveCount(0)
await expect(picker.getByLabel('Search tag')).toBeVisible()
await picker.getByLabel('Search tag').fill(crypto.randomUUID())
await expect(picker.getByRole('button', { name: /Create new tag/i })).toHaveCount(0)
await picker.getByLabel('Search tag').clear()
await picker.getByLabel('Search tag').fill(tag)
await expect(picker.getByRole('checkbox')).toHaveCount(1)
await expect(picker.getByRole('button', { name: /Create new tag/i })).toHaveCount(0)
await expect(picker.getByRole('button', { name: 'Change tag color' })).toHaveCount(0)
// Can still assign the existing admin-created tag
await picker.getByRole('checkbox', { name: tag }).click({ force: true })
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag])
})

View file

@ -6,19 +6,17 @@
import { expect } from '@playwright/test'
import { runOcc } from '@nextcloud/e2e-test-server/docker'
import { test } from '../../support/fixtures/admin-session.ts'
import { createTag, deleteTag, listTags } from '../../support/utils/systemtags.ts'
const tagName = 'foo'
const updatedTagName = 'bar'
test.describe('System tags admin settings', () => {
// Tests are sequential: update depends on create, delete depends on update
test.describe.configure({ mode: 'serial' })
test.beforeAll(async () => {
// Delete all existing tags so each test run starts from a clean state
const output = await runOcc(['tag:list', '--output=json'])
const tags = JSON.parse(output) as Record<string, unknown>
await Promise.all(Object.keys(tags).map((id) => runOcc(['tag:delete', id]).catch(() => {})))
test.beforeEach(async () => {
const tags = await listTags()
for (const tag of tags) {
await deleteTag(tag.id)
}
})
test('Can create a tag', async ({ page }) => {
@ -44,6 +42,8 @@ test.describe('System tags admin settings', () => {
})
test('Can update a tag', async ({ page }) => {
const tag = await createTag(tagName)
await page.goto('settings/admin/server')
await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded()
@ -69,24 +69,25 @@ test.describe('System tags admin settings', () => {
await page.getByRole('button', { name: 'Update' }).click()
expect((await updateResponse).status()).toBe(207)
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
// NcEllipsisedOption splits names ≥ 10 chars across two spans, breaking the accessible name.
// "bar (invisible)" (15 chars) splits at position 8 → accessible name "bar (inv isible)".
// Use filter({ hasText }) to match on text content instead of the exact accessible name.
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
await expect(page.getByRole('option').filter({ hasText: updatedTagName })).toBeVisible()
})
test('Can delete a tag', async ({ page }) => {
await createTag(tagName)
await page.goto('settings/admin/server')
await page.getByRole('heading', { name: 'Collaborative tags' }).scrollIntoViewIfNeeded()
// Select the invisible tag to delete
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
await page.getByRole('option').filter({ hasText: updatedTagName }).click()
await page.getByRole('option').filter({ hasText: tagName }).click()
// Verify the form reflects the selected tag
await expect(page.getByLabel('Tag name')).toHaveValue(updatedTagName)
await expect(page.locator('.system-tag-form__group:has(#system-tag-level) .vs__selected')).toContainText('Invisible')
await expect(page.getByLabel('Tag name')).toHaveValue(tagName)
const deleteResponse = page.waitForResponse(
(r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'DELETE',
@ -96,6 +97,6 @@ test.describe('System tags admin settings', () => {
// Verify the tag is gone from the dropdown
await page.getByRole('combobox', { name: 'Search for a tag to edit' }).click()
await expect(page.getByRole('option').filter({ hasText: updatedTagName })).not.toBeVisible()
await expect(page.getByRole('option').filter({ hasText: tagName })).not.toBeVisible()
})
})

View file

@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { test as baseTest } from '../../support/fixtures/systemtags-files-page.ts'
import { uploadContent } from '../../support/utils/dav.ts'
import { assignTagsToFile, clearTags, createTag } from '../../support/utils/systemtags.ts'
// Extends the base fixture with per-test file IDs so tests in parallel each get
// their own isolated file IDs rather than sharing module-level mutable state.
const test = baseTest.extend<{ fileIds: [string, string] }>({
fileIds: [async ({ page, user, filesListPage }, use) => {
const fileId1 = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file1.txt')
const fileId2 = await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file2.txt')
await filesListPage.open()
await use([fileId1, fileId2])
}, { auto: true }],
})
test.describe('Systemtags: Files bulk action', () => {
test.afterAll(async () => await clearTags())
test.beforeEach(async ({ fileIds }) => {
console.debug('Created files with IDs', fileIds)
})
test('Can assign tag to selection', async ({ filesListPage, fileIds }) => {
const tag = crypto.randomUUID()
await filesListPage.expectInlineTagsForFile('file1.txt', [])
await filesListPage.expectInlineTagsForFile('file2.txt', [])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.createNewTagInPicker(tag)
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag])
})
test('Can assign multiple tags to selection', async ({ filesListPage }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
await filesListPage.expectInlineTagsForFile('file1.txt', [])
await filesListPage.expectInlineTagsForFile('file2.txt', [])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.createNewTagInPicker(tag1)
await filesListPage.createNewTagInPicker(tag2)
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2])
})
test('Can remove tag from selection', async ({ filesListPage, page, fileIds }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
await assignTagsToFile(fileIds[0], [tag1, tag2])
await assignTagsToFile(fileIds[1], [tag1, tag2])
await page.reload()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 })
.uncheck({ force: true })
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1])
})
test('Can remove multiple tags from selection', async ({ filesListPage, page, fileIds }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
const tag3 = crypto.randomUUID()
await assignTagsToFile(fileIds[0], [tag1, tag2, tag3])
await assignTagsToFile(fileIds[1], [tag1, tag2, tag3])
await page.reload()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2, tag3])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2, tag3])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 })
.uncheck({ force: true })
await filesListPage.getTagPicker().getByRole('checkbox', { name: tag3 })
.uncheck({ force: true })
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1])
})
test('Can assign and remove multiple tags', async ({ filesListPage, page, fileIds }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
const tag3 = crypto.randomUUID()
await assignTagsToFile(fileIds[0], [tag1, tag2])
await assignTagsToFile(fileIds[1], [tag1, tag2])
await page.reload()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag2])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag2])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 })
.scrollIntoViewIfNeeded()
await filesListPage.getTagPicker().getByRole('checkbox', { name: tag2 })
.uncheck({ force: true })
await filesListPage.createNewTagInPicker(tag3)
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag1, tag3])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag1, tag3])
})
test('Can search for tags with insensitive case', async ({ filesListPage }) => {
const tag = crypto.randomUUID().toLowerCase()
await createTag(tag, 'public')
await filesListPage.expectInlineTagsForFile('file1.txt', [])
await filesListPage.expectInlineTagsForFile('file2.txt', [])
await filesListPage.selectRowForFile('file1.txt')
await filesListPage.selectRowForFile('file2.txt')
await filesListPage.openTagPickerForSelection()
await filesListPage.selectTagInPicker(tag.toUpperCase())
await filesListPage.applyTagPicker()
await filesListPage.expectInlineTagsForFile('file1.txt', [tag])
await filesListPage.expectInlineTagsForFile('file2.txt', [tag])
})
})

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { test, expect } from '../../support/fixtures/systemtags-files-page.ts'
import { uploadContent } from '../../support/utils/dav.ts'
import { clearTags } from '../../support/utils/systemtags.ts'
test.describe('Systemtags: Files integration', () => {
test.afterAll(async () => await clearTags())
test.beforeEach(async ({ page, user, filesListPage }) => {
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
await filesListPage.open()
})
test('See first assigned tag in the file list', async ({ page, filesListPage }) => {
const tag = crypto.randomUUID()
await filesListPage.openTagPickerForFile('file.txt')
await filesListPage.createNewTagInPicker(tag)
await filesListPage.applyTagPicker()
await page.reload()
const tagList = filesListPage.getInlineTagsForFile('file.txt')
await expect(tagList.getByRole('listitem')).toHaveCount(1)
await expect(tagList.getByRole('listitem')).toBeVisible()
await expect(tagList.getByRole('listitem')).toContainText(tag)
})
test('See two assigned tags are also shown in the file list', async ({ page, filesListPage }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
await filesListPage.openTagPickerForFile('file.txt')
await filesListPage.createNewTagInPicker(tag1)
await filesListPage.createNewTagInPicker(tag2)
await filesListPage.applyTagPicker()
await page.reload()
const tagList = filesListPage.getInlineTagsForFile('file.txt')
// 2 tags, no overflow — both li elements are visible
await expect(tagList.locator('li')).toHaveCount(2)
await expect(tagList).toContainText(tag1)
await expect(tagList).toContainText(tag2)
})
test('See three assigned tags result in overflow entry', async ({ page, filesListPage }) => {
const tag1 = crypto.randomUUID()
const tag2 = crypto.randomUUID()
const tag3 = crypto.randomUUID()
await filesListPage.openTagPickerForFile('file.txt')
await filesListPage.createNewTagInPicker(tag1)
await filesListPage.createNewTagInPicker(tag2)
await filesListPage.createNewTagInPicker(tag3)
await filesListPage.applyTagPicker()
await page.reload()
const tagList = filesListPage.getInlineTagsForFile('file.txt')
// 3 tags with overflow: 1 visible + "+2" (aria-hidden, role=presentation) + 2 hidden-visually = 4 li elements
await expect(tagList.locator('li')).toHaveCount(4)
// First li is the visible tag; second li is the aria-hidden overflow indicator
await expect(tagList.locator('li').first()).toBeVisible()
await expect(tagList.locator('li').nth(1)).toContainText('+2')
// All 3 tag names are present in the list (1 visible, 2 hidden-visually)
await expect(tagList).toContainText(tag1)
await expect(tagList).toContainText(tag2)
await expect(tagList).toContainText(tag3)
})
})

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { test, expect } from '../../support/fixtures/systemtags-files-page.ts'
import { uploadContent } from '../../support/utils/dav.ts'
import { clearTags } from '../../support/utils/systemtags.ts'
test.describe('Systemtags: Files sidebar integration', () => {
test.afterAll(async () => await clearTags())
test.beforeEach(async ({ page, user, filesListPage }) => {
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
await filesListPage.open()
})
test('Can assign tags using the sidebar', async ({ filesListPage, filesSidebar }) => {
const tag = crypto.randomUUID()
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
// Open the file details sidebar
await filesListPage.triggerActionForFile('file.txt', 'details')
await expect(filesSidebar.sidebar()).toBeVisible()
// Open the sidebar's Actions menu and click "Add tags"
await filesSidebar.triggerAction('Add tags')
// Create and apply the new tag via the picker
await expect(filesListPage.getTagPicker()).toBeVisible()
await filesListPage.createNewTagInPicker(tag)
await filesListPage.applyTagPicker()
})
})

View file

@ -0,0 +1,43 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { test, expect } from '../../support/fixtures/systemtags-files-page.ts'
import { mkdir, uploadContent } from '../../support/utils/dav.ts'
import { clearTags } from '../../support/utils/systemtags.ts'
test.describe('Systemtags: Files view', () => {
test.afterAll(async () => await clearTags())
test.beforeEach(async ({ page, user, filesListPage }) => {
await mkdir(page.request, user, '/folder')
await uploadContent(page.request, user, Buffer.alloc(0), 'text/plain', '/file.txt')
await filesListPage.open()
})
test('See first assigned tag in the file list', async ({ page, filesListPage }) => {
const tag = crypto.randomUUID()
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
await expect(filesListPage.getRowForFile('file.txt')).toBeVisible()
// Assign tag to the folder via the Manage Tags picker
await filesListPage.openTagPickerForFile('folder')
await filesListPage.createNewTagInPicker(tag)
await filesListPage.applyTagPicker()
// Navigate to the tags view
await page.goto('apps/files/tags')
await expect(filesListPage.getRowForFile('folder')).not.toBeVisible()
await expect(filesListPage.getRowForFile('file.txt')).not.toBeVisible()
// The tag should appear as a cell in the tags list view
await expect(page.getByRole('cell', { name: tag })).toBeVisible()
await page.getByRole('cell', { name: tag }).click()
// Only the folder (tagged) is shown; file.txt (untagged) is absent
await expect(filesListPage.getRowForFile('folder')).toBeVisible()
await expect(filesListPage.getRowForFile('file.txt')).not.toBeVisible()
})
})

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { test as filesTest } from './files-page.ts'
import { SystemTagsFilesListPage } from '../sections/SystemTagsFilesListPage.ts'
type SystemTagsFixtures = {
filesListPage: SystemTagsFilesListPage
}
/**
* Extends the base files-page fixture by replacing `filesListPage` with a
* {@link SystemTagsFilesListPage}, which adds SystemTagPicker actions and
* inline-tags assertion helpers on top of the standard file list interactions.
*/
export const test = filesTest.extend<SystemTagsFixtures>({
filesListPage: async ({ page }, use) => {
await use(new SystemTagsFilesListPage(page))
},
})
export { expect } from '../matchers.ts'

View file

@ -6,7 +6,7 @@
import type { Locator, Page } from '@playwright/test'
export class FilesListPage {
constructor(private readonly page: Page) {}
constructor(protected readonly page: Page) {}
/**
* Open the files app. Pass a view id (e.g. 'recent') to open that view

View file

@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import { FilesListPage } from './FilesListPage.ts'
/**
* Extension of {@link FilesListPage} for tests involving the SystemTags app.
* Adds locators, actions, and assertion helpers for the SystemTagPicker dialog
* and the inline collaborative-tags column.
*/
export class SystemTagsFilesListPage extends FilesListPage {
/**
* The "Manage tags" dialog (SystemTagPicker).
*/
getTagPicker(): Locator {
return this.page.getByRole('dialog', { name: 'Manage tags' })
}
/**
* The inline collaborative-tags list rendered in the file row.
* The overflow indicator (e.g. "+2") has role="presentation" and is excluded
* by getByRole('listitem'), so the accessible listitem count always equals the
* number of actual tags regardless of whether an overflow indicator is shown.
*/
getInlineTagsForFile(filename: string): Locator {
return this.getRowForFile(filename).getByRole('list', { name: /collaborative tags/i })
}
/**
* Opens the SystemTagPicker for a single file via its row action and waits
* for the tags PROPFIND to complete before returning the picker locator.
*/
async openTagPickerForFile(filename: string): Promise<Locator> {
const tagsListLoaded = this.page.waitForResponse(
(r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'PROPFIND' && !r.url().includes('/files'),
)
await this.triggerActionForFile(filename, 'systemtags:bulk')
await tagsListLoaded
const picker = this.getTagPicker()
await picker.waitFor({ state: 'visible' })
return picker
}
/**
* Opens the SystemTagPicker for the current selection via the selection
* actions bar and waits for the tags PROPFIND to complete before returning
* the picker locator.
*/
async openTagPickerForSelection(): Promise<Locator> {
const tagsListLoaded = this.page.waitForResponse(
(r) => r.url().includes('/remote.php/dav/systemtags/') && r.request().method() === 'PROPFIND' && !r.url().includes('/files'),
)
await this.triggerSelectionAction('systemtags:bulk')
await tagsListLoaded
const picker = this.getTagPicker()
await picker.waitFor({ state: 'visible' })
return picker
}
/**
* Types a new tag name into the already-open picker, asserts no existing match,
* creates the tag via the "Create new tag" button, verifies it is checked.
*/
async createNewTagInPicker(tagName: string): Promise<void> {
const picker = this.getTagPicker()
await picker.waitFor({ state: 'visible' })
await picker.getByLabel(/Search.*tag/i).fill(tagName)
await expect(picker.getByRole('checkbox')).toHaveCount(0)
const createTagResponse = this.page.waitForResponse(
(r) => r.url().includes('/remote.php/dav/systemtags') && !r.url().includes('/files') && r.request().method() === 'POST',
)
await picker.getByRole('button', { name: /Create new tag/i }).click()
await createTagResponse
await expect(picker.getByRole('checkbox', { name: tagName })).toBeChecked()
}
async selectTagInPicker(tagName: string): Promise<void> {
const picker = this.getTagPicker()
await picker.waitFor({ state: 'visible' })
await picker.getByLabel(/Search.*tag/i).fill(tagName)
await expect(picker.getByRole('checkbox', { name: new RegExp(tagName, 'i') })).toHaveCount(1)
await picker.getByRole('checkbox', { name: new RegExp(tagName, 'i') }).check({ force: true })
}
/**
* Clicks Apply on the already-open picker and waits for the dialog to close.
*/
async applyTagPicker(): Promise<void> {
const picker = this.getTagPicker()
await picker.getByRole('button', { name: 'Apply' }).click()
await expect(picker).not.toBeVisible({ timeout: 10_000 })
}
/**
* Asserts that the inline tag list for a file shows exactly the expected tags.
*/
async expectInlineTagsForFile(filename: string, expectedTags: string[]): Promise<void> {
const tagList = this.getInlineTagsForFile(filename)
if (expectedTags.length === 0) {
await expect(tagList).toHaveCount(0)
return
}
await expect(tagList.getByRole('listitem')).toHaveCount(expectedTags.length)
for (const tag of expectedTags) {
await expect(tagList).toContainText(tag)
}
}
}

View file

@ -40,7 +40,7 @@ export async function uploadContent(
content: Buffer | string,
mimeType: string,
path: string,
): Promise<number> {
): Promise<string> {
const requesttoken = await getRequestToken(request)
const response = await request.fetch(davUrl(user, path), {
method: 'PUT',
@ -54,7 +54,7 @@ export async function uploadContent(
throw new Error(`PUT ${path} failed with status ${response.status()}`)
}
const fileId = response.headers()['oc-fileid']
return fileId ? parseInt(fileId, 10) : 0
return fileId ? String(parseInt(fileId, 10)) : '0'
}
/**

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { runOcc } from '@nextcloud/e2e-test-server'
export interface OccSystemTag {
id: string
name: string
access: 'public' | 'restricted' | 'invisible'
}
/**
* Create a system tag via OCC
*
* @param name - The tag to create
* @param access - The access level
*/
export async function createTag(name: string, access: 'public' | 'restricted' | 'invisible' = 'public'): Promise<OccSystemTag> {
const result = await runOcc(['tag:add', '--output=json', '--', name, access])
return JSON.parse(result) as OccSystemTag
}
/**
* Delete a system tag via OCC
*
* @param id - The id of the tag to delete
*/
export async function deleteTag(id: string): Promise<void> {
await runOcc(['tag:delete', id])
}
/**
* List all system tags via OCC
*/
export async function listTags(): Promise<OccSystemTag[]> {
const output = await runOcc(['tag:list', '--output=json'])
const json = JSON.parse(output) as Record<string, Omit<OccSystemTag, 'id'>>
return Object.entries(json).map(([id, value]) => ({ ...value, id }))
}
/**
* Delete all existing tags via OCC.
*/
export async function clearTags(): Promise<void> {
const tags = await listTags()
for (const tag of tags) {
await deleteTag(tag.id)
}
}
/**
* Assign tags to a file via OCC.
*
* @param fileId - The ID of the file to assign tags to
* @param tags - An array of tag names to assign to the file
*/
export async function assignTagsToFile(fileId: string, tags: string[]): Promise<void> {
await runOcc(['tag:files:add', fileId, tags.join(','), 'public'])
}