From fd0ab446c46df7147b8d6e0b652fc3be316210e5 Mon Sep 17 00:00:00 2001 From: Cassie Liu Date: Wed, 14 Jun 2023 17:14:22 -0400 Subject: [PATCH 1/2] M3-6505: Add StackScript Update/Delete E2E Tests --- .../stackscripts/create-stackscripts.spec.ts | 2 +- .../stackscripts/delete-stackscripts.spec.ts | 104 +++++++ .../smoke-stackscripts-landing-page.spec.ts | 10 +- .../stackscripts/update-stackscripts.spec.ts | 268 ++++++++++++++++++ .../support/intercepts/stackscripts.ts | 81 ++++++ 5 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts create mode 100644 packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 20eb08704e0..0cda85f7e87 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -128,7 +128,7 @@ const createLinodeAndImage = async () => { }; authenticate(); -describe('stackscripts', () => { +describe('Create stackscripts', () => { /* * - Creates a StackScript with user-defined fields. * - Confirms that an error message appears upon submitting script without a shebang. diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts new file mode 100644 index 00000000000..63a4ad7e177 --- /dev/null +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -0,0 +1,104 @@ +import { authenticate } from 'support/api/authentication'; +import { stackScriptFactory } from 'src/factories'; +import { + mockDeleteStackScript, + interceptGetStackScripts, + mockGetStackScripts, +} from 'support/intercepts/stackscripts'; +import { ui } from 'support/ui'; + +authenticate(); +describe('Delete stackscripts', () => { + /* + * - Deletes the stackscripts. + * - Confirms that the stackscript item still exist when cancelling the delete operation. + * - Confirms that the stackscript item can be deleted successfully. + * - Confirms that "Automate Deployment with StackScripts!" welcome page appears when user has no StackScript. + */ + it('deletes the stackscripts', () => { + const stackScripts = stackScriptFactory.buildList(2, { + is_public: false, + }); + interceptGetStackScripts(stackScripts).as('getStackScripts'); + cy.visitWithLogin('/stackscripts/account'); + cy.wait('@getStackScripts'); + + // Do nothing when cancelling + cy.get(`[aria-label="${stackScripts[0].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + ui.dialog + .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) + .should('be.visible') + .within(() => { + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + + cy.findByText(stackScripts[0].label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(stackScripts[0].description).should('be.visible'); + }); + + // The StackScript is deleted successfully. + cy.get(`[aria-label="${stackScripts[0].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); + mockDeleteStackScript(stackScripts[0].id).as('deleteStackScript'); + const updateStackScript = JSON.parse(JSON.stringify(stackScripts[1])); + mockGetStackScripts([updateStackScript]).as('getUpdatedStackScripts'); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + ui.dialog + .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete StackScript') + .should('be.visible') + .click(); + }); + cy.wait('@deleteStackScript'); + cy.wait('@getUpdatedStackScripts'); + + cy.findByText(stackScripts[0].label).should('not.exist'); + + // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. + cy.get(`[aria-label="${stackScripts[1].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[1].label}`) + .should('be.visible') + .click(); + }); + mockDeleteStackScript(stackScripts[1].id).as('deleteStackScript'); + mockGetStackScripts([]).as('getUpdatedStackScripts'); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + ui.dialog + .findByTitle(`Delete StackScript ${stackScripts[1].label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Delete StackScript') + .should('be.visible') + .click(); + }); + cy.wait('@deleteStackScript'); + cy.wait('@getUpdatedStackScripts'); + + cy.findByText(stackScripts[1].label).should('not.exist'); + cy.findByText('Automate deployment scripts').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index 6e878fa62e2..2df80d3f4d0 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -4,12 +4,12 @@ import { mockGetStackScripts } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; authenticate(); -describe('stackscripts', () => { +describe('Display stackscripts', () => { /* - * - Displays welcom message in the landing page. + * - Displays welcome message in the landing page. * - Confirms that "Automate Deployment with StackScripts!" welcome page appears when user has no StackScripts. */ - it('display the correct welcom message in landing page', () => { + it('displays the correct welcome message in landing page', () => { mockGetStackScripts([]).as('getStackScripts'); cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); @@ -29,7 +29,7 @@ describe('stackscripts', () => { * - Displays Account StackScripts in the landing page. * - Confirms that all the StackScripts are shown as expected. */ - it('display Account StackScripts in landing page', () => { + it('displays Account StackScripts in landing page', () => { const stackScripts = stackScriptFactory.buildList(2); mockGetStackScripts(stackScripts).as('getStackScripts'); cy.visitWithLogin('/stackscripts/account'); @@ -54,7 +54,7 @@ describe('stackscripts', () => { * - Displays Community StackScripts in the landing page. * - Confirms that Community page is not empty. */ - it('display Community StackScripts in landing page', () => { + it('displays Community StackScripts in landing page', () => { cy.visitWithLogin('/stackscripts/community'); cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts new file mode 100644 index 00000000000..0a609dc6535 --- /dev/null +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -0,0 +1,268 @@ +import { authenticate } from 'support/api/authentication'; +import { randomLabel, randomPhrase } from 'support/util/random'; +import { + interceptGetStackScript, + mockUpdateStackScript, + mockUpdateStackScriptError, + mockGetStackScripts, +} from 'support/intercepts/stackscripts'; +import { ui } from 'support/ui'; +import { stackScriptFactory } from '@src/factories'; + +// StackScript fixture paths. +const stackscriptNoShebangPath = 'stackscripts/stackscript-no-shebang.sh'; +const stackscriptUdfPath = 'stackscripts/stackscript-udf.sh'; +const stackscriptUdfInvalidPath = 'stackscripts/stackscript-udf-invalid.sh'; + +// StackScript error that is expected to appear when script is missing a shebang. +const stackScriptErrorNoShebang = + "Script must begin with a shebang (example: '#!/bin/bash')."; + +// StackScript error that is expected to appear when UDFs with non-alphanumeric names are supplied. +const stackScriptErrorUdfAlphanumeric = + 'UDF names can only contain alphanumeric and underscore characters.'; + +/** + * Fills out the StackScript edition form. + * + * This assumes that the user is already on the StackScript edition page. This + * function does not attempt to submit the filled out form. + * + * @param label - StackScript label. + * @param description - StackScript description. Optional. + * @param targetImage - StackScript target image name. + * @param script - StackScript contents. + */ +const fillOutStackscriptForm = ( + label: string, + description: string | undefined, + targetImage: string, + script: string +) => { + // Fill out "StackScript Label", "Description", "Target Images", and "Script" fields. + cy.findByLabelText(/^StackScript Label.*/) + .should('be.visible') + .click() + .clear() + .type(label); + + if (description) { + cy.findByLabelText('Description') + .should('be.visible') + .click() + .clear() + .type(description); + } + + cy.get('[data-qa-multi-select="Select an Image"]') + .should('be.visible') + .click() + .type(`${targetImage}{enter}`); + + // Insert a script with invalid UDF data. + cy.get('[data-qa-textfield-label="Script"]') + .should('be.visible') + .click() + .type(script); +}; + +authenticate(); +describe('Update stackscripts', () => { + /* + * - Updates a StackScript with user-defined fields. + * - Confirms that an error message appears upon submitting script without a shebang. + * - Confirms that an error message appears upon submitting invalid user-defined fields. + * - Confirms that the StackScript is updated successfully. + */ + it('updates a StackScript', () => { + const stackscriptLabel = randomLabel(); + const stackscriptDesc = randomPhrase(); + const stackscriptImage = 'Alpine 3.17'; + + const stackScripts = stackScriptFactory.buildList(2); + mockGetStackScripts(stackScripts).as('getStackScripts'); + cy.visitWithLogin('/stackscripts/account'); + cy.wait('@getStackScripts'); + + cy.get(`[aria-label="${stackScripts[0].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); + interceptGetStackScript(stackScripts[0].id, stackScripts[0]).as( + 'getStackScript' + ); + ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); + cy.wait('@getStackScript'); + cy.url().should('endWith', `/stackscripts/${stackScripts[0].id}/edit`); + + ui.buttonGroup + .findButtonByTitle('Save Changes') + .should('be.visible') + .should('be.disabled'); + + // Submit StackScript edit form with invalid contents, confirm error messages. + cy.fixture(stackscriptNoShebangPath).then((stackscriptWithNoShebang) => { + fillOutStackscriptForm( + stackscriptLabel, + stackscriptDesc, + stackscriptImage, + stackscriptWithNoShebang + ); + }); + + mockUpdateStackScriptError( + stackScripts[0].id, + 'script', + stackScriptErrorNoShebang + ).as('updateStackScript'); + ui.buttonGroup + .findButtonByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@updateStackScript'); + cy.findByText(stackScriptErrorNoShebang).should('be.visible'); + + // Insert a script with valid UDF data and submit StackScript edit form. + cy.fixture(stackscriptUdfInvalidPath).then((stackScriptUdfInvalid) => { + cy.get('[data-qa-textfield-label="Script"]') + .should('be.visible') + .click() + .type('{selectall}{backspace}') + .type(stackScriptUdfInvalid); + }); + + mockUpdateStackScriptError( + stackScripts[0].id, + 'script', + stackScriptErrorUdfAlphanumeric + ).as('updateStackScript'); + ui.buttonGroup + .findButtonByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@updateStackScript'); + cy.findByText(stackScriptErrorUdfAlphanumeric).should('be.visible'); + + // Insert a script with valid UDF data and submit StackScript edit form. + cy.fixture(stackscriptUdfPath).then((stackScriptUdf) => { + cy.get('[data-qa-textfield-label="Script"]') + .should('be.visible') + .click() + .type('{selectall}{backspace}') + .type(stackScriptUdf); + }); + + const updatedStackScripts = JSON.parse(JSON.stringify(stackScripts)); + updatedStackScripts[0].label = stackscriptLabel; + updatedStackScripts[0].description = stackscriptDesc; + mockGetStackScripts(updatedStackScripts).as('getStackScripts'); + mockUpdateStackScript(updatedStackScripts[0].id, updatedStackScripts[0]).as( + 'updateStackScript' + ); + ui.buttonGroup + .findButtonByTitle('Save Changes') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@updateStackScript'); + cy.url().should('endWith', '/stackscripts/account'); + cy.wait('@getStackScripts'); + + cy.findByText(stackscriptLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(stackscriptDesc).should('be.visible'); + }); + }); + + /* + * - Updates a StackScript to public. + * - Confirms that the StackScript is updated to public successfully. + */ + it('makes a StackScript public', () => { + const stackScripts = stackScriptFactory.buildList(2, { + is_public: false, + }); + mockGetStackScripts(stackScripts).as('getStackScripts'); + cy.visitWithLogin('/stackscripts/account'); + cy.wait('@getStackScripts'); + + // Do nothing when cancelling + cy.get(`[aria-label="${stackScripts[0].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); + ui.actionMenuItem + .findByTitle('Make StackScript Public') + .should('be.visible') + .click(); + ui.dialog + .findByTitle('Woah, just a word of caution...') + .should('be.visible') + .within(() => { + ui.button.findByTitle('Cancel').should('be.visible').click(); + }); + + cy.findByText(stackScripts[0].label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(stackScripts[0].description).should('be.visible'); + cy.findByText('Public').should('not.exist'); + cy.findByText('Private').should('be.visible'); + }); + + // The status of the StackScript will become public + cy.get(`[aria-label="${stackScripts[0].label}"]`) + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); + ui.actionMenuItem + .findByTitle('Make StackScript Public') + .should('be.visible') + .click(); + const updatedStackScript = JSON.parse(JSON.stringify(stackScripts[0])); + updatedStackScript.is_public = true; + mockUpdateStackScript(updatedStackScript.id, updatedStackScript).as( + 'mockUpdateStackScript' + ); + mockGetStackScripts([updatedStackScript, stackScripts[1]]).as( + 'mockGetStackScripts' + ); + ui.dialog + .findByTitle('Woah, just a word of caution...') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Yes, make me a star!') + .should('be.visible') + .click(); + }); + cy.wait('@mockUpdateStackScript'); + cy.wait('@mockGetStackScripts'); + + cy.findByText(stackScripts[0].label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(stackScripts[0].description).should('be.visible'); + cy.findByText('Private').should('not.exist'); + cy.findByText('Public').should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/stackscripts.ts b/packages/manager/cypress/support/intercepts/stackscripts.ts index d74485b8ec7..bdc3f87e81f 100644 --- a/packages/manager/cypress/support/intercepts/stackscripts.ts +++ b/packages/manager/cypress/support/intercepts/stackscripts.ts @@ -5,6 +5,7 @@ import type { StackScript } from '@linode/api-v4/types'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; /** * Intercepts GET request to list StackScripts. @@ -32,6 +33,25 @@ export const mockGetStackScripts = ( ); }; +/** + * Intercepts GET request to mock a StackScript. + * + * @param id - StackScript instance identifier + * @param stackscript - a mock StackScript object + * + * @returns Cypress chainable. + */ +export const interceptGetStackScript = ( + id: number, + stackscript: StackScript +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/stackscripts/${id}`), + stackscript + ); +}; + /** * Intercepts POST request to create a StackScript. * @@ -40,3 +60,64 @@ export const mockGetStackScripts = ( export const interceptCreateStackScript = (): Cypress.Chainable => { return cy.intercept('POST', apiMatcher('linode/stackscripts')); }; + +/** + * Mock DELETE request to remove a StackScript. + * + * @returns Cypress chainable. + */ +export const mockDeleteStackScript = (id: number): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`linode/stackscripts/${id}`), { + statusCode: 200, + body: {}, + }); +}; + +/** + * Intercept PUT request to update a StackScript. + * + * @param id - StackScript instance identifier + * @param stackscript - a mock StackScript object + * + * @returns Cypress chainable. + */ +export const mockUpdateStackScript = ( + id: number, + stackscript: StackScript +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/stackscripts/${id}`), + makeResponse(stackscript) + ); +}; + +/** + * Intercept PUT request to mock StackScript update error. + * + * @param id - StackScript instance identifier + * @param err_message - the error message if is_err is true + * + * @returns Cypress chainable. + */ +export const mockUpdateStackScriptError = ( + id: number, + err_field: string | null = null, + err_message: string | null = null +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/stackscripts/${id}`), + makeResponse( + { + errors: [ + { + reason: err_message, + field: err_field, + }, + ], + }, + 400 + ) + ); +}; From bcc09a2e88ac1dad8c76f75bde6b885fae98ffd9 Mon Sep 17 00:00:00 2001 From: Cassie Liu Date: Tue, 27 Jun 2023 10:48:55 -0400 Subject: [PATCH 2/2] Fixed comments --- .../stackscripts/delete-stackscripts.spec.ts | 6 ++---- .../stackscripts/update-stackscripts.spec.ts | 18 ++++++++++++++---- .../cypress/support/intercepts/stackscripts.ts | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index 63a4ad7e177..a50a30c8917 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -2,7 +2,6 @@ import { authenticate } from 'support/api/authentication'; import { stackScriptFactory } from 'src/factories'; import { mockDeleteStackScript, - interceptGetStackScripts, mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; @@ -19,7 +18,7 @@ describe('Delete stackscripts', () => { const stackScripts = stackScriptFactory.buildList(2, { is_public: false, }); - interceptGetStackScripts(stackScripts).as('getStackScripts'); + mockGetStackScripts(stackScripts).as('getStackScripts'); cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); @@ -57,8 +56,7 @@ describe('Delete stackscripts', () => { .click(); }); mockDeleteStackScript(stackScripts[0].id).as('deleteStackScript'); - const updateStackScript = JSON.parse(JSON.stringify(stackScripts[1])); - mockGetStackScripts([updateStackScript]).as('getUpdatedStackScripts'); + mockGetStackScripts([stackScripts[1]]).as('getUpdatedStackScripts'); ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 0a609dc6535..46064072b46 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -1,13 +1,14 @@ import { authenticate } from 'support/api/authentication'; import { randomLabel, randomPhrase } from 'support/util/random'; import { - interceptGetStackScript, + mockGetStackScript, mockUpdateStackScript, mockUpdateStackScriptError, mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; import { stackScriptFactory } from '@src/factories'; +import { StackScript } from '@linode/api-v4/types'; // StackScript fixture paths. const stackscriptNoShebangPath = 'stackscripts/stackscript-no-shebang.sh'; @@ -80,6 +81,16 @@ describe('Update stackscripts', () => { const stackscriptImage = 'Alpine 3.17'; const stackScripts = stackScriptFactory.buildList(2); + // Import StackScript type from Linode API package. + const updatedStackScripts: StackScript[] = [ + // Spread operator clones an object... + { + ...stackScripts[0], + label: stackscriptLabel, + description: stackscriptDesc, + }, + { ...stackScripts[1] }, + ]; mockGetStackScripts(stackScripts).as('getStackScripts'); cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); @@ -92,7 +103,7 @@ describe('Update stackscripts', () => { .should('be.visible') .click(); }); - interceptGetStackScript(stackScripts[0].id, stackScripts[0]).as( + mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( 'getStackScript' ); ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); @@ -158,7 +169,6 @@ describe('Update stackscripts', () => { .type(stackScriptUdf); }); - const updatedStackScripts = JSON.parse(JSON.stringify(stackScripts)); updatedStackScripts[0].label = stackscriptLabel; updatedStackScripts[0].description = stackscriptDesc; mockGetStackScripts(updatedStackScripts).as('getStackScripts'); @@ -236,7 +246,7 @@ describe('Update stackscripts', () => { .findByTitle('Make StackScript Public') .should('be.visible') .click(); - const updatedStackScript = JSON.parse(JSON.stringify(stackScripts[0])); + const updatedStackScript = { ...stackScripts[0] }; updatedStackScript.is_public = true; mockUpdateStackScript(updatedStackScript.id, updatedStackScript).as( 'mockUpdateStackScript' diff --git a/packages/manager/cypress/support/intercepts/stackscripts.ts b/packages/manager/cypress/support/intercepts/stackscripts.ts index bdc3f87e81f..4b13606c876 100644 --- a/packages/manager/cypress/support/intercepts/stackscripts.ts +++ b/packages/manager/cypress/support/intercepts/stackscripts.ts @@ -41,7 +41,7 @@ export const mockGetStackScripts = ( * * @returns Cypress chainable. */ -export const interceptGetStackScript = ( +export const mockGetStackScript = ( id: number, stackscript: StackScript ): Cypress.Chainable => {