diff --git a/packages/manager/.changeset/pr-10469-tests-1715717780880.md b/packages/manager/.changeset/pr-10469-tests-1715717780880.md new file mode 100644 index 00000000000..9ab45c98475 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717780880.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 end-to-end tests ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717804841.md b/packages/manager/.changeset/pr-10469-tests-1715717804841.md new file mode 100644 index 00000000000..8baca1f3156 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717804841.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VLAN flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717849362.md b/packages/manager/.changeset/pr-10469-tests-1715717849362.md new file mode 100644 index 00000000000..3d0b7fb4c80 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717849362.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VPC flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717865422.md b/packages/manager/.changeset/pr-10469-tests-1715717865422.md new file mode 100644 index 00000000000..1ee3be6625a --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717865422.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for SSH key flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717889337.md b/packages/manager/.changeset/pr-10469-tests-1715717889337.md new file mode 100644 index 00000000000..6f5ee7bf11e --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717889337.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for cloud-init flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1716471500474.md b/packages/manager/.changeset/pr-10469-tests-1716471500474.md new file mode 100644 index 00000000000..090c468a04d --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1716471500474.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for Linode Create v2 flow ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts new file mode 100644 index 00000000000..bf7774cc0bd --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -0,0 +1,77 @@ +/** + * @file Smoke tests for Linode Create flow across common mobile viewport sizes. + */ + +import { linodeFactory } from 'src/factories'; +import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Linode create mobile smoke', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + MOBILE_VIEWPORTS.forEach((viewport) => { + /* + * - Confirms Linode create flow can be completed on common mobile screen sizes + * - Creates a basic Nanode and confirms interactions succeed and outgoing request contains expected data. + */ + it(`can create Linode (${viewport.label})`, () => { + const mockLinodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + + cy.viewport(viewport.width, viewport.height); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlanCard('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Nanode 1 GB').should('be.visible'); + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(mockLinodeRegion.label).should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestBody = xhr.request.body; + + expect(requestBody['image']).to.equal('linode/debian11'); + expect(requestBody['label']).to.equal(mockLinode.label); + expect(requestBody['region']).to.equal(mockLinodeRegion.id); + expect(requestBody['type']).to.equal('g6-nanode-1'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts new file mode 100644 index 00000000000..07a04310671 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -0,0 +1,186 @@ +import { + accountUserFactory, + linodeFactory, + sshKeyFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { linodeCreatePage } from 'support/ui/pages'; +import { ui } from 'support/ui'; +import { mockCreateSSHKey } from 'support/intercepts/profile'; + +describe('Create Linode with SSH Key', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow when creating a Linode with an authorized SSH key. + * - Confirms that existing SSH keys are listed on page and can be selected. + * - Confirms that outgoing Linode create API request contains authorized user for chosen key. + */ + it('can add an existing SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [mockSshKey.label], + }); + + mockGetUsers([mockUser]); + mockGetUser(mockUser); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that SSH key is listed, then select it. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockUser.username); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the selected SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); + + /* + * - Confirms UI flow when creating and selecting an SSH key during Linode create flow. + * - Confirms that new SSH key is automatically shown in Linode create page. + * - Confirms that outgoing Linode create API request contains authorized user for new key. + */ + it('can add a new SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa ${randomString(16)}`, + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [], + }); + + const mockUserWithKey = { + ...mockUser, + ssh_keys: [mockSshKey.label], + }; + + mockGetUser(mockUser); + mockGetUsers([mockUser]); + mockCreateLinode(mockLinode).as('createLinode'); + mockCreateSSHKey(mockSshKey).as('createSSHKey'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that no SSH keys are listed for the mocked user. + cy.findByText(mockUser.username) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('None').should('be.visible'); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).should( + 'be.disabled' + ); + }); + + // Click "Add an SSH Key" and enter a label and the public key, then submit. + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetUsers([mockUserWithKey]).as('refetchUsers'); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + cy.findByLabelText('Label').type(mockSshKey.label); + cy.findByLabelText('SSH Public Key').type(mockSshKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createSSHKey', '@refetchUsers']); + + // Confirm that the new SSH key is listed, and select it to be added to the Linode. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the new SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts new file mode 100644 index 00000000000..21096becdf3 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -0,0 +1,149 @@ +import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with user data', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with cloud-init user data specified. + * - Confirms that outgoing API request contains expected user data payload. + */ + it('can specify user data during Linode Create flow', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const userDataFixturePath = 'user-data/user-data-config-basic.yml'; + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + // Fill out create form, selecting a region and image that both have + // cloud-init capabilities. + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Expand "Add User Data" accordion and enter user data config. + ui.accordionHeading + .findByTitle('Add User Data') + .should('be.visible') + .click(); + + cy.fixture(userDataFixturePath).then((userDataContents) => { + ui.accordion.findByTitle('Add User Data').within(() => { + cy.findByText('User Data').click(); + cy.focused().type(userDataContents); + }); + + // Submit form to create Linode and confirm that outgoing API request + // contains expected user data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['metadata']['user_data']).to.equal( + btoa(userDataContents) + ); + }); + }); + }); + + /* + * - Confirms UI flow when creating a Linode using a region that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected region lacks cloud-init. + */ + it('cannot specify user data when selected region does not support it', () => { + const mockLinodeRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockGetRegions([mockLinodeRegion]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected region + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); + + /* + * - Confirms UI flow when creating a Linode using an image that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected image lacks cloud-init. + */ + it('cannot specify user data when selected image does not support it', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const mockImage = imageFactory.build({ + id: `linode/${randomLabel()}`, + label: randomLabel(), + created_by: 'linode', + is_public: true, + vendor: 'Debian', + // `cloud-init` is omitted from Image capabilities. + capabilities: [], + }); + + mockGetImage(mockImage.id, mockImage); + mockGetAllImages([mockImage]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage(mockImage.label); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected image + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts new file mode 100644 index 00000000000..7b3d495de94 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -0,0 +1,240 @@ +import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { + randomIp, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Create Linode with VLANs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign existing VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([mockVlan]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and select existing VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(mockVlan.label) + .should('be.visible') + .click(); + + cy.findByLabelText(/IPAM Address/) + .should('be.enabled') + .type(mockVlan.cidr_block); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal( + mockVlan.cidr_block + ); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm VLAN creation and attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for new VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign new VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and specify new VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(`Create "${mockVlan.label}"`) + .should('be.visible') + .click(); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal(''); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm that VLANs cannot be assigned to Linodes in regions without capability. + * - Confirms that VLAN fields are disabled before and after selecting a region. + */ + it('cannot assign VLANs in regions without capability', () => { + const availabilityNotice = + 'VLANs are currently available in select regions.'; + + const nonVlanRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vlanRegion = regionFactory.build({ + capabilities: ['Linodes', 'Vlans'], + }); + + mockGetRegions([nonVlanRegion, vlanRegion]); + cy.visitWithLogin('/linodes/create'); + + // Expand VLAN accordion, confirm VLAN availability notice is displayed and + // that VLAN fields are disabled while no region is selected. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.contains(availabilityNotice).should('be.visible'); + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + + // Select a region that is known not to have VLAN capability. + linodeCreatePage.selectRegionById(nonVlanRegion.id); + + // Confirm that VLAN fields are still disabled. + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts new file mode 100644 index 00000000000..668c344f2de --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -0,0 +1,267 @@ +import { + linodeFactory, + regionFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateVPC, + mockCreateVPCError, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { linodeCreatePage, vpcCreateDrawer } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomIp, + randomLabel, + randomNumber, + randomPhrase, + randomString, +} from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with VPCs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. + * - Confirms that VPC assignment is reflected in create summary section. + * - Confirms that outgoing API request contains expected VPC interface data. + */ + it('can assign existing VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: `${randomIp()}/0`, + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + // + }); + + mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that mocked VPC is shown in the Autocomplete, and then select it. + cy.findByText('Assign VPC').click().type(`${mockVPC.label}`); + + ui.autocompletePopper + .findByTitle(mockVPC.label) + .should('be.visible') + .click(); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Confirm VPC assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VPC Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow to create a Linode with a new VPC assigned using mock API data. + * - Creates a VPC and a subnet from within the Linode Create flow. + * - Confirms that Cloud responds gracefully when VPC create API request fails. + * - Confirms that outgoing API request contains correct VPC interface data. + */ + it('can assign new VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockErrorMessage = 'An unknown error occurred.'; + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + description: randomPhrase(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockGetVPCs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.findByText('Create VPC').should('be.visible').click(); + + ui.drawer + .findByTitle('Create VPC') + .should('be.visible') + .within(() => { + vpcCreateDrawer.setLabel(mockVPC.label); + vpcCreateDrawer.setDescription(mockVPC.description); + vpcCreateDrawer.setSubnetLabel(mockSubnet.label); + vpcCreateDrawer.setSubnetIpRange(mockSubnet.ipv4!); + + // Confirm that unexpected API errors are handled gracefully upon + // failed VPC creation. + mockCreateVPCError(mockErrorMessage, 500).as('createVpc'); + vpcCreateDrawer.submit(); + + cy.wait('@createVpc'); + cy.findByText(mockErrorMessage).scrollIntoView().should('be.visible'); + + // Create VPC with successful API response mocked. + mockCreateVPC(mockVPC).as('createVpc'); + vpcCreateDrawer.submit(); + }); + + // Attempt to create Linode before selecting a VPC subnet, and confirm + // that validation error appears. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Subnet is required.').should('be.visible'); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Check box to assign public IPv4. + cy.findByText('Assign a public IPv4 address for this Linode') + .should('be.visible') + .click(); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.deep.equal({ nat_1_1: 'any' }); + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow when attempting to assign VPC to Linode in region without capability. + * - Confirms that VPCs selection is disabled. + * - Confirms that notice text is present to explain that VPCs are unavailable. + */ + it('cannot assign VPCs to Linodes in regions without VPC capability', () => { + const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vpcNotAvailableMessage = + 'VPC is not available in the selected region.'; + + mockGetRegions([mockRegion]); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectRegionById(mockRegion.id); + + cy.findByLabelText('Assign VPC') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + cy.findByText(vpcNotAvailableMessage).should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 025a447e577..92c99fa4884 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -1,590 +1,127 @@ -import { - containsVisible, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +/** + * @file Linode Create end-to-end tests. + */ + import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; -import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; -import { - subnetFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - regionFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, - accountFactory, -} from '@src/factories'; -import { authenticate } from 'support/api/authentication'; +import { randomLabel, randomString } from 'support/util/random'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - mockCreateLinode, - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { linodeCreatePage } from 'support/ui/pages'; +import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - checkboxTestId, - headerTestId, -} from 'src/components/DiskEncryption/DiskEncryption'; - -import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; -import { mockGetAccount } from 'support/intercepts/account'; - -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'uk', - id: 'eu-west', - label: 'London, UK', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'sg', - id: 'ap-south', - label: 'Singapore, SG', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-east', - label: 'Newark, NJ', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-central', - label: 'Dallas, TX', - }), -]; authenticate(); -describe('create linode', () => { +describe('Create Linode', () => { before(() => { cleanUp('linodes'); }); - /* - * Region select test. - * - * TODO: Cypress - * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 - * - * - Confirms that region select dropdown is visible and interactive. - * - Confirms that region select dropdown is populated with expected regions. - * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. - * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. - */ - it('region select', () => { - mockGetRegions(mockRegions).as('getRegions'); - - cy.visitWithLogin('linodes/create'); - - cy.wait(['@getRegions']); - - // Confirm that region select dropdown is visible and interactive. - ui.regionSelect.find().click(); - cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); - - // Confirm that region select dropdown are grouped by region, - // sorted alphabetically, with North America first. - cy.get('.MuiAutocomplete-groupLabel') - .should('have.length', 3) - .should((group) => { - expect(group[0]).to.contain('North America'); - expect(group[1]).to.contain('Asia'); - expect(group[2]).to.contain('Europe'); - }); - - // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. - cy.get('[data-qa-option]').should('exist').should('have.length', 4); - mockRegions.forEach((region) => { - cy.get('[data-qa-option]').contains(region.label); + // Enable the `linodeCreateRefactor` feature flag. + // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), }); - - // Select an option - cy.findByTestId('eu-west').click(); - // Confirm the popper is closed - cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); - // Confirm that the selected region is displayed in the input field. - cy.get('[data-testid="textfield-input"]').should( - 'have.value', - 'London, UK (eu-west)' - ); - - // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); - }); - - it('creates a nanode', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - containsVisible('PROVISIONING'); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL') - .should('be.visible') - .should('have.attr', 'data-selected'); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - '"booted": true', - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - `--booted true`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); + mockGetFeatureFlagClientstream(); }); /* - * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing docs link is shown in "Region" section. - * - Confirms that backups pricing is correct when selecting a region with a different price structure. + * End-to-end tests to create Linodes for each available plan type. */ - it('shows DC-specific pricing information during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - )!; - const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id - )!; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes']); - - mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - getClick('[data-qa-deploy-linode]'); - - // A message is shown to instruct users to select a region in order to view plans and prices - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - 'Plan is required.' - ); - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - dcPricingPlanPlaceholder - ); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - // Confirm there is a docs link to the pricing page. - cy.findByText(dcPricingDocsLabel) - .should('be.visible') - .should('have.attr', 'href', dcPricingDocsUrl); - - ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); + describe('End-to-end', () => { + // Run an end-to-end test to create a basic Linode for each plan type described below. + describe('By plan type', () => { + [ + { + planType: 'Shared CPU', + planLabel: 'Nanode 1 GB', + planId: 'g6-nanode-1', + }, + { + planType: 'Dedicated CPU', + planLabel: 'Dedicated 4 GB', + planId: 'g6-dedicated-2', + }, + { + planType: 'High Memory', + planLabel: 'Linode 24 GB', + planId: 'g7-highmem-1', + }, + { + planType: 'Premium CPU', + planLabel: 'Premium 4 GB', + planId: 'g7-premium-2', + }, + // TODO Include GPU plan types. + ].forEach((planConfig) => { + /* + * - Parameterized end-to-end test to create a Linode for each plan type. + * - Confirms that a Linode of the given plan type can be deployed. + */ + it(`creates a ${planConfig.planType} Linode`, () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Premium Plans'], + }); + const linodeLabel = randomLabel(); + + interceptCreateLinode().as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan( + planConfig.planType, + planConfig.planLabel + ); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(planConfig.planLabel).should('be.visible'); + }); + + // Create Linode and confirm it's provisioned as expected. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const responsePayload = xhr.response?.body; + + // Confirm that API request and response contain expected data + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['region']).to.equal(linodeRegion.id); + expect(requestPayload['type']).to.equal(planConfig.planId); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(planConfig.planId); + + // Confirm that Cloud redirects to details page + cy.url().should('endWith', `/linodes/${responsePayload['id']}`); + }); + + // TODO Confirm whether or not toast notification should appear here. + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + }); }); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { - const region: Region = getRegionById('us-southeast'); - const mockNoVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes'], - }); - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockNoVPCRegion]).as('getRegions'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible( - 'Allow Linode to communicate in an isolated environment.' - ); - // Helper text appears if VPC is not available in selected region. - containsVisible('VPC is not available in the selected region.'); - }); - }); - - it('assigns a VPC to the linode during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockSubnet = subnetFactory.build({ - id: randomNumber(2), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - region: 'us-southeast', - subnets: [mockSubnet], - }); - const mockVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], - }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', - active: true, - }); - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - interfaces: [ - // The order of this array is significant. Index 0 (eth0) should be public. - mockPublicConfigInterface, - mockVlanConfigInterface, - mockVpcConfigInterface, - ], - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockVPCRegion]).as('getRegions'); - - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockGetVPCs([mockVPC]).as('getVPCs'); - mockCreateLinode(mockLinode).as('linodeCreated'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.get('[data-qa-enhanced-select="None"]') - .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); - // select subnet - cy.findByText('Select Subnet') - .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - - fbtClick('Configurations'); - //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); - }); - }); - - it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); - }); - - it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegion = regionFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegionWithoutDiskEncryption = regionFactory.build({ - capabilities: ['Linodes'], - }); - - const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid="${headerTestId}"]`).should('exist'); - - // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected - ui.regionSelect.find().click(); - ui.select - .findItemByText( - `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` - ) - .click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); - - ui.regionSelect.find().click(); - ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts new file mode 100644 index 00000000000..0f9e5fc2238 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -0,0 +1,603 @@ +/** + * @file Integration tests and end-to-end tests for legacy Linode Create flow. + */ +// TODO Delete this test file when `linodeCreateRefactor` feature flag is retired. +// Move out any tests (e.g. region select test) for flows that aren't covered by new tests in the meantime. + +import { + containsVisible, + fbtClick, + fbtVisible, + getClick, + getVisible, +} from 'support/helpers'; +import { ui } from 'support/ui'; +import { apiMatcher } from 'support/util/intercepts'; +import { randomString, randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { getRegionById } from 'support/util/regions'; +import { + accountFactory, + subnetFactory, + vpcFactory, + linodeFactory, + linodeConfigFactory, + regionFactory, + VLANFactory, + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/DiskEncryption/DiskEncryption'; + +import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }), +]; + +authenticate(); +describe('create linode', () => { + before(() => { + cleanUp('linodes'); + }); + + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + cy.visitWithLogin('linodes/create'); + + cy.wait(['@getRegions']); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + // Confirm that the selected region is displayed in the input field. + cy.get('[data-testid="textfield-input"]').should( + 'have.value', + 'London, UK (eu-west)' + ); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); + + it('creates a nanode', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.get('[data-qa-deploy-linode]'); + cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); + fbtClick('Shared CPU'); + getClick('[id="g6-nanode-1"]'); + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + containsVisible('PROVISIONING'); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it('creates a linode via CLI', () => { + const linodeLabel = randomLabel(); + const linodePass = randomString(32); + const linodeRegion = chooseRegion(); + + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.autocompletePopper + .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) + .should('exist') + .click(); + + cy.get('[id="g6-dedicated-2"]').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .should('be.enabled') + .clear() + .type(linodeLabel); + + cy.findByLabelText('Root Password') + .should('be.visible') + .should('be.enabled') + .type(linodePass); + + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + // Switch to cURL view if necessary. + cy.findByText('cURL') + .should('be.visible') + .should('have.attr', 'data-selected'); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + '"booted": true', + ].forEach((line: string) => + cy.findByText(line, { exact: false }).should('be.visible') + ); + + cy.findByText('Linode CLI').should('be.visible').click(); + + [ + `--region ${linodeRegion.id}`, + '--type g6-dedicated-2', + `--label ${linodeLabel}`, + `--root_pass ${linodePass}`, + `--booted true`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms DC-specific pricing UI flow works as expected during Linode creation. + * - Confirms that pricing docs link is shown in "Region" section. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. + */ + it('shows DC-specific pricing information during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const initialRegion = getRegionById('us-west'); + const newRegion = getRegionById('us-east'); + + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: initialRegion.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + )!; + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + )!; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes']); + + mockCreateLinode(mockLinode).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + getClick('[data-qa-deploy-linode]'); + + // A message is shown to instruct users to select a region in order to view plans and prices + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + 'Plan is required.' + ); + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + dcPricingPlanPlaceholder + ); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { + const region: Region = getRegionById('us-southeast'); + const mockNoVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes'], + }); + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockNoVPCRegion]).as('getRegions'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible( + 'Allow Linode to communicate in an isolated environment.' + ); + // Helper text appears if VPC is not available in selected region. + containsVisible('VPC is not available in the selected region.'); + }); + }); + + it('assigns a VPC to the linode during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const region: Region = getRegionById('us-southeast'); + const diskLabel: string = 'Debian 10 Disk'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); + const mockVLANs: VLAN[] = VLANFactory.buildList(2); + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + }); + const mockVPC = vpcFactory.build({ + id: randomNumber(), + region: 'us-southeast', + subnets: [mockSubnet], + }); + const mockVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes', 'VPCs', 'Vlans'], + }); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); + const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + purpose: 'vpc', + active: true, + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + mockVlanConfigInterface, + mockVpcConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: diskLabel, + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockVPCRegion]).as('getRegions'); + + mockGetVLANs(mockVLANs); + mockGetVPC(mockVPC).as('getVPC'); + mockGetVPCs([mockVPC]).as('getVPCs'); + mockCreateLinode(mockLinode).as('linodeCreated'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getLinodeTypes', + '@getClientStream', + '@getFeatureFlags', + '@getVPCs', + ]); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present, and the VPC in the same region of + // the linode can be selected. + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible('Assign this Linode to an existing VPC.'); + // select VPC + cy.get('[data-qa-enhanced-select="None"]') + .should('be.visible') + .click() + .type(`${mockVPC.label}{enter}`); + // select subnet + cy.findByText('Select Subnet') + .should('be.visible') + .click() + .type(`${mockSubnet.label}{enter}`); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + + fbtClick('Configurations'); + //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); + + // Confirm that VLAN and VPC have been assigned. + cy.findByLabelText('List of Configurations').within(() => { + cy.get('tr').should('have.length', 2); + containsVisible(`${mockConfig.label} – GRUB 2`); + containsVisible('eth0 – Public Internet'); + containsVisible(`eth2 – VPC: ${mockVPC.label}`); + }); + }); + + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); +}); diff --git a/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml new file mode 100644 index 00000000000..b8bc4e3163e --- /dev/null +++ b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml @@ -0,0 +1,11 @@ +#cloud-config + +# Sample cloud-init config data file. +# See also: https://cloudinit.readthedocs.io/en/latest/explanation/format.html + +groups: + - foo-group + +users: + - name: foo + primary_group: foo-group diff --git a/packages/manager/cypress/support/constants/environment.ts b/packages/manager/cypress/support/constants/environment.ts new file mode 100644 index 00000000000..545bcd9bc8a --- /dev/null +++ b/packages/manager/cypress/support/constants/environment.ts @@ -0,0 +1,21 @@ +/** + * @file Constants related to test environment. + */ + +export interface ViewportSize { + width: number; + height: number; + label?: string; +} + +// Array of common mobile viewports against which to test. +export const MOBILE_VIEWPORTS: ViewportSize[] = [ + { + // iPhone 14 Pro, iPhone 15, iPhone 15 Pro, etc. + label: 'iPhone 15', + width: 393, + height: 852, + }, + // TODO Evaluate what devices to include here and how long to allow this list to be. Tablets? + // Do we want to keep this short, or make it long and just choose a random subset each time we do mobile testing? +]; diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts new file mode 100644 index 00000000000..f6eb377242c --- /dev/null +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -0,0 +1,10 @@ +/** + * @file Constants related to Linode tests. + */ + +/** + * Length of time to wait for a Linode to be created. + * + * Equals 3 minutes. + */ +export const LINODE_CREATE_TIMEOUT = 180_000; diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index 244e969867a..a9a38804c06 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Image API requests. */ -import { imageFactory } from '@src/factories'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { getFilters } from 'support/util/request'; -import type { Image, ImageStatus } from '@linode/api-v4'; +import type { Image } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; /** * Intercepts POST request to create a machine image and mocks the response. @@ -92,20 +92,15 @@ export const mockGetRecoveryImages = ( * @returns Cypress chainable. */ export const mockGetImage = ( - label: string, - id: string, - status: ImageStatus + imageId: string, + image: Image ): Cypress.Chainable => { - const encodedId = encodeURIComponent(id); - return cy.intercept('GET', apiMatcher(`images/${encodedId}*`), (req) => { - return req.reply( - imageFactory.build({ - id, - label, - status, - }) - ); - }); + const encodedId = encodeURIComponent(imageId); + return cy.intercept( + 'GET', + apiMatcher(`images/${encodedId}*`), + makeResponse(image) + ); }; /** diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 3ed9a751483..f9053602749 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -13,6 +13,7 @@ import type { Profile, SecurityQuestionsData, SecurityQuestionsPayload, + SSHKey, Token, UserPreferences, } from '@linode/api-v4'; @@ -388,3 +389,48 @@ export const mockResetOAuthApps = ( oauthApp ); }; + +/** + * Intercepts GET request to fetch SSH keys and mocks the response. + * + * @param sshKeys - Array of SSH key objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKeys = (sshKeys: SSHKey[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('/profile/sshkeys*'), + paginateResponse(sshKeys) + ); +}; + +/** + * Intercepts GET request to fetch an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/profile/sshkeys/${sshKey.id}`), + makeResponse(sshKey) + ); +}; + +/** + * Intercepts POST request to create an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('/profile/sshkeys'), + makeResponse(sshKey) + ); +}; diff --git a/packages/manager/cypress/support/ui/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 523de4ed3fa..31e8f25cd32 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -1,3 +1,23 @@ +/** + * UI helpers for accordion panel headings. + */ +export const accordionHeading = { + /** + * Finds an accordion with the given title. + * + * @param title - Title of the accordion header to find. + * + * @returns Cypress chainable. + */ + findByTitle: (title: string) => { + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy.findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }); + }, +}; + /** * UI helpers for accordion panels. */ @@ -19,6 +39,13 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy + .findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }) + .closest('[data-qa-panel]') + .find('[data-qa-panel-details]'); }, }; diff --git a/packages/manager/cypress/support/ui/pages/index.ts b/packages/manager/cypress/support/ui/pages/index.ts new file mode 100644 index 00000000000..f08f42cd5a9 --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/index.ts @@ -0,0 +1,15 @@ +/** + * @file Index file for Cypress page utility re-exports. + * + * Page utilities are basic JavaScript objects containing functions to perform + * common page-specific interactions. They allow us to minimize code duplication + * across tests that interact with similar pages. + * + * Page utilities are NOT page objects in the traditional UI testing sense. + * Specifically, page utility objects should NOT have state, and page utilities + * should only be concerned with interacting with or asserting the state of + * the DOM. + */ + +export * from './linode-create-page'; +export * from './vpc-create-drawer'; diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts new file mode 100644 index 00000000000..1f24a0899ca --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -0,0 +1,94 @@ +/** + * @file Page utilities for Linode Create page (v2 implementation). + */ + +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the Linode create page. + */ +export const linodeCreatePage = { + /** + * Sets the Linode's label. + * + * @param linodeLabel - Linode label to set. + */ + setLabel: (linodeLabel: string) => { + cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); + }, + + /** + * Sets the Linode's root password. + * + * @param linodePassword - Root password to set. + */ + setRootPassword: (linodePassword: string) => { + cy.findByLabelText('Root Password').as('rootPasswordField').click(); + + cy.get('@rootPasswordField').type(linodePassword, { log: false }); + }, + + /** + * Selects the Image with the given name. + * + * @param imageName - Name of Image to select. + */ + selectImage: (imageName: string) => { + cy.findByText('Choose a Distribution') + .closest('[data-qa-paper]') + .within(() => { + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle(imageName) + .should('be.visible') + .click(); + }); + }, + + /** + * Select the Region with the given ID. + * + * @param regionId - ID of Region to select. + */ + selectRegionById: (regionId: string) => { + ui.regionSelect.find().click().type(`${regionId}{enter}`); + }, + + /** + * Select the given Linode plan. + * + * Assumes that plans are displayed in a table. + * + * @param planTabTitle - Title of tab where desired plan is located. + * @param planTitle - Title of desired plan. + */ + selectPlan: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.get(`[data-qa-plan-row="${planTitle}"]`) + .closest('tr') + .should('be.visible') + .click(); + }); + }, + + /** + * Select the given Linode plan selection card. + * + * Useful for testing Linode create page against mobile viewports. + * + * Assumes that plans are displayed as selection cards. + */ + selectPlanCard: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.findByText(planTitle) + .should('be.visible') + .as('selectionCard') + .scrollIntoView(); + + cy.get('@selectionCard').click(); + }); + }, +}; diff --git a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts new file mode 100644 index 00000000000..20c5635dc4b --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the VPC create drawer. + * + * Assumes that selection context is limited to only the drawer. + */ +export const vpcCreateDrawer = { + /** + * Sets the VPC create form's label field. + * + * @param vpcLabel - VPC label to set. + */ + setLabel: (vpcLabel: string) => { + cy.findByLabelText('VPC Label') + .should('be.visible') + .type(`{selectall}{del}${vpcLabel}`); + }, + + /** + * Sets the VPC create form's description field. + * + * @param vpcDescription - VPC description to set. + */ + setDescription: (vpcDescription: string) => { + cy.findByLabelText('Description', { exact: false }) + .should('be.visible') + .type(`{selectall}{del}${vpcDescription}`); + }, + + /** + * Sets the VPC create form's subnet label. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetLabel - Label to set. + * @param subnetIndex - Optional index of subnet for which to update label. + */ + setSubnetLabel: (subnetLabel: string, subnetIndex: number = 0) => { + cy.findByText('Subnet Label', { + selector: `label[for="subnet-label-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetLabel}`); + }, + + /** + * Sets the VPC create form's subnet IP address. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetIpRange - IP range to set. + * @param subnetIndex - Optional index of subnet for which to update IP range. + */ + setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => { + cy.findByText('Subnet IP Address Range', { + selector: `label[for="subnet-ipv4-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetIpRange}`); + }, + + /** + * Submits the VPC create form. + */ + submit: () => { + ui.buttonGroup + .findButtonByTitle('Create VPC') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }, +}; diff --git a/packages/manager/src/components/Paper.tsx b/packages/manager/src/components/Paper.tsx index c12f8ce9530..61bc8f129c3 100644 --- a/packages/manager/src/components/Paper.tsx +++ b/packages/manager/src/components/Paper.tsx @@ -30,6 +30,7 @@ export const Paper = (props: Props) => { {props.error && {props.error}} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx index e4e9c2da42d..9501b863392 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx @@ -15,6 +15,7 @@ import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/d import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import type { CreateLinodeRequest } from '@linode/api-v4'; +import { extendType } from 'src/utilities/extendType'; export const Summary = () => { const theme = useTheme(); @@ -78,7 +79,7 @@ export const Summary = () => { { item: { details: `$${price?.monthly}/month`, - title: type?.label, + title: type && extendType(type).formattedLabel, }, show: Boolean(type), }, @@ -124,7 +125,7 @@ export const Summary = () => { const summaryItemsToShow = summaryItems.filter((item) => item.show); return ( - + Summary {label} {summaryItemsToShow.length === 0 ? (