From 08c3e7ee4eaa22cfdcfea760010f1f2f1be16c1b Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 05:25:49 +0100 Subject: [PATCH 01/10] test(cy): switchPageMode -> switchTo{Edit,View}Mode Signed-off-by: Max --- cypress/e2e/page-details.spec.js | 5 ++--- cypress/e2e/page-list.spec.js | 2 +- cypress/e2e/pages-links.spec.js | 3 +-- cypress/e2e/pages.spec.js | 5 ++--- cypress/support/commands.js | 34 +++++++++++++++----------------- 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/cypress/e2e/page-details.spec.js b/cypress/e2e/page-details.spec.js index ffddbabe5..34249b501 100644 --- a/cypress/e2e/page-details.spec.js +++ b/cypress/e2e/page-details.spec.js @@ -55,8 +55,7 @@ describe('Page details', function() { .find('.editor--toc .editor--toc__item') .should('contain', 'Second-Level Heading') - // Switch to edit mode - cy.switchPageMode(1) + cy.switchToEditMode() cy.getEditor() .find('.editor--toc .editor--toc__item') @@ -68,7 +67,7 @@ describe('Page details', function() { .click() // Switch back to view mode - cy.switchPageMode(0) + cy.switchToViewMode() cy.get('.editor--toc') .should('not.exist') diff --git a/cypress/e2e/page-list.spec.js b/cypress/e2e/page-list.spec.js index 7a641d0e7..24fa8df5c 100644 --- a/cypress/e2e/page-list.spec.js +++ b/cypress/e2e/page-list.spec.js @@ -257,7 +257,7 @@ describe('Page list', function() { cy.getEditor() .should('be.visible') .type('text') - cy.switchPageMode(0) + cy.switchToViewMode() // Trash page cy.openPageMenu('Day 1') diff --git a/cypress/e2e/pages-links.spec.js b/cypress/e2e/pages-links.spec.js index 11ee56033..f00b22a4c 100644 --- a/cypress/e2e/pages-links.spec.js +++ b/cypress/e2e/pages-links.spec.js @@ -103,8 +103,7 @@ describe('Page Link Handling', function() { const clickLink = function(href, edit) { if (edit) { - // Change to edit mode - cy.switchPageMode(1) + cy.switchToEditMode() cy.getEditor() .should('be.visible') .find(`a[href="${href}"]`) diff --git a/cypress/e2e/pages.spec.js b/cypress/e2e/pages.spec.js index 60be285a9..982501bd1 100644 --- a/cypress/e2e/pages.spec.js +++ b/cypress/e2e/pages.spec.js @@ -188,7 +188,7 @@ describe('Page', function() { cy.wait(1000) // eslint-disable-line cypress/no-unnecessary-waiting // Switch back to view mode - cy.switchPageMode(0) + cy.switchToViewMode() cy.getEditor() .should('not.be.visible') @@ -205,8 +205,7 @@ describe('Page', function() { it('Lists attachments for the page and allows restore', function() { cy.openPage('Day 1') - // Switch to edit mode - cy.switchPageMode(1) + cy.switchToEditMode() // Open attachment list cy.get('button.action-item .icon-menu-sidebar').click() diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 6f72da7d5..62c23ccb1 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -62,24 +62,22 @@ Cypress.Commands.add('getReadOnlyEditor', () => { /** * Switch page mode to view or edit */ -Cypress.Commands.add('switchPageMode', (pageMode) => { - if (pageMode === 0) { - cy.log('Switch to view mode') - cy.get('button.titleform-button') - .should('contain', 'Done') - .click() - cy.getReadOnlyEditor() - .should('be.visible') - } else if (pageMode === 1) { - cy.log('Switch to edit mode') - cy.get('button.titleform-button') - .should('contain', 'Edit') - .click() - cy.getEditor() - .should('be.visible') - } else { - throw new Error(`Unknown page mode: ${pageMode}`) - } +Cypress.Commands.add('switchToViewMode', () => { + cy.log('Switch to view mode') + cy.get('button.titleform-button') + .should('contain', 'Done') + .click() + cy.getReadOnlyEditor() + .should('be.visible') +}) + +Cypress.Commands.add('switchToEditMode', () => { + cy.log('Switch to edit mode') + cy.get('button.titleform-button') + .should('contain', 'Edit') + .click() + cy.getEditor() + .should('be.visible') }) /** From a81bc1607236f5bb12c3f94d4d73a5e8e6c2771d Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 06:00:52 +0100 Subject: [PATCH 02/10] test(cy): drop unused cy.seedCollective Remove the handling of an existing collective during creation as we always delete the collective first. Signed-off-by: Max --- cypress/support/commands.js | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 62c23ccb1..e56c64ae6 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -112,36 +112,19 @@ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { }) /** - * First delete, then seed a collective (to start fresh) + * Create a fresh collective for use in the test + * + * If the collective already existed it will be deleted first. */ Cypress.Commands.add('deleteAndSeedCollective', (name) => { cy.deleteCollective(name) - cy.seedCollective(name) -}) - -/** - * Create a collective if it doesn't exist - */ -Cypress.Commands.add('seedCollective', (name) => { cy.log(`Seeding collective ${name}`) cy.window() .its('app') .then(async app => { await app.$store.dispatch(NEW_COLLECTIVE, { name }) - .catch(e => { - if (e.request && e.request.status === 422) { - // The collective already existed... carry on. - } else { - throw e - } - }) const updatedCollectivePath = app.$store.getters.updatedCollectivePath - if (updatedCollectivePath) { - app.$router.push(updatedCollectivePath) - } else { - // Fallback - if collective exists, updatedCollectivePath is undefined - app.$router.push(`/${name}`) - } + app.$router.push(updatedCollectivePath) }) // Make sure new collective is loaded cy.get('#titleform input').should('have.value', name) From 5fb44a89890b330b21c947c337357c8a4cc9f6ac Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 05:41:13 +0100 Subject: [PATCH 03/10] test(cypress): Rely on @nextcloud/axios to fetch requesttoken `@nextcloud/axios` deals with request tokens already. If the initial request fails with a 412 it will get a request token and retry: https://github.com/nextcloud-libraries/nextcloud-axios/blob/master/lib/interceptors/csrf-token.ts The additional request is fast so we can reduce the complexity of our test here. Signed-off-by: Max --- cypress/support/commands.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e56c64ae6..5cd1c35a9 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -86,29 +86,21 @@ Cypress.Commands.add('switchToEditMode', () => { Cypress.Commands.add('enableApp', appName => cy.setAppEnabled(appName)) Cypress.Commands.add('disableApp', appName => cy.setAppEnabled(appName, false)) Cypress.Commands.add('setAppEnabled', (appName, value = true) => { - cy.request('/csrftoken').then(({ body }) => { - const requesttoken = body.token - const verb = value ? 'enable' : 'disable' - const api = `${Cypress.env('baseUrl')}/index.php/settings/apps/${verb}` - return axios.post(api, - { appIds: [appName] }, - { headers: { requesttoken } }, - ) - }) + const verb = value ? 'enable' : 'disable' + const api = `${Cypress.env('baseUrl')}/index.php/settings/apps/${verb}` + return axios.post(api, + { appIds: [appName] }, + ) }) /** * Enable dashboard widget */ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { - cy.request('/csrftoken').then(({ body }) => { - const requesttoken = body.token - const api = `${Cypress.env('baseUrl')}/index.php/apps/dashboard/layout` - return axios.post(api, - { layout: widgetName }, - { headers: { requesttoken } }, - ) - }) + const api = `${Cypress.env('baseUrl')}/index.php/apps/dashboard/layout` + return axios.post(api, + { layout: widgetName }, + ) }) /** From b12e3bf19e0797d90c89f4cd1d3e4ae78bea25ea Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 05:43:26 +0100 Subject: [PATCH 04/10] test(cy): rely on @nextcloud/axios for requesttoken headers Signed-off-by: Max --- cypress/support/commands.js | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5cd1c35a9..f5f45ebb5 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -222,22 +222,15 @@ Cypress.Commands.add('seedPage', (name, parentFilePath, parentFileName) => { * Upload a file */ Cypress.Commands.add('uploadFile', (path, mimeType, remotePath = '') => { + Cypress.log() // Get fixture return cy.fixture(path, 'base64').then(file => { // convert the base64 string to a blob const blob = Cypress.Blob.base64StringToBlob(file, mimeType) try { const file = new File([blob], path, { type: mimeType }) - return cy.window() - .its('app') - .then(async app => { - const response = await axios.put(`${Cypress.env('baseUrl')}/remote.php/webdav/${remotePath}${path}`, file, { - headers: { - requesttoken: app.OC.requestToken, - 'Content-Type': mimeType, - }, - }) - cy.log(`Uploaded file to ${remotePath}${path}`) + return cy.uploadContent(remotePath + path, file, mimeType) + .then(response => { const ocFileId = response.headers['oc-fileid'] const fileId = parseInt(ocFileId.substring(0, ocFileId.indexOf('oc'))) return fileId @@ -254,13 +247,22 @@ Cypress.Commands.add('uploadFile', (path, mimeType, remotePath = '') => { */ Cypress.Commands.add('seedPageContent', (pagePath, content) => { cy.log(`Seeding collective page content for ${pagePath}`) + cy.uploadContent(`Collectives/${pagePath}`, content) +}) + +/** + * Generic upload of content - used by seedPageContent and uploadPage + */ +Cypress.Commands.add('uploadContent', (path, content, mimetype = 'text/markdown') => { + // @nextcloud/axios automatic handling for request tokens does not work for webdav cy.window() - .its('app') - .then(async app => { - await axios.put(`${Cypress.env('baseUrl')}/remote.php/webdav/Collectives/${pagePath}`, content, { + .its('app.OC.requestToken') + .then(requesttoken => { + const url = `${Cypress.env('baseUrl')}/remote.php/webdav/${path}` + return axios.put(url, content, { headers: { - requesttoken: app.OC.requestToken, - 'Content-Type': 'text/markdown', + requesttoken, + 'Content-Type': mimetype, }, }) }) @@ -281,7 +283,6 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { if (!circle) { const response = await axios.post(api, { name, personal: false, local: true }, - { headers: { requesttoken: app.OC.requestToken } }, ) circleId = response.data.ocs.data.id } else { @@ -298,7 +299,6 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { .reduce((sum, [k, v]) => sum + v, 0) await axios.put(`${api}/${circleId}/config`, { value }, - { headers: { requesttoken: app.OC.requestToken } }, ) } }) @@ -318,7 +318,6 @@ Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { const api = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` const response = await axios.post(api, { userId, type }, - { headers: { requesttoken: app.OC.requestToken } }, ).catch(e => { if (e.request && e.request.status === 400) { // The member already got added... carry on. @@ -332,7 +331,6 @@ Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { cy.log(`Setting circle ${name} member ${userId} level to ${level}`) await axios.put(`${api}/${memberId}/level`, { level }, - { headers: { requesttoken: app.OC.requestToken } }, ) } }) From f6217b5fa72141c911ba7e5b9eea1bbf3fe7a3bf Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 08:42:18 +0100 Subject: [PATCH 05/10] test(cy): dispatch, store and routeTo helper commands Signed-off-by: Max --- cypress/support/commands.js | 131 +++++++++++++++++++----------------- 1 file changed, 69 insertions(+), 62 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index f5f45ebb5..e4c0bd86b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -103,6 +103,23 @@ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { ) }) +Cypress.Commands.add('store', (selector) => { + if (selector) { + cy.window().its(`app.$store.${selector}`) + } else { + cy.window().its('app.$store') + } +}) + +Cypress.Commands.add('routeTo', (path) => { + cy.window().its('app.$router').invoke('push', path) +}) + +Cypress.Commands.add('dispatch', (...args) => { + cy.store() + .invoke(...['dispatch', ...args]) +}) + /** * Create a fresh collective for use in the test * @@ -111,13 +128,9 @@ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { Cypress.Commands.add('deleteAndSeedCollective', (name) => { cy.deleteCollective(name) cy.log(`Seeding collective ${name}`) - cy.window() - .its('app') - .then(async app => { - await app.$store.dispatch(NEW_COLLECTIVE, { name }) - const updatedCollectivePath = app.$store.getters.updatedCollectivePath - app.$router.push(updatedCollectivePath) - }) + cy.dispatch(NEW_COLLECTIVE, { name }) + cy.store('getters.updatedCollectivePath') + .then(path => cy.routeTo(path)) // Make sure new collective is loaded cy.get('#titleform input').should('have.value', name) }) @@ -144,23 +157,27 @@ Cypress.Commands.add('createCollective', (name, members = []) => { * Delete a collective if it exists */ Cypress.Commands.add('deleteCollective', (name) => { - cy.window() - .its('app') - .then(async app => { - await app.$store.dispatch(GET_COLLECTIVES) - const id = app.$store.state.collectives.collectives.find(c => c.name === name)?.id + cy.dispatch(GET_COLLECTIVES) + cy.store('state.collectives.collectives') + .invoke('find', c => c.name === name) + .then(collective => collective?.id) + .then(id => { if (id) { cy.log(`Deleting collective ${name} (id ${id})`) - await app.$store.dispatch(TRASH_COLLECTIVE, { id }) - return await app.$store.dispatch(DELETE_COLLECTIVE, { id, circle: true }) - } - - // Try to find and delete collective from trash - await app.$store.dispatch(GET_TRASH_COLLECTIVES) - const trashId = app.$store.state.collectives.trashCollectives.find(c => c.name === name)?.id - if (trashId) { - cy.log(`Deleting trashed collective ${name} (id ${trashId})`) - return await app.$store.dispatch(DELETE_COLLECTIVE, { id: trashId, circle: true }) + cy.dispatch(TRASH_COLLECTIVE, { id }) + cy.dispatch(DELETE_COLLECTIVE, { id, circle: true }) + } else { + // Try to find and delete collective from trash + cy.dispatch(GET_TRASH_COLLECTIVES) + cy.store('state.collectives.collectives') + .invoke('find', c => c.name === name) + .then(collective => collective?.id) + .then(trashId => { + if (trashId) { + cy.log(`Deleting trashed collective ${name} (id ${trashId})`) + cy.dispatch(DELETE_COLLECTIVE, { id: trashId, circle: true }) + } + }) } }) }) @@ -169,17 +186,13 @@ Cypress.Commands.add('deleteCollective', (name) => { * Change permission settings for a collective */ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { + const action = (type === 'edit') + ? UPDATE_COLLECTIVE_EDIT_PERMISSIONS + : UPDATE_COLLECTIVE_SHARE_PERMISSIONS cy.log(`Seeding collective permissions for ${name}`) - cy.window() - .its('app') - .then(async app => { - const id = app.$store.state.collectives.collectives.find(c => c.name === name).id - if (type === 'edit') { - await app.$store.dispatch(UPDATE_COLLECTIVE_EDIT_PERMISSIONS, { id, level }) - } else if (type === 'share') { - await app.$store.dispatch(UPDATE_COLLECTIVE_SHARE_PERMISSIONS, { id, level }) - } - }) + cy.store('state.collectives.collectives') + .invoke('find', c => c.name === name) + .then(({ id }) => cy.dispatch(action, { id, level })) }) /** @@ -187,12 +200,9 @@ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { */ Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { cy.log(`Seeding collective page mode for ${name}`) - cy.window() - .its('app') - .then(async app => { - const id = app.$store.state.collectives.collectives.find(c => c.name === name).id - await app.$store.dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { id, mode }) - }) + cy.store('state.collectives.collectives') + .invoke('find', c => c.name === name) + .then(({ id }) => cy.dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { id, mode })) }) /** @@ -200,21 +210,19 @@ Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { */ Cypress.Commands.add('seedPage', (name, parentFilePath, parentFileName) => { cy.log(`Seeding collective page ${name}`) - cy.window() - .its('app') - .then(async app => { - await app.$store.dispatch(GET_PAGES) - const parentPage = app.$store.state.pages.pages.find(function(p) { - return p.filePath === parentFilePath - && p.fileName === parentFileName - }) - const parentId = parentPage.id - await app.$store.dispatch(NEW_PAGE, { title: name, pagePath: name, parentId }) + cy.dispatch(GET_PAGES) + cy.store('state.pages.pages') + .invoke('find', function(p) { + return p.filePath === parentFilePath + && p.fileName === parentFileName + }) + .its('id') + .then(parentId => { + cy.dispatch(NEW_PAGE, { title: name, pagePath: name, parentId }) // Return pageId of created page - return app.$store.state.pages.pages.find(function(p) { - return p.parentId === parentId - && p.title === name - }).id + return cy.store('state.pages.pages') + .invoke('find', p => p.parentId === parentId && p.title === name) + .its('id') }) }) @@ -273,11 +281,10 @@ Cypress.Commands.add('uploadContent', (path, content, mimetype = 'text/markdown' */ Cypress.Commands.add('seedCircle', (name, config = null) => { cy.log(`Seeding circle ${name}`) - cy.window() - .its('app') - .then(async app => { - await app.$store.dispatch(GET_CIRCLES) - const circle = app.$store.state.circles.circles.find(c => c.sanitizedName === name) + cy.dispatch(GET_CIRCLES) + cy.store('state.circles.circles') + .invoke('find', c => c.sanitizedName === name) + .then(async circle => { const api = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles` let circleId if (!circle) { @@ -309,11 +316,11 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { */ Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { cy.log(`Seeding circle member ${name} of type ${type}`) - cy.window() - .its('app') - .then(async app => { - await app.$store.dispatch(GET_CIRCLES) - const circleId = app.$store.state.circles.circles.find(c => c.sanitizedName === name).id + cy.dispatch(GET_CIRCLES) + cy.store('state.circles.circles') + .invoke('find', c => c.sanitizedName === name) + .its('id') + .then(async circleId => { cy.log(`circleId: ${circleId}`) const api = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` const response = await axios.post(api, From 7dde61471566b7ebda861c891cc029e143f6fc0b Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 10:21:46 +0100 Subject: [PATCH 06/10] test(cy): better logging for store, dispatch and routeTo Signed-off-by: Max --- cypress/support/commands.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e4c0bd86b..aee6ec4d0 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -18,6 +18,7 @@ import axios from '@nextcloud/axios' const url = Cypress.config('baseUrl').replace(/\/index.php\/?$/g, '') Cypress.env('baseUrl', url) +const silent = { log: false } /** * Ignore ResizeObserver loop limit exceeded' exceptions from browser @@ -103,21 +104,30 @@ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { ) }) -Cypress.Commands.add('store', (selector) => { +Cypress.Commands.add('store', (selector, options = {}) => { if (selector) { - cy.window().its(`app.$store.${selector}`) + Cypress.log() + } + if (selector) { + cy.window(silent) + .its(`app.$store.${selector}`, silent) } else { - cy.window().its('app.$store') + cy.window(silent) + .its('app.$store', silent) } }) Cypress.Commands.add('routeTo', (path) => { - cy.window().its('app.$router').invoke('push', path) + Cypress.log() + cy.window(silent) + .its('app.$router', silent) + .invoke(silent, 'push', path) }) Cypress.Commands.add('dispatch', (...args) => { + Cypress.log() cy.store() - .invoke(...['dispatch', ...args]) + .invoke(silent, ...['dispatch', ...args]) }) /** From 8cfb99585e6022bc432a26f7315a47bf1aaf6580 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 11:10:00 +0100 Subject: [PATCH 07/10] test(cy): findBy command with nice logging `.findBy({ sanitizedName: name })` is the same as `.invoke(find, c => c.sanitizedName === name)`. But it will log the properties the item is selected by. Signed-off-by: Max --- cypress/support/commands.js | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index aee6ec4d0..b7d7bbb5d 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -130,6 +130,20 @@ Cypress.Commands.add('dispatch', (...args) => { .invoke(silent, ...['dispatch', ...args]) }) +Cypress.Commands.add('findBy', + { prevSubject: true }, + (subject, properties) => { + Cypress.log() + return subject.find(item => { + for (const key in properties) { + if (item[key] !== properties[key]) { + return false + } + } + return true + }) + }) + /** * Create a fresh collective for use in the test * @@ -169,7 +183,7 @@ Cypress.Commands.add('createCollective', (name, members = []) => { Cypress.Commands.add('deleteCollective', (name) => { cy.dispatch(GET_COLLECTIVES) cy.store('state.collectives.collectives') - .invoke('find', c => c.name === name) + .findBy({ name }) .then(collective => collective?.id) .then(id => { if (id) { @@ -179,8 +193,8 @@ Cypress.Commands.add('deleteCollective', (name) => { } else { // Try to find and delete collective from trash cy.dispatch(GET_TRASH_COLLECTIVES) - cy.store('state.collectives.collectives') - .invoke('find', c => c.name === name) + cy.store('state.collectives.trashCollectives') + .findBy({ name }) .then(collective => collective?.id) .then(trashId => { if (trashId) { @@ -201,7 +215,7 @@ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { : UPDATE_COLLECTIVE_SHARE_PERMISSIONS cy.log(`Seeding collective permissions for ${name}`) cy.store('state.collectives.collectives') - .invoke('find', c => c.name === name) + .findBy({ name }) .then(({ id }) => cy.dispatch(action, { id, level })) }) @@ -211,7 +225,7 @@ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { cy.log(`Seeding collective page mode for ${name}`) cy.store('state.collectives.collectives') - .invoke('find', c => c.name === name) + .findBy({ name }) .then(({ id }) => cy.dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { id, mode })) }) @@ -222,16 +236,13 @@ Cypress.Commands.add('seedPage', (name, parentFilePath, parentFileName) => { cy.log(`Seeding collective page ${name}`) cy.dispatch(GET_PAGES) cy.store('state.pages.pages') - .invoke('find', function(p) { - return p.filePath === parentFilePath - && p.fileName === parentFileName - }) + .findBy({ filePath: parentFilePath, fileName: parentFileName }) .its('id') .then(parentId => { cy.dispatch(NEW_PAGE, { title: name, pagePath: name, parentId }) // Return pageId of created page return cy.store('state.pages.pages') - .invoke('find', p => p.parentId === parentId && p.title === name) + .findBy({ parentId, title: name }) .its('id') }) }) @@ -293,7 +304,7 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { cy.log(`Seeding circle ${name}`) cy.dispatch(GET_CIRCLES) cy.store('state.circles.circles') - .invoke('find', c => c.sanitizedName === name) + .findBy({ sanitizedName: name }) .then(async circle => { const api = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles` let circleId @@ -328,7 +339,7 @@ Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { cy.log(`Seeding circle member ${name} of type ${type}`) cy.dispatch(GET_CIRCLES) cy.store('state.circles.circles') - .invoke('find', c => c.sanitizedName === name) + .findBy({ sanitizedName: name }) .its('id') .then(async circleId => { cy.log(`circleId: ${circleId}`) From b273a4f019e7d724ce03c078f0c3a962451d36ec Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 8 Nov 2023 13:50:35 +0100 Subject: [PATCH 08/10] test(cy): make dispatch available as child command When used as a child command expects an object to be yielded and merges it into the payload: `cy.wrap({ id: 123 }).dispatch(SOME_ACTION, { value: 'Hello' })` will dispatch `SOME_ACTION` with a payload of `{ id: 123, value: \'Hello\'}`. If null is yielded the action won't be dispatched. This is useful for cleanup commands like `deleteCollective`. Signed-off-by: Max --- cypress/support/commands.js | 77 ++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index b7d7bbb5d..f5f0792cc 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -124,11 +124,28 @@ Cypress.Commands.add('routeTo', (path) => { .invoke(silent, 'push', path) }) -Cypress.Commands.add('dispatch', (...args) => { - Cypress.log() - cy.store() - .invoke(silent, ...['dispatch', ...args]) -}) +/* + * Dispatch action + * + * When used as a child command expects an object to be yielded + * and merges it into the payload: + * `cy.wrap({ id: 123 }).dispatch(SOME_ACTION, { value: 'Hello' })` + * will dispatch `SOME_ACTION` with a payload of `{ id: 123, value: 'Hello'}`. + * + * If null is yielded the action won't be dispatched. + * This is useful for cleanup commands like `deleteCollective`. + */ +Cypress.Commands.add('dispatch', + { prevSubject: 'optional' }, + (subject, action, payload) => { + // used as a child command but null was yielded + if (subject === null) { + return + } + Cypress.log() + cy.store() + .invoke(silent, 'dispatch', action, { ...payload, ...subject }) + }) Cypress.Commands.add('findBy', { prevSubject: true }, @@ -141,7 +158,7 @@ Cypress.Commands.add('findBy', } } return true - }) + }) || null }) /** @@ -178,32 +195,18 @@ Cypress.Commands.add('createCollective', (name, members = []) => { }) /** - * Delete a collective if it exists + * Delete a collective if exists and clean it from the trash. */ Cypress.Commands.add('deleteCollective', (name) => { cy.dispatch(GET_COLLECTIVES) cy.store('state.collectives.collectives') .findBy({ name }) - .then(collective => collective?.id) - .then(id => { - if (id) { - cy.log(`Deleting collective ${name} (id ${id})`) - cy.dispatch(TRASH_COLLECTIVE, { id }) - cy.dispatch(DELETE_COLLECTIVE, { id, circle: true }) - } else { - // Try to find and delete collective from trash - cy.dispatch(GET_TRASH_COLLECTIVES) - cy.store('state.collectives.trashCollectives') - .findBy({ name }) - .then(collective => collective?.id) - .then(trashId => { - if (trashId) { - cy.log(`Deleting trashed collective ${name} (id ${trashId})`) - cy.dispatch(DELETE_COLLECTIVE, { id: trashId, circle: true }) - } - }) - } - }) + .dispatch(TRASH_COLLECTIVE) + // Try to find and delete collective from trash + cy.dispatch(GET_TRASH_COLLECTIVES) + cy.store('state.collectives.trashCollectives') + .findBy({ name }) + .dispatch(DELETE_COLLECTIVE, { circle: true }) }) /** @@ -216,7 +219,7 @@ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { cy.log(`Seeding collective permissions for ${name}`) cy.store('state.collectives.collectives') .findBy({ name }) - .then(({ id }) => cy.dispatch(action, { id, level })) + .dispatch(action, { level }) }) /** @@ -226,7 +229,7 @@ Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { cy.log(`Seeding collective page mode for ${name}`) cy.store('state.collectives.collectives') .findBy({ name }) - .then(({ id }) => cy.dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { id, mode })) + .dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { mode }) }) /** @@ -238,13 +241,15 @@ Cypress.Commands.add('seedPage', (name, parentFilePath, parentFileName) => { cy.store('state.pages.pages') .findBy({ filePath: parentFilePath, fileName: parentFileName }) .its('id') - .then(parentId => { - cy.dispatch(NEW_PAGE, { title: name, pagePath: name, parentId }) - // Return pageId of created page - return cy.store('state.pages.pages') - .findBy({ parentId, title: name }) - .its('id') - }) + .as('parentId') + .then(id => ({ parentId: id })) + .dispatch(NEW_PAGE, { title: name, pagePath: name }) + // Return pageId of created page + cy.get('@parentId').then(parentId => { + return cy.store('state.pages.pages') + .findBy({ parentId, title: name }) + .its('id') + }) }) /** From f27de8f381e6ec25116a9987fd1207bf6f753dd5 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 9 Nov 2023 11:27:01 +0100 Subject: [PATCH 09/10] test(cy): use Cypress.log for consistent logging Signed-off-by: Max --- cypress/e2e/collective-members.spec.js | 1 - cypress/support/commands.js | 81 +++++++++++++++----------- cypress/support/navigation.js | 7 +++ 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/cypress/e2e/collective-members.spec.js b/cypress/e2e/collective-members.spec.js index b9fad1e87..b764eae5d 100644 --- a/cypress/e2e/collective-members.spec.js +++ b/cypress/e2e/collective-members.spec.js @@ -28,7 +28,6 @@ describe('Collective members', function() { before(function() { cy.loginAs('bob') cy.visit('apps/collectives') - cy.deleteCollective('Members Collective') cy.deleteAndSeedCollective('Members Collective') }) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index f5f0792cc..adc5b55be 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -64,7 +64,7 @@ Cypress.Commands.add('getReadOnlyEditor', () => { * Switch page mode to view or edit */ Cypress.Commands.add('switchToViewMode', () => { - cy.log('Switch to view mode') + Cypress.log() cy.get('button.titleform-button') .should('contain', 'Done') .click() @@ -73,7 +73,7 @@ Cypress.Commands.add('switchToViewMode', () => { }) Cypress.Commands.add('switchToEditMode', () => { - cy.log('Switch to edit mode') + Cypress.log() cy.get('button.titleform-button') .should('contain', 'Edit') .click() @@ -84,8 +84,16 @@ Cypress.Commands.add('switchToEditMode', () => { /** * Enable/disable a Nextcloud app */ -Cypress.Commands.add('enableApp', appName => cy.setAppEnabled(appName)) -Cypress.Commands.add('disableApp', appName => cy.setAppEnabled(appName, false)) +Cypress.Commands.add('enableApp', appName => { + Cypress.log() + cy.setAppEnabled(appName) +}) + +Cypress.Commands.add('disableApp', appName => { + Cypress.log() + cy.setAppEnabled(appName, false) +}) + Cypress.Commands.add('setAppEnabled', (appName, value = true) => { const verb = value ? 'enable' : 'disable' const api = `${Cypress.env('baseUrl')}/index.php/settings/apps/${verb}` @@ -98,6 +106,7 @@ Cypress.Commands.add('setAppEnabled', (appName, value = true) => { * Enable dashboard widget */ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { + Cypress.log() const api = `${Cypress.env('baseUrl')}/index.php/apps/dashboard/layout` return axios.post(api, { layout: widgetName }, @@ -105,9 +114,7 @@ Cypress.Commands.add('enableDashboardWidget', (widgetName) => { }) Cypress.Commands.add('store', (selector, options = {}) => { - if (selector) { - Cypress.log() - } + Cypress.log() if (selector) { cy.window(silent) .its(`app.$store.${selector}`, silent) @@ -167,8 +174,8 @@ Cypress.Commands.add('findBy', * If the collective already existed it will be deleted first. */ Cypress.Commands.add('deleteAndSeedCollective', (name) => { + Cypress.log() cy.deleteCollective(name) - cy.log(`Seeding collective ${name}`) cy.dispatch(NEW_COLLECTIVE, { name }) cy.store('getters.updatedCollectivePath') .then(path => cy.routeTo(path)) @@ -180,7 +187,7 @@ Cypress.Commands.add('deleteAndSeedCollective', (name) => { * Create a collective via UI */ Cypress.Commands.add('createCollective', (name, members = []) => { - cy.log(`Creating collective ${name}`) + Cypress.log() cy.get('button').contains('New collective').click() cy.get('.collective-name input[type="text"]').type(`${name}{enter}`) if (members.length > 0) { @@ -213,10 +220,10 @@ Cypress.Commands.add('deleteCollective', (name) => { * Change permission settings for a collective */ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { + Cypress.log() const action = (type === 'edit') ? UPDATE_COLLECTIVE_EDIT_PERMISSIONS : UPDATE_COLLECTIVE_SHARE_PERMISSIONS - cy.log(`Seeding collective permissions for ${name}`) cy.store('state.collectives.collectives') .findBy({ name }) .dispatch(action, { level }) @@ -226,7 +233,7 @@ Cypress.Commands.add('seedCollectivePermissions', (name, type, level) => { * Change default page mode for a collective */ Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { - cy.log(`Seeding collective page mode for ${name}`) + Cypress.log() cy.store('state.collectives.collectives') .findBy({ name }) .dispatch(UPDATE_COLLECTIVE_PAGE_MODE, { mode }) @@ -236,7 +243,7 @@ Cypress.Commands.add('seedCollectivePageMode', (name, mode) => { * Add a page to a collective */ Cypress.Commands.add('seedPage', (name, parentFilePath, parentFileName) => { - cy.log(`Seeding collective page ${name}`) + Cypress.log() cy.dispatch(GET_PAGES) cy.store('state.pages.pages') .findBy({ filePath: parentFilePath, fileName: parentFileName }) @@ -280,7 +287,10 @@ Cypress.Commands.add('uploadFile', (path, mimeType, remotePath = '') => { * Upload content of a page */ Cypress.Commands.add('seedPageContent', (pagePath, content) => { - cy.log(`Seeding collective page content for ${pagePath}`) + const contentForLog = content.length > 200 + ? content.substring(0, 200) + '...' + : content + Cypress.log({ message: `${pagePath}, ${contentForLog}`}) cy.uploadContent(`Collectives/${pagePath}`, content) }) @@ -306,7 +316,7 @@ Cypress.Commands.add('uploadContent', (path, content, mimetype = 'text/markdown' * Create a circle (optionally with given config) */ Cypress.Commands.add('seedCircle', (name, config = null) => { - cy.log(`Seeding circle ${name}`) + Cypress.log() cy.dispatch(GET_CIRCLES) cy.store('state.circles.circles') .findBy({ sanitizedName: name }) @@ -341,34 +351,39 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { * Add someone to a circle */ Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { - cy.log(`Seeding circle member ${name} of type ${type}`) + Cypress.log() cy.dispatch(GET_CIRCLES) cy.store('state.circles.circles') .findBy({ sanitizedName: name }) .its('id') - .then(async circleId => { - cy.log(`circleId: ${circleId}`) - const api = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` - const response = await axios.post(api, - { userId, type }, - ).catch(e => { - if (e.request && e.request.status === 400) { - // The member already got added... carry on. - } else { - throw e - } - }) + .then(circleId => { + cy.circleAddMember(circleId, { userId, type }) + }) + .then(({ circleId, memberId }) => { if (level) { - const memberId = response.data.ocs.data.id - cy.log(memberId) - cy.log(`Setting circle ${name} member ${userId} level to ${level}`) - await axios.put(`${api}/${memberId}/level`, - { level }, - ) + cy.circleSetMemberLevel(circleId, memberId, level) } }) }) +Cypress.Commands.add('circleSetMemberLevel', (circleId, memberId, level) => { + Cypress.log() + const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` + return axios.put(`${url}/${memberId}/level`, + { level }, + ) +}) + +Cypress.Commands.add('circleAddMember', async (circleId, { userId, type }) => { + Cypress.log() + const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` + const response = await axios.post(url, + { userId, type }, + ) + const memberId = response.data.ocs.data.id + return { circleId, userId, memberId } +}) + /** * Fail the test on the initial run to check if retries work */ diff --git a/cypress/support/navigation.js b/cypress/support/navigation.js index 6719f8ffd..a02469cba 100644 --- a/cypress/support/navigation.js +++ b/cypress/support/navigation.js @@ -1,23 +1,28 @@ Cypress.Commands.add('openApp', (appName) => { + Cypress.log() cy.get(`nav.app-menu li[data-app-id="${appName}"] a`).click() }) Cypress.Commands.add('openPage', (pageName) => { + Cypress.log() cy.contains('.app-content-list-item a', pageName).click() }) Cypress.Commands.add('openPageMenu', (pageName) => { + Cypress.log() cy.contains('.app-content-list-item', pageName) .find('.action-item__menutoggle') .click({ force: true }) }) Cypress.Commands.add('openCollective', (collectiveName) => { + Cypress.log() cy.get(`.collectives_list_item a[title="${collectiveName}"]`) .click() }) Cypress.Commands.add('openCollectiveMenu', (collectiveName) => { + Cypress.log() cy.get('.collectives_list_item') .contains('li', collectiveName) .find('.action-item__menutoggle') @@ -25,6 +30,7 @@ Cypress.Commands.add('openCollectiveMenu', (collectiveName) => { }) Cypress.Commands.add('openTrashedCollectiveMenu', (collectiveName) => { + Cypress.log() cy.get('.collectives_trash_list_item') .contains('li', collectiveName) .find('.action-item__menutoggle') @@ -32,6 +38,7 @@ Cypress.Commands.add('openTrashedCollectiveMenu', (collectiveName) => { }) Cypress.Commands.add('clickMenuButton', (title) => { + Cypress.log() cy.get('button.action-button') .contains(title) .click() From ca288be30d63447e85863e7dc8f3bf9a3414ef6a Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 9 Nov 2023 12:08:11 +0100 Subject: [PATCH 10/10] test(cy): use child commands for seeding circle Signed-off-by: Max --- cypress/e2e/circle-with-group.spec.js | 4 +- cypress/e2e/collective-readonly.spec.js | 3 +- cypress/e2e/collective.spec.js | 3 +- cypress/e2e/page-landingpage.spec.js | 6 +-- cypress/support/commands.js | 53 ++++++++++++------------- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/cypress/e2e/circle-with-group.spec.js b/cypress/e2e/circle-with-group.spec.js index 3e82d2467..9fc448a37 100644 --- a/cypress/e2e/circle-with-group.spec.js +++ b/cypress/e2e/circle-with-group.spec.js @@ -34,7 +34,9 @@ describe('Pages are accessible via group membership to circle', function() { cy.loginAs('jane') cy.visit('apps/collectives') cy.deleteAndSeedCollective('Group Collective') - cy.seedCircleMember('Group Collective', 'Bobs Group', 2, 8) + cy.circleFind('Group Collective') + .circleAddMember('Bobs Group', 2) + .circleSetMemberLevel(8) }) it('Lists the collective', function() { diff --git a/cypress/e2e/collective-readonly.spec.js b/cypress/e2e/collective-readonly.spec.js index a9de3700b..afc92f4ea 100644 --- a/cypress/e2e/collective-readonly.spec.js +++ b/cypress/e2e/collective-readonly.spec.js @@ -28,7 +28,8 @@ describe('Read-only collective', function() { cy.deleteAndSeedCollective('PermissionCollective') cy.seedPage('SecondPage', '', 'Readme.md') cy.seedCollectivePermissions('PermissionCollective', 'edit', 4) - cy.seedCircleMember('PermissionCollective', 'bob') + cy.circleFind('PermissionCollective') + .circleAddMember('bob') }) describe('in read-only collective', function() { diff --git a/cypress/e2e/collective.spec.js b/cypress/e2e/collective.spec.js index f00da6a12..f7a739e37 100644 --- a/cypress/e2e/collective.spec.js +++ b/cypress/e2e/collective.spec.js @@ -34,7 +34,8 @@ describe('Collective', function() { cy.deleteCollective('History Club') cy.deleteCollective(specialCollective) cy.deleteAndSeedCollective('Preexisting Collective') - cy.seedCircleMember('Preexisting Collective', 'jane') + cy.circleFind('Preexisting Collective') + .circleAddMember('jane') cy.seedCircle('Preexisting Circle') cy.seedCircle('History Club', { visible: true, open: true }) cy.loginAs('jane') diff --git a/cypress/e2e/page-landingpage.spec.js b/cypress/e2e/page-landingpage.spec.js index 7ac5df6f0..4d92fa2db 100644 --- a/cypress/e2e/page-landingpage.spec.js +++ b/cypress/e2e/page-landingpage.spec.js @@ -31,9 +31,9 @@ describe('Page landing page', function() { cy.loginAs('bob') cy.visit('/apps/collectives') cy.deleteAndSeedCollective(collective) - cy.seedCircleMember(collective, 'alice') - cy.seedCircleMember(collective, 'jane') - cy.seedCircleMember(collective, 'john') + cy.circleFind(collective).circleAddMember('alice') + cy.circleFind(collective).circleAddMember('jane') + cy.circleFind(collective).circleAddMember('john') cy.seedPage('Page 1', '', 'Readme.md') cy.seedPage('Page 2', '', 'Readme.md') cy.seedPage('Page 3', '', 'Readme.md') diff --git a/cypress/support/commands.js b/cypress/support/commands.js index adc5b55be..128e8a118 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -290,7 +290,7 @@ Cypress.Commands.add('seedPageContent', (pagePath, content) => { const contentForLog = content.length > 200 ? content.substring(0, 200) + '...' : content - Cypress.log({ message: `${pagePath}, ${contentForLog}`}) + Cypress.log({ message: `${pagePath}, ${contentForLog}` }) cy.uploadContent(`Collectives/${pagePath}`, content) }) @@ -350,39 +350,36 @@ Cypress.Commands.add('seedCircle', (name, config = null) => { /** * Add someone to a circle */ -Cypress.Commands.add('seedCircleMember', (name, userId, type = 1, level) => { +Cypress.Commands.add('circleFind', (name) => { Cypress.log() cy.dispatch(GET_CIRCLES) - cy.store('state.circles.circles') + return cy.store('state.circles.circles') .findBy({ sanitizedName: name }) - .its('id') - .then(circleId => { - cy.circleAddMember(circleId, { userId, type }) - }) - .then(({ circleId, memberId }) => { - if (level) { - cy.circleSetMemberLevel(circleId, memberId, level) - } - }) }) -Cypress.Commands.add('circleSetMemberLevel', (circleId, memberId, level) => { - Cypress.log() - const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` - return axios.put(`${url}/${memberId}/level`, - { level }, - ) -}) +Cypress.Commands.add('circleAddMember', + { prevSubject: true }, + async ({ id }, userId, type = 1) => { + Cypress.log() + const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${id}/members` + const response = await axios.post(url, + { userId, type }, + ) + const memberId = response.data.ocs.data.id + return { circleId: id, userId, memberId } + }, +) -Cypress.Commands.add('circleAddMember', async (circleId, { userId, type }) => { - Cypress.log() - const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` - const response = await axios.post(url, - { userId, type }, - ) - const memberId = response.data.ocs.data.id - return { circleId, userId, memberId } -}) +Cypress.Commands.add('circleSetMemberLevel', + { prevSubject: true }, + ({ circleId, memberId }, level) => { + Cypress.log() + const url = `${Cypress.env('baseUrl')}/ocs/v2.php/apps/circles/circles/${circleId}/members` + return axios.put(`${url}/${memberId}/level`, + { level }, + ) + }, +) /** * Fail the test on the initial run to check if retries work