diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index bfa1895f339..cf2ed977b1f 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -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: diff --git a/cypress/e2e/systemtags/files-bulk-action.cy.ts b/cypress/e2e/systemtags/files-bulk-action.cy.ts deleted file mode 100644 index 491b551a09b..00000000000 --- a/cypress/e2e/systemtags/files-bulk-action.cy.ts +++ /dev/null @@ -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 -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') -} diff --git a/cypress/e2e/systemtags/files-inline-action.cy.ts b/cypress/e2e/systemtags/files-inline-action.cy.ts deleted file mode 100644 index a3225671a23..00000000000 --- a/cypress/e2e/systemtags/files-inline-action.cy.ts +++ /dev/null @@ -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]) - }) - }) -}) diff --git a/cypress/e2e/systemtags/files-sidebar.cy.ts b/cypress/e2e/systemtags/files-sidebar.cy.ts deleted file mode 100644 index fd9fd797e17..00000000000 --- a/cypress/e2e/systemtags/files-sidebar.cy.ts +++ /dev/null @@ -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) - }) -}) diff --git a/cypress/e2e/systemtags/files-view.cy.ts b/cypress/e2e/systemtags/files-view.cy.ts deleted file mode 100644 index 46b724fcde5..00000000000 --- a/cypress/e2e/systemtags/files-view.cy.ts +++ /dev/null @@ -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') - }) - }) -}) diff --git a/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts b/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts new file mode 100644 index 00000000000..4e3a2c62d47 --- /dev/null +++ b/tests/playwright/e2e/systemtags/admin-settings-restrictions.spec.ts @@ -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]) +}) \ No newline at end of file diff --git a/tests/playwright/e2e/systemtags/admin-settings.spec.ts b/tests/playwright/e2e/systemtags/admin-settings.spec.ts index a79322d5eed..a2bac5db800 100644 --- a/tests/playwright/e2e/systemtags/admin-settings.spec.ts +++ b/tests/playwright/e2e/systemtags/admin-settings.spec.ts @@ -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 - 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() }) }) diff --git a/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts new file mode 100644 index 00000000000..127b60494b9 --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-bulk-action.spec.ts @@ -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]) + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-inline-action.spec.ts b/tests/playwright/e2e/systemtags/files-inline-action.spec.ts new file mode 100644 index 00000000000..d82568ce9db --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-inline-action.spec.ts @@ -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) + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-sidebar.spec.ts b/tests/playwright/e2e/systemtags/files-sidebar.spec.ts new file mode 100644 index 00000000000..cd0740f47b9 --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-sidebar.spec.ts @@ -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() + }) +}) diff --git a/tests/playwright/e2e/systemtags/files-view.spec.ts b/tests/playwright/e2e/systemtags/files-view.spec.ts new file mode 100644 index 00000000000..bcbd654b6cf --- /dev/null +++ b/tests/playwright/e2e/systemtags/files-view.spec.ts @@ -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() + }) +}) diff --git a/tests/playwright/support/fixtures/systemtags-files-page.ts b/tests/playwright/support/fixtures/systemtags-files-page.ts new file mode 100644 index 00000000000..c1afb45a4f7 --- /dev/null +++ b/tests/playwright/support/fixtures/systemtags-files-page.ts @@ -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({ + filesListPage: async ({ page }, use) => { + await use(new SystemTagsFilesListPage(page)) + }, +}) + +export { expect } from '../matchers.ts' diff --git a/tests/playwright/support/sections/FilesListPage.ts b/tests/playwright/support/sections/FilesListPage.ts index ea194deb7bc..977b1519161 100644 --- a/tests/playwright/support/sections/FilesListPage.ts +++ b/tests/playwright/support/sections/FilesListPage.ts @@ -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 diff --git a/tests/playwright/support/sections/SystemTagsFilesListPage.ts b/tests/playwright/support/sections/SystemTagsFilesListPage.ts new file mode 100644 index 00000000000..4fa4a20dde1 --- /dev/null +++ b/tests/playwright/support/sections/SystemTagsFilesListPage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/tests/playwright/support/utils/dav.ts b/tests/playwright/support/utils/dav.ts index d5d8bd6cef6..a06f5ae035a 100644 --- a/tests/playwright/support/utils/dav.ts +++ b/tests/playwright/support/utils/dav.ts @@ -40,7 +40,7 @@ export async function uploadContent( content: Buffer | string, mimeType: string, path: string, -): Promise { +): Promise { 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' } /** diff --git a/tests/playwright/support/utils/systemtags.ts b/tests/playwright/support/utils/systemtags.ts new file mode 100644 index 00000000000..b83bdaf2d28 --- /dev/null +++ b/tests/playwright/support/utils/systemtags.ts @@ -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 { + 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 { + await runOcc(['tag:delete', id]) +} + +/** + * List all system tags via OCC + */ +export async function listTags(): Promise { + const output = await runOcc(['tag:list', '--output=json']) + const json = JSON.parse(output) as Record> + return Object.entries(json).map(([id, value]) => ({ ...value, id })) +} + +/** + * Delete all existing tags via OCC. + */ +export async function clearTags(): Promise { + 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 { + await runOcc(['tag:files:add', fileId, tags.join(','), 'public']) +} \ No newline at end of file