diff --git a/docker-compose.yml b/docker-compose.yml index 51f2f01e6a7..47aa0b42cd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,9 @@ x-e2e-env: CY_TEST_SPLIT_RUN_TOTAL: ${CY_TEST_SPLIT_RUN_TOTAL} CY_TEST_SPLIT_RUN_INDEX: ${CY_TEST_SPLIT_RUN_INDEX} + # Cypress performance. + CY_TEST_ACCOUNT_CACHE_DIR: ${CY_TEST_ACCOUNT_CACHE_DIR} + # Cypress reporting. CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} CY_TEST_USER_REPORT: ${CY_TEST_USER_REPORT} @@ -63,6 +66,7 @@ x-e2e-env: x-e2e-volumes: &default-volumes - ./.git:/home/node/app/.git + - ./cache:/home/node/app/cache - ./packages/manager:/home/node/app/packages/manager - ./packages/validation:/home/node/app/packages/validation - ./packages/api-v4:/home/node/app/packages/api-v4 diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 805729ae65d..5635774c867 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -220,6 +220,14 @@ Environment variables related to Cypress logging and reporting, as well as repor | `CY_TEST_FAIL_ON_MANAGED` | Fail affected tests when Managed is enabled | `1` | Unset; disabled by default | | `CY_TEST_GENWEIGHTS` | Generate and output test weights to the given path | `./weights.json` | Unset; disabled by default | +###### Performance +Environment variables that can be used to improve test performance in some scenarios. + +| Environment Variable | Description | Example | Default | +|---------------------------------|-----------------------------------------------|--------------------|----------------------------| +| `CY_TEST_ACCOUNT_CACHE_DIR` | Directory containing test account cache data | `./cache/accounts` | Unset; disabled by default | + + ### Writing End-to-End Tests 1. Look here for [Cypress Best Practices](https://docs.cypress.io/guides/references/best-practices) diff --git a/packages/manager/.changeset/pr-10835-changed-1724705534485.md b/packages/manager/.changeset/pr-10835-changed-1724705534485.md new file mode 100644 index 00000000000..23282c0560d --- /dev/null +++ b/packages/manager/.changeset/pr-10835-changed-1724705534485.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Update image related copy as part of Image Service Gen2 ([#10835](https://github.com/linode/manager/pull/10835)) diff --git a/packages/manager/.changeset/pr-10852-tests-1724937535185.md b/packages/manager/.changeset/pr-10852-tests-1724937535185.md new file mode 100644 index 00000000000..a82c165e853 --- /dev/null +++ b/packages/manager/.changeset/pr-10852-tests-1724937535185.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add new tests for for selecting "All" Scopes ([#10852](https://github.com/linode/manager/pull/10852)) diff --git a/packages/manager/.changeset/pr-10867-tests-1725460656929.md b/packages/manager/.changeset/pr-10867-tests-1725460656929.md new file mode 100644 index 00000000000..a59c63b37dc --- /dev/null +++ b/packages/manager/.changeset/pr-10867-tests-1725460656929.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add `CY_TEST_ACCOUNT_CACHE_DIR` environment variable to enable retrieval of test account cache data ([#10867](https://github.com/linode/manager/pull/10867)) diff --git a/packages/manager/.changeset/pr-10867-tests-1725482924721.md b/packages/manager/.changeset/pr-10867-tests-1725482924721.md new file mode 100644 index 00000000000..5ab76d7861f --- /dev/null +++ b/packages/manager/.changeset/pr-10867-tests-1725482924721.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Allow tests to fall back on cached account data when API request fails ([#10867](https://github.com/linode/manager/pull/10867)) diff --git a/packages/manager/.changeset/pr-10887-tech-stories-1725467686328.md b/packages/manager/.changeset/pr-10887-tech-stories-1725467686328.md new file mode 100644 index 00000000000..48bf6c14c7f --- /dev/null +++ b/packages/manager/.changeset/pr-10887-tech-stories-1725467686328.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Resolve "Incomplete string escape or encoding" codeQL alert in `generate-ansibleConfig.ts` ([#10887](https://github.com/linode/manager/pull/10887)) diff --git a/packages/manager/.changeset/pr-10889-tests-1725485099504.md b/packages/manager/.changeset/pr-10889-tests-1725485099504.md new file mode 100644 index 00000000000..0145a26624d --- /dev/null +++ b/packages/manager/.changeset/pr-10889-tests-1725485099504.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update remaining Linode Create Cypress tests run against Linode Create v2 ([#10889](https://github.com/linode/manager/pull/10889)) diff --git a/packages/manager/.changeset/pr-10892-tests-1725546783401.md b/packages/manager/.changeset/pr-10892-tests-1725546783401.md new file mode 100644 index 00000000000..359a0df0caf --- /dev/null +++ b/packages/manager/.changeset/pr-10892-tests-1725546783401.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Clean up feature flag mocks ([#10892](https://github.com/linode/manager/pull/10892)) diff --git a/packages/manager/.changeset/pr-10899-changed-1725595024930.md b/packages/manager/.changeset/pr-10899-changed-1725595024930.md new file mode 100644 index 00000000000..9d38fbc00dd --- /dev/null +++ b/packages/manager/.changeset/pr-10899-changed-1725595024930.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Lower Events historical data fetching to 7 days ([#10899](https://github.com/linode/manager/pull/10899)) diff --git a/packages/manager/.changeset/pr-10904-added-1725873382370.md b/packages/manager/.changeset/pr-10904-added-1725873382370.md new file mode 100644 index 00000000000..66b5e4a75f6 --- /dev/null +++ b/packages/manager/.changeset/pr-10904-added-1725873382370.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +DisplayPrice Story ([#10904](https://github.com/linode/manager/pull/10904)) diff --git a/packages/manager/.changeset/pr-10907-tests-1725898460559.md b/packages/manager/.changeset/pr-10907-tests-1725898460559.md new file mode 100644 index 00000000000..d4a32549afa --- /dev/null +++ b/packages/manager/.changeset/pr-10907-tests-1725898460559.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress integration test for Object Storage Gen2: E1 Endpoint ([#10907](https://github.com/linode/manager/pull/10907)) diff --git a/packages/manager/.changeset/pr-10910-changed-1725910840891.md b/packages/manager/.changeset/pr-10910-changed-1725910840891.md new file mode 100644 index 00000000000..afad60fe57b --- /dev/null +++ b/packages/manager/.changeset/pr-10910-changed-1725910840891.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + + "contact support" links to new support ticket in event messages ([#10910](https://github.com/linode/manager/pull/10910)) diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 270f5568f23..7df2637bbc7 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -24,6 +24,8 @@ describe('Personal access tokens', () => { * - Confirms that user is shown the token secret upon successful PAT creation * - Confirms that new personal access token is shown in list * - Confirms that user can open and close "View Scopes" drawer + * - Confirm that the “Child account access” grant is not visible in the list of permissions. + * - Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. */ it('can create personal access tokens', () => { const token = appTokenFactory.build({ @@ -63,6 +65,9 @@ describe('Personal access tokens', () => { .findByTitle('Add Personal Access Token') .should('be.visible') .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + // Confirm submit button is disabled without specifying scopes. ui.buttonGroup .findButtonByTitle('Create Token') @@ -147,7 +152,12 @@ describe('Personal access tokens', () => { }); // Confirm that new PAT is shown in list and "View Scopes" drawer works. - cy.wait('@getTokens'); + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(token.scopes); + }); cy.findByText(token.label) .should('be.visible') .closest('tr') diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index c73550b7a13..346023aa089 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -2,13 +2,8 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { TAX_ID_HELPER_TEXT } from 'src/features/Billing/constants'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import type { Flags } from 'src/featureFlags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -72,11 +67,10 @@ const checkAccountContactDisplay = (accountInfo: Account) => { describe('Billing Contact', () => { beforeEach(() => { mockAppendFeatureFlags({ - taxId: makeFeatureFlagData({ + taxId: { enabled: true, - }), + }, }); - mockGetFeatureFlagClientstream(); }); it('Edit Contact Info', () => { // mock the user's account data and confirm that it is displayed correctly upon page load diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index 089e766fb93..282a1bf48f5 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -1,16 +1,10 @@ import { ui } from 'support/ui'; -import { fbtClick, getClick } from 'support/helpers'; -import { regionFactory } from '@src/factories'; +import { linodeFactory, regionFactory } from '@src/factories'; import { randomString, randomLabel } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccountAgreements } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - import type { Region } from '@linode/api-v4'; +import { mockCreateLinode } from 'support/intercepts/linodes'; const mockRegions: Region[] = [ regionFactory.build({ @@ -102,15 +96,7 @@ describe('GDPR agreement', () => { cy.get('[data-testid="eu-agreement-checkbox"]').should('not.exist'); }); - it('needs the agreement checked to validate the form', () => { - // This test does not apply to Linode Create v2 because - // Linode Create v2 allows you to press "Create Linode" - // without checking the GDPR checkbox. (The user will - // get a validation error if they have not agreed). - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); + it('needs the agreement checked to submit the form', () => { mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ privacy_policy: false, @@ -120,26 +106,50 @@ describe('GDPR agreement', () => { const linodeLabel = randomLabel(); cy.visitWithLogin('/linodes/create'); - cy.wait(['@getAgreements', '@getRegions']); + cy.wait(['@getRegions']); // Paris should have the agreement ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId('fr-par').click(); - cy.get('[data-testid="eu-agreement-checkbox"]').should('be.visible'); - // Fill out the form - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); + cy.wait('@getAgreements'); + + cy.findByText('Shared CPU').click(); + + cy.get('[id="g6-nanode-1"]').click(); + + cy.findByLabelText('Linode Label').clear().type(linodeLabel); - // expect the button to be disabled - cy.get('[data-qa-deploy-linode="true"]').should('be.disabled'); + cy.findByLabelText('Root Password').type(rootpass); + + cy.get('[data-testid="eu-agreement-checkbox"]') + .scrollIntoView() + .should('be.visible'); + + cy.findByText('Create Linode') + .scrollIntoView() + .should('be.enabled') + .should('be.visible') + .click(); + + cy.findByText( + 'You must agree to the EU agreement to deploy to this region.' + ).should('be.visible'); // check the agreement - getClick('#gdpr-checkbox'); + cy.get('#gdpr-checkbox').click(); + + cy.findByText( + 'You must agree to the EU agreement to deploy to this region.' + ).should('not.exist'); + + mockCreateLinode(linodeFactory.build()).as('createLinode'); + + cy.findByText('Create Linode') + .should('be.enabled') + .should('be.visible') + .click(); - // expect the button to be enabled - cy.get('[data-qa-deploy-linode="true"]').should('not.be.disabled'); + cy.wait('@createLinode'); }); }); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts index f9eb2a840cb..46f3867d7a1 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts @@ -1,9 +1,5 @@ import 'cypress-file-upload'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomItem, @@ -48,9 +44,8 @@ describe('close support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); @@ -107,9 +102,8 @@ describe('close support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index bffc53d6cef..5644f5deea4 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -2,11 +2,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import 'cypress-file-upload'; import { interceptGetProfile } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { randomItem, @@ -69,9 +65,8 @@ describe('open support tickets', () => { */ it('can open a support ticket', () => { mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(false), + supportTicketSeverity: false, }); - mockGetFeatureFlagClientstream(); const image = 'test_screenshot.png'; const ticketDescription = 'this is a test ticket'; @@ -159,9 +154,8 @@ describe('open support tickets', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockCreateSupportTicket(mockTicket).as('createTicket'); mockGetSupportTickets([]); mockGetSupportTicket(mockTicket); diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts index 2c511d9810f..7fc84b7a74b 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts @@ -1,9 +1,5 @@ import { interceptGetProfile } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { randomItem, randomLabel, @@ -38,9 +34,8 @@ describe('support tickets landing page', () => { */ it('shows the empty message when there are no tickets.', () => { mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(false), + supportTicketSeverity: false, }); - mockGetFeatureFlagClientstream(); interceptGetProfile().as('getProfile'); @@ -92,9 +87,8 @@ describe('support tickets landing page', () => { const mockTickets = [mockTicket, mockAnotherTicket]; mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets(mockTickets); cy.visitWithLogin('/support/tickets'); @@ -156,9 +150,8 @@ describe('support tickets landing page', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); @@ -251,9 +244,8 @@ describe('support tickets landing page', () => { } mockAppendFeatureFlags({ - supportTicketSeverity: makeFeatureFlagData(true), + supportTicketSeverity: true, }); - mockGetFeatureFlagClientstream(); mockGetSupportTickets([mockTicket]); mockGetSupportTicket(mockTicket).as('getSupportTicket'); mockGetSupportTicketReplies(mockTicket.id, []).as('getReplies'); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index 1415f28d93c..b118a4cad41 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -5,7 +5,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomPhrase } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; @@ -127,7 +126,7 @@ describe('create image (e2e)', () => { it('displays notice informing user that Images are not encrypted, provided the LDE feature is enabled and the selected linode is not in an Edge region', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); // Mock responses @@ -164,7 +163,7 @@ describe('create image (e2e)', () => { it('does not display a notice informing user that Images are not encrypted if the LDE feature is disabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), + linodeDiskEncryption: false, }).as('getFeatureFlags'); // Mock responses @@ -201,7 +200,7 @@ describe('create image (e2e)', () => { it('does not display a notice informing user that Images are not encrypted if the selected linode is in an Edge region', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); // Mock responses diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 1d5409eea74..9ec97afd8fb 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -6,19 +6,23 @@ import { } from 'support/intercepts/images'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import type { Image } from '@linode/api-v4'; +import type { Image, Region } from '@linode/api-v4'; import { extendRegion } from 'support/util/regions'; -describe('Manage Image Regions', () => { +describe('Manage Image Replicas', () => { /** * Adds two new regions to an Image (region3 and region4) * and removes one existing region (region 1). */ it("updates an Image's regions", () => { - const region1 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region2 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region3 = extendRegion(regionFactory.build({ site_type: 'core' })); - const region4 = extendRegion(regionFactory.build({ site_type: 'core' })); + const regionOptions: Partial = { + site_type: 'core', + capabilities: ['Object Storage'], + }; + const region1 = extendRegion(regionFactory.build(regionOptions)); + const region2 = extendRegion(regionFactory.build(regionOptions)); + const region3 = extendRegion(regionFactory.build(regionOptions)); + const region4 = extendRegion(regionFactory.build(regionOptions)); const image = imageFactory.build({ size: 50, @@ -41,28 +45,28 @@ describe('Manage Image Regions', () => { .closest('tr') .within(() => { // Verify total size is rendered - cy.findByText(`${image.total_size} MB`).should('be.visible'); + cy.findByText(`0.1 GB`).should('be.visible'); // 100 / 1024 = 0.09765 // Verify capabilities are rendered cy.findByText('Distributed').should('be.visible'); - // Verify the first region is rendered - cy.findByText(region1.label + ',').should('be.visible'); - - // Click the "+1" - cy.findByText('+1').should('be.visible').should('be.enabled').click(); + // Verify the number of regions is rendered and click it + cy.findByText(`${image.regions.length} Regions`) + .should('be.visible') + .should('be.enabled') + .click(); }); - // Verify the Manage Regions drawer opens and contains basic content + // Verify the Manage Replicas drawer opens and contains basic content ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .should('be.visible') .within(() => { // Verify the Image regions render cy.findByText(region1.label).should('be.visible'); cy.findByText(region2.label).should('be.visible'); - cy.findByText('Image will be available in these regions (2)').should( + cy.findByText('Image will be replicated in these regions (2)').should( 'be.visible' ); @@ -72,7 +76,7 @@ describe('Manage Image Regions', () => { .should('be.visible') .should('be.disabled'); - // Close the Manage Regions drawer + // Close the Manage Replicas drawer ui.button .findByTitle('Cancel') .should('be.visible') @@ -91,9 +95,9 @@ describe('Manage Image Regions', () => { .click(); }); - // Click "Manage Regions" option in the action menu + // Click "Manage Replicas" option in the action menu ui.actionMenuItem - .findByTitle('Manage Regions') + .findByTitle('Manage Replicas') .should('be.visible') .should('be.enabled') .click(); @@ -142,7 +146,7 @@ describe('Manage Image Regions', () => { // Click outside of the Region Multi-Select to close the popover ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .click() .within(() => { // Verify the existing image regions render @@ -155,7 +159,7 @@ describe('Manage Image Regions', () => { cy.findAllByText('unsaved').should('be.visible'); // Verify the count is now 3 - cy.findByText('Image will be available in these regions (4)').should( + cy.findByText('Image will be replicated in these regions (4)').should( 'be.visible' ); @@ -172,7 +176,7 @@ describe('Manage Image Regions', () => { cy.findByText(region1.label).should('not.exist'); // Verify the count is now 3 - cy.findByText('Image will be available in these regions (3)').should( + cy.findByText('Image will be replicated in these regions (3)').should( 'be.visible' ); @@ -190,19 +194,17 @@ describe('Manage Image Regions', () => { .closest('tr') .within(() => { // Verify the new size is shown - cy.findByText('150 MB'); + cy.findByText('0.15 GB'); // 150 / 2014 = 0.1464 - // Verify the first region is rendered - cy.findByText(region2.label + ',').should('be.visible'); - - cy.findByText('+2').should('be.visible').should('be.enabled'); - - // Verify the regions count is now "+2" and open the drawer - cy.findByText('+2').should('be.visible').should('be.enabled').click(); + // Verify the new number of regions is shown and click it + cy.findByText(`${updatedImage.regions.length} Regions`) + .should('be.visible') + .should('be.enabled') + .click(); }); ui.drawer - .findByTitle(`Manage Regions for ${image.label}`) + .findByTitle(`Manage Replicas for ${image.label}`) .click() .within(() => { // "Unsaved" regions should transition to "pending replication" because diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index 24c7fcbfeb0..b97a4b77baf 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -12,20 +12,15 @@ import { import { getRegionById } from 'support/util/regions'; import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; describe('LKE landing page', () => { it('does not display a Disk Encryption info banner if the LDE feature is disabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), + linodeDiskEncryption: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ @@ -51,9 +46,8 @@ describe('LKE landing page', () => { it('displays a Disk Encryption info banner if the LDE feature is enabled', () => { // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), + linodeDiskEncryption: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock responses const mockAccount = accountFactory.build({ 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 index bf7774cc0bd..1cc4bfa5f59 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -7,23 +7,10 @@ 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 diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts new file mode 100644 index 00000000000..069fc806b06 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts @@ -0,0 +1,91 @@ +import { ui } from 'support/ui'; +import { regionFactory } from '@src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { extendRegion } from 'support/util/regions'; + +import type { ExtendedRegion } from 'support/util/regions'; + +const mockRegions: ExtendedRegion[] = [ + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }) + ), + extendRegion( + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }) + ), +]; + +describe('Linode Create Region Select', () => { + /* + * 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.findByLabelText('Region').should('have.value', 'UK, London (eu-west)'); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index d801c3046b7..0c3fb2ed059 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -6,12 +6,8 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { linodeCreatePage } from 'support/ui/pages'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; - -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { chooseRegion } from 'support/util/regions'; describe('Create Linode', () => { /* @@ -22,10 +18,8 @@ describe('Create Linode', () => { // TODO Delete these mocks once `apicliDxToolsAdditions` feature flag is retired. beforeEach(() => { mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(true), - linodeCreateRefactor: makeFeatureFlagData(true), + apicliDxToolsAdditions: true, }); - mockGetFeatureFlagClientstream(); }); it(`view code snippets in create linode flow`, () => { const linodeLabel = randomLabel(); @@ -149,15 +143,14 @@ describe('Create Linode', () => { }); }); }); + describe('Create Linode flow with apicliDxToolsAdditions disabled', () => { // Enable the `apicliDxToolsAdditions` feature flag. // TODO Delete these mocks and test once `apicliDxToolsAdditions` feature flag is retired. beforeEach(() => { mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(false), - linodeCreateRefactor: makeFeatureFlagData(true), + apicliDxToolsAdditions: false, }); - mockGetFeatureFlagClientstream(); }); it(`view code snippets in create linode flow`, () => { const linodeLabel = randomLabel(); @@ -203,4 +196,75 @@ describe('Create Linode', () => { }); }); }); + + 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( + 'have.value', + `debian-${linodeRegion.id}` + ); + + 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').click(); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + ].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}`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index 23d62bf666f..c6d8befe082 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -1,27 +1,14 @@ import { linodeFactory } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, } from 'support/intercepts/linodes'; 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 Add-ons', () => { - // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms UI flow to create a Linode with backups using mock API data. * - Confirms that backups is reflected in create summary section. diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts new file mode 100644 index 00000000000..bb2b40b205d --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts @@ -0,0 +1,118 @@ +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; +import { getRegionById } from 'support/util/regions'; +import { linodeFactory } from '@src/factories'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; + +describe('Create Linode with DC-specific pricing', () => { + /* + * - 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 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'); + + ui.button.findByTitle('Create Linode').click(); + + // 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(); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]').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-linode-create-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}`); + cy.findByText('Shared CPU').click(); + cy.get(`[id="${dcPricingMockLinodeTypes[0].id}"]`).click(); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]').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-linode-create-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' + ); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts new file mode 100644 index 00000000000..cf8707c1ec9 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; +import { accountFactory, regionFactory } from '@src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/Encryption/Encryption'; + +describe('Create Linode with Disk Encryption', () => { + 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'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@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'); + + // 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', '@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/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 1659a07189c..70a759c140a 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -3,10 +3,6 @@ import { firewallFactory, firewallTemplateFactory, } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, @@ -19,19 +15,10 @@ import { } from 'support/intercepts/firewalls'; 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 Firewall', () => { - // 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 Firewall using mock API data. * - Confirms that Firewall is reflected in create summary section. 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 index 07a04310671..b613f8cf384 100644 --- 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 @@ -3,11 +3,6 @@ import { 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'; @@ -17,14 +12,6 @@ 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. 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 index 21096becdf3..4d3be85fd49 100644 --- 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 @@ -1,8 +1,4 @@ import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { mockCreateLinode, @@ -11,18 +7,10 @@ import { 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. 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 index 65361c25d01..d80d2a963b9 100644 --- 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 @@ -2,11 +2,6 @@ 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, @@ -18,14 +13,6 @@ 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. 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 index 238be908e06..44041fc9dc6 100644 --- 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 @@ -4,10 +4,6 @@ import { subnetFactory, vpcFactory, } from 'src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, @@ -21,7 +17,6 @@ import { } 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, @@ -32,14 +27,6 @@ import { 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. 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 bf86857ff8c..f57807153d6 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -9,15 +9,10 @@ import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; import { linodeCreatePage } from 'support/ui/pages'; import { authenticate } from 'support/api/authentication'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { interceptCreateLinode, mockCreateLinodeError, } from 'support/intercepts/linodes'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { interceptGetProfile } from 'support/intercepts/profile'; import { Region, VLAN, Config, Disk } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; @@ -62,15 +57,6 @@ describe('Create Linode', () => { cleanUp('ssh-keys'); }); - // Enable the `linodeCreateRefactor` feature flag. - // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. - beforeEach(() => { - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * End-to-end tests to create Linodes for each available plan type. */ 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 index 48ece191c37..cff1073aa83 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -47,11 +47,7 @@ import { } 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 { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { checkboxTestId, headerTestId, @@ -102,8 +98,8 @@ describe('create linode', () => { beforeEach(() => { mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - apicliDxToolsAdditions: makeFeatureFlagData(false), + linodeCreateRefactor: false, + apicliDxToolsAdditions: false, }); }); @@ -474,9 +470,8 @@ describe('create linode', () => { mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); mockAppendFeatureFlags({ - apicliDxToolsAdditions: makeFeatureFlagData(false), + apicliDxToolsAdditions: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetRegions([mockVPCRegion]).as('getRegions'); @@ -540,8 +535,8 @@ describe('create linode', () => { 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), - apicliDxToolsAdditions: makeFeatureFlagData(false), + linodeDiskEncryption: false, + apicliDxToolsAdditions: false, }).as('getFeatureFlags'); // Mock account response @@ -562,8 +557,8 @@ describe('create linode', () => { 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), - apicliDxToolsAdditions: makeFeatureFlagData(false), + linodeDiskEncryption: true, + apicliDxToolsAdditions: false, }).as('getFeatureFlags'); // Mock account response diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index ba2107cd62a..02e2cfc7e17 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -3,7 +3,6 @@ import { containsVisible, fbtVisible, getClick } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; -import { mockGetFeatureFlagClientstream } from 'support/intercepts/feature-flags'; import { interceptLinodeResize } from 'support/intercepts/linodes'; authenticate(); @@ -13,8 +12,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size: warm migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); - // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -40,7 +37,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size: cold migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the @@ -67,7 +63,6 @@ describe('resize linode', () => { }); it('resizes a linode by increasing size when offline: cold migration', () => { - mockGetFeatureFlagClientstream().as('getClientStream'); // Use `vlan_no_internet` security method. // This works around an issue where the Linode API responds with a 400 // when attempting to interact with it shortly after booting up when the diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts index e1f3804b6a5..afd0300cf3b 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts @@ -1,7 +1,219 @@ +/** + * @file Integration tests for Cloud Manager's events fetching and polling behavior. + */ + +import { mockGetEvents, mockGetEventsPolling } from 'support/intercepts/events'; +import { DateTime } from 'luxon'; import { eventFactory } from 'src/factories'; -import { mockGetEvents } from 'support/intercepts/events'; +import { randomNumber } from 'support/util/random'; +import { Interception } from 'cypress/types/net-stubbing'; import { mockGetVolumes } from 'support/intercepts/volumes'; +describe('Event fetching and polling', () => { + /** + * - Confirms that Cloud Manager makes a request to the events endpoint on page load. + * - Confirms API filters are applied to the request to limit the number and type of events retrieved. + */ + it('Makes initial fetch to events endpoint', () => { + const mockNow = DateTime.now(); + + mockGetEvents([]).as('getEvents'); + + cy.clock(mockNow.toJSDate()); + cy.visitWithLogin('/'); + cy.wait('@getEvents').then((xhr) => { + const filters = xhr.request.headers['x-filter']; + const lastWeekTimestamp = mockNow + .minus({ weeks: 1 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const timestampFilter = `"created":{"+gt":"${lastWeekTimestamp}"`; + + /* + * Confirm that initial fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Retrieve a maximum of 25 events. + * - Sort events by their created date. + * - Only retrieve events created within the past week. + */ + expect(filters).to.contain(timestampFilter); + expect(filters).to.contain('"+neq":"profile_update"'); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager makes subsequent events requests after the initial request. + * - Confirms API filters are applied to polling requests which differ from the initial request. + */ + it('Polls events endpoint after initial fetch', () => { + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEvents'); + cy.visitWithLogin('/'); + + cy.wait(['@getEvents', '@getEvents']); + cy.get('@getEvents.all').then((xhrRequests: unknown) => { + // Cypress types for `cy.get().then(...)` seem to be wrong. + // Types suggest that `cy.get()` can only yield a jQuery HTML element, but + // when the alias is an HTTP route it yields the request and response data. + const secondRequest = (xhrRequests as Interception[])[1]; + const filters = secondRequest.request.headers['x-filter']; + + /* + * Confirm that polling fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Only retrieve events created more recently than the most recent event in the initial fetch. + * - Exclude the most recent event that was included in the initial fetch. + * - Sort events by their ID (TODO). + */ + expect(filters).to.contain('"action":{"+neq":"profile_update"}'); + expect(filters).to.contain(`"created":{"+gte":"${mockEvent.created}"}`); + expect(filters).to.contain(`{"id":{"+neq":${mockEvent.id}}}]`); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 16 times per second. + * - Confirms that Cloud Manager makes a request to the events endpoint after 16 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 16 seconds have passed. + * - Confirms Cloud polling rate when there are no in-progress events. + */ + it('Polls events at a 16-second interval', () => { + // Expect Cloud to poll the events endpoint every 16 seconds, + // and configure the test to check if a request has been made + // every simulated second for 16 samples total. + const expectedPollingInterval = 16_000; + const pollingSamples = 16; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(mockNow.toJSDate()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than + // once every 16 seconds. + mockGetEventsPolling([mockEvent], mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 2 times per second when there are in-progress events. + * - Confirms that Cloud Manager makes a request to the events endpoint after 2 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 2 seconds have passed. + * - Confirms Cloud polling rate when there are in-progress events. + */ + it('Polls in-progress events at a 2-second interval', () => { + // When in-progress events are present, expect Cloud to poll the + // events endpoint every 2 seconds, and configure the test to check + // if a request has been made every simulated tenth of a second for + // 20 samples total. + const expectedPollingInterval = 2_000; + const pollingSamples = 20; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEventBasic = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + const mockEventInProgress = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now().minus({ minutes: 6 }).toISO(), + duration: 0, + rate: null, + percent_complete: 50, + }); + + const mockEvents = [mockEventBasic, mockEventInProgress]; + + // Visit Cloud Manager, and wait for Cloud to fire its first two + // requests to the `events` endpoint: the initial request, and the + // initial polling request. + mockGetEvents(mockEvents).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(Date.now()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than once + // every 2 seconds. + mockGetEventsPolling(mockEvents, mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); +}); + describe('Event Handlers', () => { it('invokes event handlers when new events are polled and makes the correct number of requests', () => { // See https://github.com/linode/manager/pull/10824 diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts new file mode 100644 index 00000000000..79c1638848f --- /dev/null +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts @@ -0,0 +1,309 @@ +/** + * @file Integration tests for Cloud Manager's events menu. + */ + +import { mockGetEvents, mockMarkEventSeen } from 'support/intercepts/events'; +import { ui } from 'support/ui'; +import { eventFactory } from 'src/factories'; +import { buildArray } from 'support/util/arrays'; +import { DateTime } from 'luxon'; +import { randomLabel, randomNumber } from 'support/util/random'; + +describe('Notifications Menu', () => { + /* + * - Confirms that the notification menu shows all events when 20 or fewer exist. + */ + it('Shows all recent events when there are 20 or fewer', () => { + const mockEvents = buildArray(randomNumber(1, 20), (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that all mocked events are shown in the notification menu. + mockEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`) + .scrollIntoView() + .should('be.visible'); + }); + }); + }); + + /* + * - Confirms that the notification menu shows no more than 20 events. + * - Confirms that only the most recently created events are shown. + */ + it('Shows the 20 most recently created events', () => { + const mockEvents = buildArray(25, (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + const shownEvents = mockEvents.slice(0, 20); + const hiddenEvents = mockEvents.slice(20); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that first 20 events in response are displayed. + shownEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`) + .scrollIntoView() + .should('be.visible'); + }); + + // Confirm that last 5 events in response are not displayed. + hiddenEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`).should('not.exist'); + }); + }); + }); + + /* + * - Confirms that notification menu contains a notice when no recent events exist. + */ + it('Shows notice when there are no recent events', () => { + mockGetEvents([]).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Use RegEx here to account for cases where the period is and is not present. + // Period is displayed in Notifications Menu v2, but omitted in v1. + cy.findByText(/No recent events to display\.?/).should('be.visible'); + }); + }); + + /* + * - Confirms that events in menu are marked as seen upon viewing. + * - Uses typical mock data setup where IDs are ordered (descending) and all create dates are unique. + * - Confirms that events are reflected in the UI as being seen or unseen. + */ + it('Marks events in menu as seen', () => { + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // The event with the highest ID is expected to come first in the array. + id: 5000 - index, + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // In this case, we know that the first event in the mocked events response + // will contain the highest event ID. + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is unseen. + cy.get('[data-qa-event-seen="false"]').should('have.length', 10); + }); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is now seen. + cy.get('[data-qa-event-seen="true"]').should('have.length', 10); + }); + }); + + /* + * - Confirms event seen logic for non-typical event ordering edge case. + * - Confirms that Cloud marks the correct event as seen even when it's not the first result. + */ + it('Marks events in menu as seen with duplicate created dates and out-of-order IDs', () => { + /* + * When several events are triggered simultaneously, they may have the + * same `created` timestamp. Cloud asks for events to be sorted by created + * date when fetching from the API, but when events have identical timestamps, + * there is no guarantee in which order they will be returned. + * + * As a result, we have to account for cases where the most recent event + * in reality (e.g. as determined by its ID) is not returned first by the API. + * This is especially relevant when marking events as 'seen', as we have + * to explicitly mark the event with the highest ID as seen when the user + * closes their notification menu. + */ + const createTime = DateTime.local().minus({ minutes: 2 }).toISO(); + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // Events are not guaranteed to be ordered by ID; simulate this by using random IDs. + id: randomNumber(1000, 9999), + action: 'linode_delete', + // To simulate multiple events occurring simultaneously, give all + // events the same created timestamp. + created: createTime, + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // Sort the mockEvents array by id in descending order to simulate API response + mockEvents.sort((a, b) => b.id - a.id); + + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens; we don't care about its contents. + cy.get('[data-qa-notification-menu]').should('be.visible'); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index aa7cf9ee38b..85688840e98 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -5,15 +5,11 @@ import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptGetAccessKeys, interceptCreateAccessKey, } from 'support/intercepts/object-storage'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -41,9 +37,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); cy.visitWithLogin('/object-storage/access-keys'); cy.wait('@getKeys'); @@ -136,9 +131,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index ea45c89d99b..3182a4c5bed 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -6,10 +6,7 @@ import { objectStorageKeyFactory, objectStorageBucketFactory, } from 'src/factories/objectStorage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, @@ -17,7 +14,6 @@ import { mockGetBucketsForRegion, mockUpdateAccessKey, } from 'support/intercepts/object-storage'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomDomainName, randomLabel, @@ -48,9 +44,8 @@ describe('object storage access keys smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); mockGetAccessKeys([]).as('getKeys'); mockCreateAccessKey(mockAccessKey).as('createKey'); @@ -120,9 +115,8 @@ describe('object storage access keys smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); // Mock initial GET request to include an access key. mockGetAccessKeys([accessKey]).as('getKeys'); @@ -172,9 +166,8 @@ describe('object storage access keys smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }); - mockGetFeatureFlagClientstream(); }); /* diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 240e88153bb..5073da56424 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -31,11 +31,7 @@ import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccessKeys } from 'support/intercepts/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. @@ -62,9 +58,8 @@ describe('Object Storage enrollment', () => { it('can enroll in Object Storage', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); const mockAccountSettings = accountSettingsFactory.build({ managed: false, diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 4e6604f32ee..1c17e3e7596 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -25,11 +25,7 @@ import { import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; // Message shown on-screen when user navigates to an empty bucket. const emptyBucketMessage = 'This bucket is empty.'; @@ -186,9 +182,8 @@ describe('object storage end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); cy.visitWithLogin('/object-storage'); cy.wait(['@getFeatureFlags', '@getBuckets', '@getNetworkUtilization']); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 29947ac1374..e189ad3a87d 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -16,11 +16,7 @@ import { mockUploadBucketObjectS3, mockCreateBucketError, } from 'support/intercepts/object-storage'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; import { accountFactory, regionFactory } from 'src/factories'; @@ -68,9 +64,8 @@ describe('object storage smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetRegions(mockRegions).as('getRegions'); mockGetBuckets([]).as('getBuckets'); @@ -89,7 +84,7 @@ describe('object storage smoke tests', () => { .within(() => { // Enter label. cy.contains('Label').click().type(mockBucket.label); - + cy.log(`${mockRegionWithObj.label}`); cy.contains('Region').click().type(mockRegionWithObj.label); ui.autocompletePopper @@ -167,10 +162,9 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), - gecko2: makeFeatureFlagData(false), + objMultiCluster: false, + gecko2: false, }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetBuckets([]).as('getBuckets'); @@ -305,9 +299,8 @@ describe('object storage smoke tests', () => { mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(false), + objMultiCluster: false, }); - mockGetFeatureFlagClientstream(); mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -359,9 +352,8 @@ describe('object storage smoke tests', () => { }) ); mockAppendFeatureFlags({ - objMultiCluster: makeFeatureFlagData(true), + objMultiCluster: true, }); - mockGetFeatureFlagClientstream(); mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index d70e3c33a3b..dce48e600f3 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -107,18 +107,10 @@ describe('Object Storage Gen2 create bucket tests', () => { * Confirms all endpoints are displayed regardless if there's multiple of the same type * Confirms S3 endpoint hostname displayed to differentiate between identical options in the dropdown */ - it('can create a bucket with endpoint type 0', () => { + it('can create a bucket with E0 endpoint type', () => { const endpointTypeE0 = 'Legacy (E0)'; const bucketLabel = randomLabel(); - //wait for the newly 'created' mocked bucket to appear - const mockBucket = objectStorageBucketFactoryGen2.build({ - label: bucketLabel, - region: mockRegion.id, - endpoint_type: 'E0', - s3_endpoint: undefined, - }); - mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ @@ -128,11 +120,6 @@ describe('Object Storage Gen2 create bucket tests', () => { region: mockRegion.id, }).as('createBucket'); - mockAppendFeatureFlags({ - objMultiCluster: true, - objectStorageGen2: { enabled: true }, - }).as('getFeatureFlags'); - mockGetObjectStorageEndpoints(mockEndpoints).as( 'getObjectStorageEndpoints' ); @@ -147,6 +134,13 @@ describe('Object Storage Gen2 create bucket tests', () => { '@getObjectStorageEndpoints', ]); + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E0', + s3_endpoint: undefined, + }); + ui.drawer .findByTitle('Create Bucket') .should('be.visible') @@ -191,6 +185,8 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm bucket rate limit table should not exist when E0 endpoint is selected cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); + mockGetBuckets([mockBucket]).as('getBuckets'); + ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -198,7 +194,7 @@ describe('Object Storage Gen2 create bucket tests', () => { .click(); }); - mockGetBuckets([mockBucket]).as('getBuckets'); + // Wait for the newly 'created' mocked bucket to appear cy.wait(['@getBuckets']); // Confirm request body has expected data @@ -239,20 +235,127 @@ describe('Object Storage Gen2 create bucket tests', () => { }); /** - * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E2 + * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E1 */ - it('can create a bucket with endpoint type 2', () => { - const endpointTypeE2 = 'Standard (E2)'; + it('can create a bucket with E1 endpoint type', () => { + const endpointTypeE1 = 'Standard (E1)'; const bucketLabel = randomLabel(); - //wait for the newly 'created' mocked bucket to appear + mockGetBuckets([]).as('getBuckets'); + mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); + mockCreateBucket({ + label: bucketLabel, + endpoint_type: 'E1', + cors_enabled: true, + region: mockRegion.id, + }).as('createBucket'); + + mockGetObjectStorageEndpoints(mockEndpoints).as( + 'getObjectStorageEndpoints' + ); + + mockGetRegions(mockRegions); + + cy.visitWithLogin('/object-storage/buckets/create'); + cy.wait([ + '@getFeatureFlags', + '@getBuckets', + '@getAccount', + '@getObjectStorageEndpoints', + ]); + const mockBucket = objectStorageBucketFactoryGen2.build({ label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E2', - s3_endpoint: undefined, + endpoint_type: 'E1', + s3_endpoint: 'us-sea-1.linodeobjects.com', + }); + + ui.drawer + .findByTitle('Create Bucket') + .should('be.visible') + .within(() => { + cy.findByText('Label').click().type(bucketLabel); + ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Object Storage Endpoint Type') + .should('be.visible') + .click(); + + // Select E1 endpoint + ui.autocompletePopper + .findByTitle('Standard (E1) us-sea-1.linodeobjects.com') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm bucket rate limits text for E1 endpoint + cy.findByText('Bucket Rate Limits').should('be.visible'); + cy.contains( + 'This endpoint type supports up to 750 Requests Per Second (RPS). Understand bucket rate limits' + ).should('be.visible'); + + // Confirm bucket rate limit table should not exist when E1 endpoint is selected + cy.get('[data-testid="bucket-rate-limit-table"]').should('not.exist'); + + mockGetBuckets([mockBucket]).as('getBuckets'); + + ui.buttonGroup + .findButtonByTitle('Create Bucket') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for the newly 'created' mocked bucket to appear + cy.wait(['@getBuckets']); + + // Confirm request body has expected data + cy.wait('@createBucket').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['endpoint_type']).to.equal('E1'); + expect(requestPayload['cors_enabled']).to.equal(true); + expect(requestPayload['s3_endpoint']).to.equal( + 'us-sea-1.linodeobjects.com' + ); }); + ui.drawer.find().should('not.exist'); + + // Confirm that bucket is created, initiate deletion for cleanup + cy.findByText(endpointTypeE1).should('be.visible'); + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockRegion.label).should('be.visible'); + ui.button.findByTitle('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm bucket gets deleted + mockGetBuckets([]).as('getBuckets'); + cy.wait(['@deleteBucket', '@getBuckets']); + cy.findByText(bucketLabel).should('not.exist'); + }); + + /** + * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E2 + */ + it('can create a bucket with E2 endpoint type', () => { + const endpointTypeE2 = 'Standard (E2)'; + const bucketLabel = randomLabel(); + mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ @@ -276,6 +379,13 @@ describe('Object Storage Gen2 create bucket tests', () => { '@getObjectStorageEndpoints', ]); + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E2', + s3_endpoint: undefined, + }); + ui.drawer .findByTitle('Create Bucket') .should('be.visible') @@ -305,6 +415,8 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm that basic rate limits table is displayed checkRateLimitsTable(mockBucket.endpoint_type!); + mockGetBuckets([mockBucket]).as('getBuckets'); + ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -312,7 +424,7 @@ describe('Object Storage Gen2 create bucket tests', () => { .click(); }); - mockGetBuckets([mockBucket]).as('getBuckets'); + // Wait for the newly 'created' mocked bucket to appear cy.wait(['@getBuckets']); // Confirm request body has expected data @@ -355,18 +467,10 @@ describe('Object Storage Gen2 create bucket tests', () => { /** * Confirms UI flow for creating a gen2 Object Storage bucket with endpoint E3 */ - it('can create a bucket with endpoint type 3', () => { + it('can create a bucket with E3 endpoint type', () => { const endpointTypeE3 = 'Standard (E3)'; const bucketLabel = randomLabel(); - //wait for the newly 'created' mocked bucket to appear - const mockBucket = objectStorageBucketFactoryGen2.build({ - label: bucketLabel, - region: mockRegion.id, - endpoint_type: 'E3', - s3_endpoint: undefined, - }); - mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ @@ -390,6 +494,13 @@ describe('Object Storage Gen2 create bucket tests', () => { '@getObjectStorageEndpoints', ]); + const mockBucket = objectStorageBucketFactoryGen2.build({ + label: bucketLabel, + region: mockRegion.id, + endpoint_type: 'E3', + s3_endpoint: undefined, + }); + ui.drawer .findByTitle('Create Bucket') .should('be.visible') @@ -420,6 +531,8 @@ describe('Object Storage Gen2 create bucket tests', () => { // Confirm that basic rate limits table is displayed checkRateLimitsTable(mockBucket.endpoint_type!); + mockGetBuckets([mockBucket]).as('getBuckets'); + ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -427,7 +540,7 @@ describe('Object Storage Gen2 create bucket tests', () => { .click(); }); - mockGetBuckets([mockBucket]).as('getBuckets'); + // Wait for the newly 'created' mocked bucket to appear cy.wait(['@getBuckets']); // Confirm request body has expected data diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index f7aa759ec34..f453f6b7153 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -1,37 +1,27 @@ -import { containsClick, containsVisible } from 'support/helpers'; +import { containsVisible } from 'support/helpers'; import { ui } from 'support/ui'; -import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; import { interceptGetStackScripts, + mockGetStackScript, mockGetStackScripts, } from 'support/intercepts/stackscripts'; -import { interceptCreateLinode } from 'support/intercepts/linodes'; +import { mockCreateLinode } from 'support/intercepts/linodes'; import { filterOneClickApps, handleAppLabel, } from 'src/features/Linodes/LinodesCreate/utilities'; import { randomLabel, 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 { mapStackScriptLabelToOCA } from 'src/features/OneClickApps/utils'; import { stackScriptFactory } from 'src/factories/stackscripts'; import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import type { StackScript } from '@linode/api-v4'; import type { OCA } from '@src/features/OneClickApps/types'; - -authenticate(); +import { imageFactory, linodeFactory } from 'src/factories'; +import { mockGetAllImages } from 'support/intercepts/images'; describe('OneClick Apps (OCA)', () => { - before(() => { - cleanUp(['linodes']); - }); - it('Lists all the OneClick Apps', () => { interceptGetStackScripts().as('getStackScripts'); @@ -123,9 +113,19 @@ describe('OneClick Apps (OCA)', () => { }); it('Deploys a Linode from a One Click App', () => { - const stackscriptId = 401709; - const stackScripts = stackScriptFactory.build({ - id: stackscriptId, + const images = [ + imageFactory.build({ + id: 'linode/ubuntu22.04', + label: 'Ubuntu 20.04', + }), + imageFactory.build({ + id: 'linode/debian11', + label: 'Debian 11', + }), + ]; + + const stackscript = stackScriptFactory.build({ + id: 0, username: 'linode', user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142', label: 'E2E Test App', @@ -160,26 +160,25 @@ describe('OneClick Apps (OCA)', () => { ], }); - const firstName = randomLabel(); - const password = randomString(16); - const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); - const region = chooseRegion({ capabilities: ['Vlans'] }); + const region = chooseRegion(); const linodeLabel = randomLabel(); + + // UDF values + const firstName = randomLabel(); + const password = randomString(16); const levelName = 'Get the enderman!'; - mockGetStackScripts([stackScripts]).as('getStackScripts'); - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData(false), - oneClickApps: makeFeatureFlagData({ - 401709: 'E2E Test App', - }), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); + const linode = linodeFactory.build({ + label: linodeLabel, + }); + + mockGetAllImages(images); + mockGetStackScripts([stackscript]).as('getStackScripts'); + mockGetStackScript(stackscript.id, stackscript); cy.visitWithLogin(`/linodes/create?type=One-Click`); - cy.wait('@getFeatureFlags'); cy.wait('@getStackScripts'); cy.findByTestId('one-click-apps-container').within(() => { @@ -188,40 +187,43 @@ describe('OneClick Apps (OCA)', () => { // Check that the app is listed and select it cy.get('[data-qa-selection-card="true"]').should('have.length', 3); - cy.get(`[id=app-${stackscriptId}]`).first().should('be.visible').click(); + cy.findAllByText(stackscript.label).first().should('be.visible').click(); }); - // Input the user defined fields - const userFieldId = - "the-username-for-the-linode's-non-root-admin/ssh-user(must-be-lowercase)"; - const passwordFieldId = - "the-password-for-the-linode's-non-root-admin/ssh-user"; - const levelNameFieldId = 'world-name'; + cy.findByLabelText( + "The username for the Linode's non-root admin/SSH user(must be lowercase) (required)" + ) + .should('be.visible') + .click() + .type(firstName); + + cy.findByLabelText( + "The password for the Linode's non-root admin/SSH user (required)" + ) + .should('be.visible') + .click() + .type(password); - cy.findByTestId('user-defined-fields-panel').within(() => { - cy.get(`[id="${userFieldId}"]`) - .should('be.visible') - .click() - .type(`${firstName}{enter}`); - cy.get(`[id="${passwordFieldId}"]`) - .should('be.visible') - .click() - .type(`${password}{enter}`); - cy.get(`[id="${levelNameFieldId}"]`) - .should('be.visible') - .click() - .type(`${levelName}{enter}`); + cy.findByLabelText('World Name (required)') + .should('be.visible') + .click() + .type(levelName); - // Check each field should persist when moving onto another field - cy.get(`[id="${userFieldId}"]`).should('have.value', firstName); - cy.get(`[id="${passwordFieldId}"]`).should('have.value', password); - cy.get(`[id="${levelNameFieldId}"]`).should('have.value', levelName); - }); + // Check each field should persist when moving onto another field + cy.findByLabelText( + "The username for the Linode's non-root admin/SSH user(must be lowercase) (required)" + ).should('have.value', firstName); + + cy.findByLabelText( + "The password for the Linode's non-root admin/SSH user (required)" + ).should('have.value', password); + + cy.findByLabelText('World Name (required)').should('have.value', levelName); // Choose an image - cy.get('[data-qa-enhanced-select="Choose an image"]').within(() => { - containsClick('Choose an image').type(`${image}{enter}`); - }); + cy.findByPlaceholderText('Choose an image') + .click() + .type('{downArrow}{enter}'); // Choose a region ui.regionSelect.find().click().type(`${region.id}{enter}`); @@ -237,14 +239,14 @@ describe('OneClick Apps (OCA)', () => { cy.findByText('Linode Label') .should('be.visible') .click() - .type('{selectAll}{backspace}') .type(linodeLabel); // Choose a Root Password cy.get('[id="root-password"]').type(rootPassword); // Create the Linode - interceptCreateLinode().as('createLinode'); + mockCreateLinode(linode).as('createLinode'); + ui.button .findByTitle('Create Linode') .should('be.visible') @@ -252,6 +254,7 @@ describe('OneClick Apps (OCA)', () => { .click(); cy.wait('@createLinode'); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + + ui.toast.assertMessage(`Your Linode ${linode.label} is being created.`); }); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts new file mode 100644 index 00000000000..ddfbdab9566 --- /dev/null +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -0,0 +1,211 @@ +import { + accountFactory, + appTokenFactory, + profileFactory, +} from '@src/factories'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { DateTime } from 'luxon'; +import { + mockGetAccount, + mockGetChildAccounts, + mockGetUser, +} from 'support/intercepts/account'; +import { + mockCreatePersonalAccessToken, + mockGetAppTokens, + mockGetPersonalAccessTokens, + mockGetProfile, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; + +const mockParentAccount = accountFactory.build({ + company: 'Parent Company', +}); + +const mockParentProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'parent', +}); + +const mockParentUser = accountUserFactory.build({ + username: mockParentProfile.username, + user_type: 'parent', +}); + +const mockChildAccount = accountFactory.build({ + company: 'Partner Company', +}); + +const mockParentAccountToken = appTokenFactory.build({ + id: randomNumber(), + created: DateTime.now().toISO(), + expiry: DateTime.now().plus({ minutes: 15 }).toISO(), + label: `${mockParentAccount.company}_proxy`, + scopes: '*', + token: randomString(32), + website: undefined, + thumbnail_url: undefined, +}); + +describe('Token scopes', () => { + /* + * Confirm that the “Child account access” grant is not visible in the list of permissions. + * Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + */ + it('Token scopes for parent user with restricted access', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetProfile({ ...mockParentProfile, restricted: true }); + mockGetUser(mockParentUser); + + mockGetPersonalAccessTokens([]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockCreatePersonalAccessToken(mockParentAccountToken).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait(['@getTokens', '@getAppTokens']); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetPersonalAccessTokens([mockParentAccountToken]).as('getTokens'); + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access').should('not.exist'); + + // Specify ALL scopes by selecting the "No Access" Select All radio button. + cy.get('[data-qa-perm-rw-radio]').click(); + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and re-submit. + cy.findByLabelText('Label') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click() + .type(mockParentAccountToken.label); + + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that PAT secret dialog is shown and close it. + cy.wait('@createToken'); + ui.dialog + .findByTitle('Personal Access Token') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('I Have Saved My Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT is shown in list and "View Scopes" drawer works. + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(mockParentAccountToken.scopes); + }); + }); + + /* + * Confirm that the “Child account access” grant is visible in the list of permissions. + * Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + */ + it('Token scopes for parent user with unrestricted access', () => { + mockGetProfile(mockParentProfile); + mockGetAccount(mockParentAccount); + mockGetChildAccounts([mockChildAccount]); + mockGetProfile({ ...mockParentProfile, restricted: false }); + mockGetUser(mockParentUser); + + mockGetPersonalAccessTokens([]).as('getTokens'); + mockGetAppTokens([]).as('getAppTokens'); + mockCreatePersonalAccessToken(mockParentAccountToken).as('createToken'); + + cy.visitWithLogin('/profile/tokens'); + cy.wait(['@getTokens', '@getAppTokens']); + + // Click create button, fill out and submit PAT create form. + ui.button + .findByTitle('Create a Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetPersonalAccessTokens([mockParentAccountToken]).as('getTokens'); + ui.drawer + .findByTitle('Add Personal Access Token') + .should('be.visible') + .within(() => { + // Confirm that the “Child account access” grant is not visible in the list of permissions. + cy.findAllByText('Child Account Access') + .scrollIntoView() + .should('be.visible'); + + // Specify ALL scopes by selecting the "No Access" Select All radio button. + cy.get('[data-qa-perm-rw-radio]').click(); + cy.get('[data-qa-perm-rw-radio]').should( + 'have.attr', + 'data-qa-radio', + 'true' + ); + + // Specify a label and re-submit. + cy.findByLabelText('Label') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click() + .type(mockParentAccountToken.label); + + ui.buttonGroup + .findButtonByTitle('Create Token') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that PAT secret dialog is shown and close it. + cy.wait('@createToken'); + ui.dialog + .findByTitle('Personal Access Token') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('I Have Saved My Personal Access Token') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that new PAT is shown in list and "View Scopes" drawer works. + // Upon clicking “Create Token”, assert that the outgoing API request payload contains "scopes" value as defined in token. + cy.wait('@getTokens').then((xhr) => { + const actualTokenData = xhr.response?.body.data; + const actualTokenScopes = actualTokenData[0].scopes; + expect(actualTokenScopes).to.equal(mockParentAccountToken.scopes); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts index b1573679bdc..2484db5c322 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory, @@ -25,8 +20,6 @@ import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/Place import { linodeCreatePage } from 'support/ui/pages'; import { extendRegion } from 'support/util/regions'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); const mockNewarkRegion = extendRegion( @@ -53,13 +46,6 @@ describe('Linode create flow with Placement Group', () => { beforeEach(() => { mockGetAccount(mockAccount); mockGetRegions(mockRegions).as('getRegions'); - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - linodeCreateRefactor: makeFeatureFlagData( - false - ), - }); - mockGetFeatureFlagClientstream(); }); /* @@ -194,16 +180,16 @@ describe('Linode create flow with Placement Group', () => { }); // Confirm the Placement group assignment is accounted for in the summary. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText('Assigned to Placement Group').should('be.visible'); - }); + cy.findByText('Assigned to Placement Group') + .scrollIntoView() + .should('be.visible'); // Type in a label, password and submit the form. mockCreateLinode(mockLinode).as('createLinode'); cy.get('#linode-label').clear().type('linode-with-placement-group'); cy.get('#root-password').type(randomString(32)); - cy.get('[data-qa-deploy-linode]').click(); + cy.findByText('Create Linode').should('be.enabled').click(); // Wait for outgoing API request and confirm that payload contains expected data. cy.wait('@createLinode').then((xhr) => { @@ -265,9 +251,9 @@ describe('Linode create flow with Placement Group', () => { .click(); // Confirm the Placement group assignment is accounted for in the summary. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText('Assigned to Placement Group').should('be.visible'); - }); + cy.findByText('Assigned to Placement Group') + .scrollIntoView() + .should('be.visible'); // Create Linode and confirm contents of outgoing API request payload. ui.button diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts index 39069134862..e114bf576de 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -2,7 +2,6 @@ import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory, placementGroupFactory } from 'src/factories'; import { regionFactory } from 'src/factories'; import { ui } from 'support/ui/'; - import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 90890095375..0c52a148848 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -22,7 +22,6 @@ import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; - import type { Linode } from '@linode/api-v4'; const mockAccount = accountFactory.build(); diff --git a/packages/manager/cypress/support/intercepts/events.ts b/packages/manager/cypress/support/intercepts/events.ts index eea7b5fac12..0e12a2f8fb9 100644 --- a/packages/manager/cypress/support/intercepts/events.ts +++ b/packages/manager/cypress/support/intercepts/events.ts @@ -2,9 +2,11 @@ * @file Mocks and intercepts related to notification and event handling. */ -import type { Event, Notification } from '@linode/api-v4'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { Event, Notification } from '@linode/api-v4'; /** * Intercepts GET request to fetch events and mocks response. @@ -21,6 +23,50 @@ export const mockGetEvents = (events: Event[]): Cypress.Chainable => { ); }; +/** + * Intercepts polling GET request to fetch events and mocks response. + * + * Unlike `mockGetEvents`, this utility only intercepts outgoing requests that + * occur while Cloud Manager is polling for events. + * + * @param events - Array of Events with which to mock response. + * @param pollingTimestamp - Timestamp to find when identifying polling requests. + * + * @returns Cypress chainable. + */ +export const mockGetEventsPolling = ( + events: Event[], + pollingTimestamp: string +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('account/events*'), (req) => { + console.log({ headers: req.headers }); + if ( + req.headers['x-filter'].includes( + `{"created":{"+gte":"${pollingTimestamp}"}}` + ) + ) { + req.reply(paginateResponse(events)); + } else { + req.continue(); + } + }); +}; + +/** + * Intercepts POST request to mark an event as seen and mocks response. + * + * @param eventId - ID of the event for which to intercept request. + * + * @returns Cypress chainable. + */ +export const mockMarkEventSeen = (eventId: number): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/events/${eventId}/seen`), + makeResponse({}) + ); +}; + /** * Intercepts GET request to fetch notifications and mocks response. * diff --git a/packages/manager/cypress/support/plugins/fetch-account.ts b/packages/manager/cypress/support/plugins/fetch-account.ts index 3de8553f7b8..0262c5d3f16 100644 --- a/packages/manager/cypress/support/plugins/fetch-account.ts +++ b/packages/manager/cypress/support/plugins/fetch-account.ts @@ -1,5 +1,14 @@ import type { CypressPlugin } from './plugin'; -import { getAccountInfo, getAccountSettings } from '@linode/api-v4'; +import { resolve, join } from 'path'; +import { getAccountInfo, getAccountSettings, getProfile } from '@linode/api-v4'; +import { readFileSync } from 'fs'; + +import type { Account } from '@linode/api-v4'; + +/** + * The name of the environment variable that controls account cache reading. + */ +const envVarName = 'CY_TEST_ACCOUNT_CACHE_DIR'; /** * Fetches and caches Linode account info and settings. @@ -8,16 +17,78 @@ import { getAccountInfo, getAccountSettings } from '@linode/api-v4'; * `cloudManagerAccountSettings` env, respectively. */ export const fetchAccount: CypressPlugin = async (_on, config) => { - const [account, accountSettings] = await Promise.all([ - getAccountInfo(), + // Fetch profile and account settings first, since there is a comparatively + // low likelihood that these requests will fail. + const [profile, accountSettings] = await Promise.all([ + getProfile(), getAccountSettings(), ]); + /** + * Cached account data for the desired test account if it is available, or `undefined`. + */ + const accountCacheData = (() => { + if (!config.env[envVarName]) { + return undefined; + } + + const accountCacheDir = config.env[envVarName]; + const accountCachePath = resolve( + join(accountCacheDir, `${profile.uid}.json`) + ); + + try { + const cacheJson = readFileSync(accountCachePath, 'utf8'); + const cacheData = JSON.parse(cacheJson); + + if ('account' in cacheData) { + const accountCache = cacheData['account'] as Account; + return accountCache; + } + } catch (e) { + // TODO Error message. + console.error(`Failed to read account cache file at ${accountCachePath}`); + if ('message' in e) { + console.error(e.message); + } + return undefined; + } + + return undefined; + })(); + + // Fetch account info, falling back to offline cached data if it is + // enabled and available. + let account: undefined | Account = undefined; + try { + account = await getAccountInfo(); + } catch (e) { + console.error( + 'An error occurred while retrieving test account information.' + ); + + // Re-throw the error if no cached account data is available, because the + // test run cannot continue. + if (!accountCacheData) { + throw e; + } + // Otherwise, note that the original account fetch failed and that the tests + // will be proceeding using cached data. + else { + if (e.message) { + console.error(e.message); + } + console.info( + 'Cached account data is available and will be used instead.' + ); + } + } + return { ...config, env: { ...config.env, - cloudManagerAccount: account, + cloudManagerAccount: account || accountCacheData, cloudManagerAccountSettings: accountSettings, }, }; diff --git a/packages/manager/cypress/support/ui/app-bar.ts b/packages/manager/cypress/support/ui/app-bar.ts new file mode 100644 index 00000000000..163e395760b --- /dev/null +++ b/packages/manager/cypress/support/ui/app-bar.ts @@ -0,0 +1,13 @@ +/** + * UI helpers for Cloud Manager top app bar. + */ +export const appBar = { + /** + * Finds the app bar. + * + * @returns Cypress chainable. + */ + find: () => { + return cy.get('[data-qa-appbar]'); + }, +}; diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 26d8b89ac17..1cba6746bcb 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -1,5 +1,6 @@ import * as accordion from './accordion'; import * as actionMenu from './action-menu'; +import * as appBar from './app-bar'; import * as autocomplete from './autocomplete'; import * as breadcrumb from './breadcrumb'; import * as buttons from './buttons'; @@ -21,6 +22,7 @@ import * as userMenu from './user-menu'; export const ui = { ...accordion, ...actionMenu, + ...appBar, ...autocomplete, ...breadcrumb, ...buttons, diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx new file mode 100644 index 00000000000..1c3aebab2b3 --- /dev/null +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.stories.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import { DisplayPrice } from './DisplayPrice'; + +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; + +type Story = StoryObj; + +const meta: Meta = { + component: DisplayPrice, + decorators: [ + (Story: StoryFn) => ( +
+ +
+ ), + ], + title: 'Components/DisplayPrice', +}; + +export default meta; + +export const Default: Story = { + args: { + price: 99, + }, + render: (args) => , +}; + +export const WithFontSize: Story = { + args: { + ...Default.args, + fontSize: '2rem', + }, +}; + +export const WithDecimalPlaces: Story = { + args: { + ...Default.args, + decimalPlaces: 1, + }, +}; + +export const WithInterval: Story = { + args: { + ...Default.args, + interval: 'mo', + }, +}; diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx index 07bacae6467..134d880c231 100644 --- a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx @@ -5,9 +5,24 @@ import { Currency } from 'src/components/Currency'; import { Typography } from 'src/components/Typography'; export interface DisplayPriceProps { + /** + * The number of decimal places to display in the price. + */ decimalPlaces?: number; + /** + * The font size of the displayed price. + */ fontSize?: string; + /** + * The format interval to use for price formatting. + * @example 'mo' + * @example 'month' + * @example 'year' + */ interval?: string; + /** + * The price to display. + */ price: '--.--' | number; } diff --git a/packages/manager/src/features/Events/EventMessage.tsx b/packages/manager/src/features/Events/EventMessage.tsx deleted file mode 100644 index d555fb04eb1..00000000000 --- a/packages/manager/src/features/Events/EventMessage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -interface MessageLinkEntity { - message: null | string; -} - -/** - * Renders a message with inline code blocks. - * Meant to be used in the context of an event message. - * This component is only used to render {e.message} in the case of potential ticks we want to render as
.
- */
-export const EventMessage = (props: MessageLinkEntity) => {
-  const { message } = props;
-
-  if (!message) {
-    return null;
-  }
-
-  return formatTicks(message);
-};
-
-const formatTicks = (message: string): JSX.Element => {
-  const parts = message.split(/(`[^`]*`)/g);
-
-  return (
-    <>
-      {parts.map((part, i) =>
-        part.startsWith('`') && part.endsWith('`') ? (
-          {part.slice(1, -1)}
-        ) : (
-          part
-        )
-      )}
-    
-  );
-};
-
-const StyledPre = styled('pre')(({ theme }) => ({
-  backgroundColor: theme.name === 'dark' ? '#222' : '#f4f4f4',
-  borderRadius: 4,
-  display: 'inline',
-  fontSize: '0.75rem',
-  padding: '0.15rem 0.25rem',
-}));
diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx
index 35ad2beb4cf..ae39354f47c 100644
--- a/packages/manager/src/features/Events/EventsMessages.stories.tsx
+++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx
@@ -22,7 +22,7 @@ const event: Event = eventFactory.build({
     type: 'linode',
     url: 'https://google.com',
   },
-  message: 'message with a `ticked` word',
+  message: 'message with a `ticked` word - please contact Support',
   secondary_entity: {
     id: 1,
     label: '{secondary entity}',
diff --git a/packages/manager/src/features/Events/EventMessage.test.tsx b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx
similarity index 50%
rename from packages/manager/src/features/Events/EventMessage.test.tsx
rename to packages/manager/src/features/Events/FormattedEventMessage.test.tsx
index cf0b7ad69f9..62463801db6 100644
--- a/packages/manager/src/features/Events/EventMessage.test.tsx
+++ b/packages/manager/src/features/Events/FormattedEventMessage.test.tsx
@@ -2,18 +2,20 @@ import * as React from 'react';
 
 import { renderWithTheme } from 'src/utilities/testHelpers';
 
-import { EventMessage } from './EventMessage';
+import { FormattedEventMessage } from './FormattedEventMessage';
 
-describe('EventMessage', () => {
+describe('FormattedEventMessage', () => {
   it('renders null when message is null', () => {
-    const { queryByRole } = renderWithTheme();
+    const { queryByRole } = renderWithTheme(
+      
+    );
 
     expect(queryByRole('pre')).toBeNull();
   });
 
   it('renders message without ticks as plain text', () => {
     const { getByText, queryByRole } = renderWithTheme(
-      
+      
     );
 
     expect(getByText('Hello, world!')).toBeInTheDocument();
@@ -22,10 +24,22 @@ describe('EventMessage', () => {
 
   it('renders message with ticks as inline code blocks', () => {
     const { container, getByText } = renderWithTheme(
-      
+      
     );
 
     expect(getByText(/Hello,/)).toBeInTheDocument();
     expect(container.querySelector('pre')).toHaveTextContent('world');
   });
+
+  it('converts contact support links', () => {
+    const { container, getByText } = renderWithTheme(
+      
+    );
+
+    expect(getByText('contact Support')).toBeInTheDocument();
+    expect(container.querySelector('a')).toHaveAttribute(
+      'href',
+      '/support/tickets'
+    );
+  });
 });
diff --git a/packages/manager/src/features/Events/FormattedEventMessage.tsx b/packages/manager/src/features/Events/FormattedEventMessage.tsx
new file mode 100644
index 00000000000..6a0677a7d75
--- /dev/null
+++ b/packages/manager/src/features/Events/FormattedEventMessage.tsx
@@ -0,0 +1,66 @@
+import { styled } from '@mui/material/styles';
+import * as React from 'react';
+
+import { SupportLink } from 'src/components/SupportLink';
+
+interface MessageLinkEntity {
+  message: null | string;
+}
+
+/**
+ * Renders a message with inline code blocks.
+ * Meant to be used in the context of an event message.
+ * This component is only used to render {e.message} as JSX in order to:
+ *  - render ticks as 
.
+ *  - render "contact support" strings as .
+ */
+export const FormattedEventMessage = (props: MessageLinkEntity) => {
+  const { message } = props;
+
+  if (!message) {
+    return null;
+  }
+
+  return formatMessage(message);
+};
+
+const formatMessage = (message: string): JSX.Element => {
+  const parts = message.split(/(`[^`]*`)/g);
+  const supportLinkMatch = /(contact support)/i;
+
+  return (
+    <>
+      {parts.map((part, i) => {
+        let formattedPart: JSX.Element | string = part;
+
+        if (part.startsWith('`') && part.endsWith('`')) {
+          formattedPart = (
+            {part.slice(1, -1)}
+          );
+        }
+
+        if (part.match(supportLinkMatch)) {
+          const [before, linkText, after] = part.split(supportLinkMatch);
+
+          formattedPart = (
+            
+              {before}
+              
+              {after}
+            
+          );
+        }
+
+        return formattedPart;
+      })}
+    
+  );
+};
+
+const StyledPre = styled('pre')(({ theme }) => ({
+  backgroundColor: theme.name === 'dark' ? '#222' : '#f4f4f4',
+  borderRadius: 4,
+  display: 'inline',
+  fontSize: '0.75rem',
+  padding: '0.15rem 0.25rem',
+}));
diff --git a/packages/manager/src/features/Events/factories/domain.tsx b/packages/manager/src/features/Events/factories/domain.tsx
index c6b02034ca3..c29d2e95c27 100644
--- a/packages/manager/src/features/Events/factories/domain.tsx
+++ b/packages/manager/src/features/Events/factories/domain.tsx
@@ -1,7 +1,7 @@
 import * as React from 'react';
 
 import { EventLink } from '../EventLink';
-import { EventMessage } from '../EventMessage';
+import { FormattedEventMessage } from '../FormattedEventMessage';
 
 import type { PartialEventMap } from '../types';
 
@@ -32,8 +32,8 @@ export const domain: PartialEventMap<'domain'> = {
   domain_record_create: {
     notification: (e) => (
       <>
-         has been added to{' '}
-        .
+         has been{' '}
+        added to .
       
     ),
   },
@@ -48,16 +48,16 @@ export const domain: PartialEventMap<'domain'> = {
   domain_record_update: {
     notification: (e) => (
       <>
-         has been updated{' '}
-        for .
+         has been{' '}
+        updated for .
       
     ),
   },
   domain_record_updated: {
     notification: (e) => (
       <>
-         has been updated{' '}
-        for .
+         has been{' '}
+        updated for .
       
     ),
   },
diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx
index a9912b6ab87..0cc07e47362 100644
--- a/packages/manager/src/features/Events/factories/image.tsx
+++ b/packages/manager/src/features/Events/factories/image.tsx
@@ -1,6 +1,7 @@
 import * as React from 'react';
 
 import { EventLink } from '../EventLink';
+import { FormattedEventMessage } from '../FormattedEventMessage';
 
 import type { PartialEventMap } from '../types';
 
@@ -44,12 +45,17 @@ export const image: PartialEventMap<'image'> = {
     ),
   },
   image_upload: {
-    failed: (e) => (
-      <>
-        Image  could not be{' '}
-        uploaded: {e?.message?.replace(/(\d+)/g, '$1 MB')}.
-      
-    ),
+    failed: (e) => {
+      const message = e?.message?.replace(/(\d+)/g, '$1 MB') || '';
+
+      return (
+        <>
+          Image  could not{' '}
+          be uploaded:{' '}
+          .
+        
+      );
+    },
 
     finished: (e) => (
       <>
diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
index 7047a89e20a..aac0cc5349c 100644
--- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
+++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx
@@ -158,7 +158,8 @@ describe('CreateImageTab', () => {
 
     // Verify distributed compute region notice renders
     await findByText(
-      'This Linode is in a distributed compute region. Images captured from this Linode will be stored in the closest core site.'
+      "This Linode is in a distributed compute region. These regions can't store images.",
+      { exact: false }
     );
   });
 
diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
index 2b3de896aa9..5c78d7b4ad0 100644
--- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
+++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx
@@ -16,7 +16,6 @@ import { Notice } from 'src/components/Notice/Notice';
 import { Paper } from 'src/components/Paper';
 import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils';
 import { Stack } from 'src/components/Stack';
-import { SupportLink } from 'src/components/SupportLink';
 import { TagsInput } from 'src/components/TagsInput/TagsInput';
 import { TextField } from 'src/components/TextField';
 import { TooltipIcon } from 'src/components/TooltipIcon';
@@ -190,27 +189,14 @@ export const CreateImageTab = () => {
           
             Select Linode & Disk
             
-              By default, Linode images are limited to 6144 MB of data per disk.
-              Ensure your content doesn’t exceed this limit, or{' '}
-              {' '}
-              to request a higher limit. Additionally, images can’t be
-              created from a raw disk or a disk that’s formatted using a
-              custom file system.
+              Custom images are billed monthly, at $.10/GB. The disk you target
+              for an image needs to meet specific{' '}
+              
+                requirements
+              
+              .
             
-            {linodeIsInDistributedRegion && (
-              
-                This Linode is in a distributed compute region. Images captured
-                from this Linode will be stored in the closest core site.
-              
-            )}
+
              {
               required
               value={selectedLinodeId}
             />
+            {linodeIsInDistributedRegion && (
+              
+                This Linode is in a distributed compute region. These regions
+                can't store images. The image is stored in the core compute
+                region that is{' '}
+                
+                  geographically closest
+                
+                . After it's stored, you can replicate it to other core compute
+                regions.
+              
+            )}
             {showDiskEncryptionWarning && (
               
                  ({ fontFamily: theme.font.normal })}>
@@ -377,20 +375,6 @@ export const CreateImageTab = () => {
               control={control}
               name="description"
             />
-            
-              Custom Images are billed at $0.10/GB per month.{' '}
-              
-                Learn more about requirements and considerations.{' '}
-              
-              For information about how to check and clean a Linux
-              system’s disk space,{' '}
-              
-                read this guide.
-              
-            
           
         
          {
             />
           )}
           
-            
+            
               Image Details
             
+            
+              Custom images are billed monthly, at $.10/GB. An uploaded image
+              file needs to meet specific{' '}
+              
+                requirements
+              
+              .
+            
             {form.formState.errors.root?.message && (
                {
                   errorText={fieldState.error?.message}
                   inputRef={field.ref}
                   label="Label"
-                  noMarginTop
                   onBlur={field.onBlur}
                   onChange={field.onChange}
                   value={field.value ?? ''}
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
index f5be4d8398f..0d6f2124885 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx
@@ -6,13 +6,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers';
 import { HttpResponse, http, server } from 'src/mocks/testServer';
 import { renderWithTheme } from 'src/utilities/testHelpers';
 
-import { ManageImageRegionsForm } from './ManageImageRegionsForm';
+import { ManageImageReplicasForm } from './ManageImageRegionsForm';
 
 describe('ManageImageRegionsDrawer', () => {
   it('should render a save button and a cancel button', () => {
     const image = imageFactory.build();
     const { getByText } = renderWithTheme(
-      
+      
     );
 
     const cancelButton = getByText('Cancel').closest('button');
@@ -49,7 +49,7 @@ describe('ManageImageRegionsDrawer', () => {
     );
 
     const { findByText } = renderWithTheme(
-      
+      
     );
 
     await findByText('US, Newark, NJ');
@@ -59,8 +59,16 @@ describe('ManageImageRegionsDrawer', () => {
   });
 
   it('should render a status of "unsaved" when a new region is selected', async () => {
-    const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' });
-    const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' });
+    const region1 = regionFactory.build({
+      capabilities: ['Object Storage'],
+      id: 'us-east',
+      label: 'Newark, NJ',
+    });
+    const region2 = regionFactory.build({
+      capabilities: ['Object Storage'],
+      id: 'us-west',
+      label: 'Place, CA',
+    });
 
     const image = imageFactory.build({
       regions: [
@@ -78,7 +86,7 @@ describe('ManageImageRegionsDrawer', () => {
     );
 
     const { findByText, getByLabelText, getByText } = renderWithTheme(
-      
+      
     );
 
     const saveButton = getByText('Save').closest('button');
@@ -127,7 +135,7 @@ describe('ManageImageRegionsDrawer', () => {
     );
 
     const { findByText, getByLabelText } = renderWithTheme(
-      
+      
     );
 
     // Verify both region labels have been loaded by the API
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
index d281e3645eb..3cc6a136f7c 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx
@@ -32,7 +32,7 @@ interface Context {
   regions: Region[] | undefined;
 }
 
-export const ManageImageRegionsForm = (props: Props) => {
+export const ManageImageReplicasForm = (props: Props) => {
   const { image, onClose } = props;
 
   const imageRegionIds = image?.regions.map(({ region }) => region) ?? [];
@@ -116,7 +116,7 @@ export const ManageImageRegionsForm = (props: Props) => {
             shouldValidate: true,
           })
         }
-        currentCapability={undefined}
+        currentCapability="Object Storage" // Images use Object Storage as the storage backend
         disabledRegions={disabledRegions}
         errorText={errors.regions?.message}
         label="Add Regions"
@@ -126,7 +126,7 @@ export const ManageImageRegionsForm = (props: Props) => {
         selectedIds={values.regions}
       />
       
-        Image will be available in these regions ({values.regions.length})
+        Image will be replicated in these regions ({values.regions.length})
       
        ({
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
index 4a695e8055c..1860593c336 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx
@@ -21,6 +21,8 @@ describe('Image Table Row', () => {
       { region: 'us-east', status: 'available' },
       { region: 'us-southeast', status: 'pending' },
     ],
+    size: 300,
+    total_size: 600,
   });
 
   const handlers: Handlers = {
@@ -41,12 +43,14 @@ describe('Image Table Row', () => {
     );
 
     // Check to see if the row rendered some data
+
+    expect(getByText('2 Regions')).toBeVisible();
+    expect(getByText('0.29 GB')).toBeVisible(); // 300 / 1024 = 0.292
+    expect(getByText('0.59 GB')).toBeVisible(); // 600 / 1024 = 0.585
+
     getByText(image.label);
     getAllByText('Ready');
-    getAllByText((text) => text.includes(image.regions[0].region));
-    getAllByText('+1');
     getAllByText('Cloud-init, Distributed');
-    expect(getAllByText('1500 MB').length).toBe(2);
     getAllByText(image.id);
 
     // Open action menu
@@ -54,7 +58,7 @@ describe('Image Table Row', () => {
     await userEvent.click(actionMenu);
 
     getByText('Edit');
-    getByText('Manage Regions');
+    getByText('Manage Replicas');
     getByText('Deploy to New Linode');
     getByText('Rebuild an Existing Linode');
     getByText('Delete');
@@ -74,7 +78,7 @@ describe('Image Table Row', () => {
     await userEvent.click(getByText('Edit'));
     expect(handlers.onEdit).toBeCalledWith(image);
 
-    await userEvent.click(getByText('Manage Regions'));
+    await userEvent.click(getByText('Manage Replicas'));
     expect(handlers.onManageRegions).toBeCalledWith(image);
 
     await userEvent.click(getByText('Deploy to New Linode'));
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
index c37a632c905..73cb2527106 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx
@@ -1,15 +1,17 @@
 import * as React from 'react';
 
 import { Hidden } from 'src/components/Hidden';
+import { LinkButton } from 'src/components/LinkButton';
 import { TableCell } from 'src/components/TableCell';
 import { TableRow } from 'src/components/TableRow';
 import { Typography } from 'src/components/Typography';
 import { useProfile } from 'src/queries/profile/profile';
 import { capitalizeAllWords } from 'src/utilities/capitalize';
 import { formatDate } from 'src/utilities/formatDate';
+import { pluralize } from 'src/utilities/pluralize';
+import { convertStorageUnit } from 'src/utilities/unitConversions';
 
 import { ImagesActionMenu } from './ImagesActionMenu';
-import { RegionsList } from './RegionsList';
 
 import type { Handlers } from './ImagesActionMenu';
 import type { Event, Image, ImageCapabilities } from '@linode/api-v4';
@@ -73,7 +75,14 @@ export const ImageRow = (props: Props) => {
     eventStatus: string | undefined
   ) => {
     if (status === 'available' || eventStatus === 'finished') {
-      return `${size} MB`;
+      const sizeInGB = convertStorageUnit('MB', size, 'GB');
+
+      const formattedSizeInGB = Intl.NumberFormat('en-US', {
+        maximumFractionDigits: 2,
+        minimumFractionDigits: 0,
+      }).format(sizeInGB);
+
+      return `${formattedSizeInGB} GB`;
     } else if (isFailed) {
       return 'N/A';
     } else {
@@ -85,18 +94,15 @@ export const ImageRow = (props: Props) => {
     
       {label}
       
-        {status ? {getStatusForImage(status)} : null}
+        {getStatusForImage(status)}
       
       {multiRegionsEnabled && (
         <>
           
             
-              {regions && regions.length > 0 && (
-                 handlers.onManageRegions?.(image)}
-                  regions={regions}
-                />
-              )}
+               handlers.onManageRegions?.(image)}>
+                {pluralize('Region', 'Regions', regions.length)}
+              
             
           
           
@@ -122,13 +128,13 @@ export const ImageRow = (props: Props) => {
         
       
       
-        {expiry ? (
+        {expiry && (
           
             {formatDate(expiry, {
               timezone: profile?.timezone,
             })}
           
-        ) : null}
+        )}
       
       {multiRegionsEnabled && (
         
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
index bd53035af38..b664da0917d 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx
@@ -97,7 +97,7 @@ export const ImagesActionMenu = (props: Props) => {
                 {
                   disabled: isImageReadOnly || isDisabled,
                   onClick: () => onManageRegions(image),
-                  title: 'Manage Regions',
+                  title: 'Manage Replicas',
                   tooltip: isImageReadOnly
                     ? getRestrictedResourceText({
                         action: 'edit',
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
index 59d52947e28..8977317fad0 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx
@@ -57,10 +57,10 @@ describe('Images Landing Table', () => {
     // Static text and table column headers
     expect(getAllByText('Image').length).toBe(2);
     expect(getAllByText('Status').length).toBe(2);
-    expect(getAllByText('Region(s)').length).toBe(1);
+    expect(getAllByText('Replicated in').length).toBe(1);
     expect(getAllByText('Compatibility').length).toBe(1);
-    expect(getAllByText('Size').length).toBe(2);
-    expect(getAllByText('Total Size').length).toBe(1);
+    expect(getAllByText('Original Image').length).toBe(1);
+    expect(getAllByText('All Replicas').length).toBe(1);
     expect(getAllByText('Created').length).toBe(2);
     expect(getAllByText('Image ID').length).toBe(1);
   });
diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
index 3766c1d86e8..5a6b50b2f45 100644
--- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
+++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx
@@ -47,7 +47,7 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
 
 import { getEventsForImages } from '../utils';
 import { EditImageDrawer } from './EditImageDrawer';
-import { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm';
+import { ManageImageReplicasForm } from './ImageRegions/ManageImageRegionsForm';
 import { ImageRow } from './ImageRow';
 import { ImagesLandingEmptyState } from './ImagesLandingEmptyState';
 import { RebuildImageDrawer } from './RebuildImageDrawer';
@@ -221,8 +221,8 @@ export const ImagesLanding = () => {
   const [selectedImageId, setSelectedImageId] = React.useState();
 
   const [
-    isManageRegionsDrawerOpen,
-    setIsManageRegionsDrawerOpen,
+    isManageReplicasDrawerOpen,
+    setIsManageReplicasDrawerOpen,
   ] = React.useState(false);
   const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false);
   const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false);
@@ -349,7 +349,7 @@ export const ImagesLanding = () => {
     onManageRegions: multiRegionsEnabled
       ? (image) => {
           setSelectedImageId(image.id);
-          setIsManageRegionsDrawerOpen(true);
+          setIsManageReplicasDrawerOpen(true);
         }
       : undefined,
     onRestore: (image) => {
@@ -430,7 +430,8 @@ export const ImagesLanding = () => {
           Custom Images
           
             These are images you manually uploaded or captured from an existing
-            Linode disk.
+            compute instance disk. You can deploy an image to a compute instance
+            in any region.
           
         
         
@@ -450,7 +451,7 @@ export const ImagesLanding = () => {
               {multiRegionsEnabled && (
                 <>
                   
-                    Region(s)
+                    Replicated in
                   
                     Compatibility
@@ -463,11 +464,11 @@ export const ImagesLanding = () => {
                 handleClick={handleManualImagesOrderChange}
                 label="size"
               >
-                Size
+                {multiRegionsEnabled ? 'Original Image' : 'Size'}
               
               {multiRegionsEnabled && (
                 
-                  Total Size
+                  All Replicas
                 
               )}
               
@@ -598,13 +599,13 @@ export const ImagesLanding = () => {
         open={isRebuildDrawerOpen}
       />
        setIsManageRegionsDrawerOpen(false)}
-        open={isManageRegionsDrawerOpen}
-        title={`Manage Regions for ${selectedImage?.label}`}
+        onClose={() => setIsManageReplicasDrawerOpen(false)}
+        open={isManageReplicasDrawerOpen}
+        title={`Manage Replicas for ${selectedImage?.label}`}
       >
-         setIsManageRegionsDrawerOpen(false)}
+          onClose={() => setIsManageReplicasDrawerOpen(false)}
         />
       
        {
-  it('should render a single region', async () => {
-    const { findByText } = renderWithTheme(
-      
-    );
-
-    // Should initially fallback to region id
-    await findByText('us-east');
-    await findByText('US, Newark, NJ');
-  });
-
-  it('should allow expanding to view multiple regions', async () => {
-    const manageRegions = vi.fn();
-
-    const { findByRole, findByText } = renderWithTheme(
-      
-    );
-
-    await findByText((text) => text.includes('US, Newark, NJ'));
-    const expand = await findByRole('button');
-    expect(expand).toHaveTextContent('+1');
-
-    await userEvent.click(expand);
-    expect(manageRegions).toBeCalled();
-  });
-});
diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx
deleted file mode 100644
index e17785ea634..00000000000
--- a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-
-import { StyledLinkButton } from 'src/components/Button/StyledLinkButton';
-import { Typography } from 'src/components/Typography';
-import { useRegionsQuery } from 'src/queries/regions/regions';
-
-import type { ImageRegion } from '@linode/api-v4';
-
-interface Props {
-  onManageRegions: () => void;
-  regions: ImageRegion[];
-}
-
-export const RegionsList = ({ onManageRegions, regions }: Props) => {
-  const { data: regionsData } = useRegionsQuery();
-
-  return (
-    
-      {regionsData?.find((region) => region.id == regions[0].region)?.label ??
-        regions[0].region}
-      {regions.length > 1 && (
-        <>
-          ,{' '}
-          
-            +{regions.length - 1}
-          
-        
-      )}
-    
-  );
-};
diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx
index de19fca6b22..a8652f9eeec 100644
--- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx
+++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx
@@ -28,7 +28,7 @@ export const Addons = () => {
     selectedRegion?.site_type === 'edge';
 
   return (
-    
+    
       
         Add-ons
         {isDistributedRegionSelected && (
diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
index 25ab2a7ba4b..6a8fef5766e 100644
--- a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
+++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
@@ -51,6 +51,8 @@ export const NotificationCenterEvent = React.memo(
     return (
       
          = {
-  0: {
-    ...oneClickAppFactory.build({
-      name: 'E2E Test App',
-    }),
-  },
+  0: oneClickAppFactory.build({
+    isNew: true,
+    name: 'E2E Test App',
+  }),
   401697: {
     alt_description: 'Popular website content management system.',
     alt_name: 'CMS: content management system',
diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx
index 65d78b3cdcb..ffd769a1b95 100644
--- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx
+++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx
@@ -23,7 +23,7 @@ import { usePrevious } from 'src/hooks/usePrevious';
 import { useNotificationsQuery } from 'src/queries/account/notifications';
 import { isInProgressEvent } from 'src/queries/events/event.helpers';
 import {
-  useEventsInfiniteQuery,
+  useInitialEventsQuery,
   useMarkEventsAsSeen,
 } from 'src/queries/events/events';
 import { rotate360 } from 'src/styles/keyframes';
@@ -37,7 +37,8 @@ export const NotificationMenu = () => {
   const formattedNotifications = useFormattedNotifications();
   const notificationContext = React.useContext(_notificationContext);
 
-  const { data, events } = useEventsInfiniteQuery();
+  const { data, events } = useInitialEventsQuery();
+  const eventsData = data?.data ?? [];
   const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen();
 
   const numNotifications =
@@ -132,6 +133,7 @@ export const NotificationMenu = () => {
           },
         }}
         anchorEl={anchorRef.current}
+        data-qa-notification-menu
         id={id}
         onClose={handleClose}
         open={notificationContext.menuOpen}
@@ -150,13 +152,22 @@ export const NotificationMenu = () => {
             
           
           
-          {data?.pages[0].data.slice(0, 20).map((event) => (
-            
-          ))}
+
+          {eventsData.length > 0 ? (
+            eventsData
+              .slice(0, 20)
+              .map((event) => (
+                
+              ))
+          ) : (
+            
+              No recent events to display
+            
+          )}
         
       
     
diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx
index c9f8c5bdc91..4276ca7695f 100644
--- a/packages/manager/src/features/TopMenu/TopMenu.tsx
+++ b/packages/manager/src/features/TopMenu/TopMenu.tsx
@@ -46,7 +46,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => {
           
         
       )}
-      
+      
          ({
             '&.MuiToolbar-root': {
diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts
index 95009a10fc8..0528df5c6c6 100644
--- a/packages/manager/src/queries/events/events.ts
+++ b/packages/manager/src/queries/events/events.ts
@@ -6,7 +6,7 @@ import {
   useQueryClient,
 } from '@tanstack/react-query';
 import { DateTime } from 'luxon';
-import { useEffect, useRef } from 'react';
+import { useEffect, useState } from 'react';
 
 import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants';
 import { EVENTS_LIST_FILTER } from 'src/features/Events/constants';
@@ -26,6 +26,47 @@ import type {
   QueryKey,
 } from '@tanstack/react-query';
 
+/**
+ * This query exists to get the first 7 days of events when you load the app.
+ *
+ * Using the first page of useEventsInfiniteQuery would be ideal, but we are going to try this...
+ *
+ * @note This initial query should match X-Filtering that our poller does. If we want this query
+ * to have a different filter than our poller, we will need to implement filtering in
+ * `updateEventsQueries` like we do for our infinite queries.
+ */
+export const useInitialEventsQuery = () => {
+  /**
+   * We only want to get events from the last 7 days.
+   */
+  const [defaultCreatedFilter] = useState(
+    DateTime.now()
+      .minus({ days: 7 })
+      .setZone('utc')
+      .toFormat(ISO_DATETIME_NO_TZ_FORMAT)
+  );
+
+  const query = useQuery, APIError[]>({
+    gcTime: Infinity,
+    queryFn: () =>
+      getEvents(
+        {},
+        {
+          ...EVENTS_LIST_FILTER,
+          '+order': 'desc',
+          '+order_by': 'id',
+          created: { '+gt': defaultCreatedFilter },
+        }
+      ),
+    queryKey: ['events', 'initial'],
+    staleTime: Infinity,
+  });
+
+  const events = query.data?.data;
+
+  return { ...query, events };
+};
+
 /**
  * Gets an infinitely scrollable list of all Events
  *
@@ -107,32 +148,27 @@ export const useEventsPoller = () => {
 
   const queryClient = useQueryClient();
 
-  const { events } = useEventsInfiniteQuery();
+  const { data: initialEvents } = useInitialEventsQuery();
 
-  const hasFetchedInitialEvents = events !== undefined;
+  const hasFetchedInitialEvents = initialEvents !== undefined;
 
-  const mountTimestamp = useRef(
+  const [mountTimestamp] = useState(
     DateTime.now().setZone('utc').toFormat(ISO_DATETIME_NO_TZ_FORMAT)
   );
 
-  const { data: polledEvents } = useQuery({
+  const { data: events } = useQuery({
     enabled: hasFetchedInitialEvents,
     queryFn: () => {
-      const data = queryClient.getQueryData>>([
+      const data = queryClient.getQueryData>([
         'events',
-        'infinite',
-        EVENTS_LIST_FILTER,
+        'initial',
       ]);
-      const events = data?.pages.reduce(
-        (events, page) => [...events, ...page.data],
-        []
-      );
+      const events = data?.data;
+
       // If the user has events, poll for new events based on the most recent event's created time.
       // If the user has no events, poll events from the time the app mounted.
       const latestEventTime =
-        events && events.length > 0
-          ? events[0].created
-          : mountTimestamp.current;
+        events && events.length > 0 ? events[0].created : mountTimestamp;
 
       const {
         eventsThatAlreadyHappenedAtTheFilterTime,
@@ -161,15 +197,15 @@ export const useEventsPoller = () => {
   });
 
   useEffect(() => {
-    if (polledEvents && polledEvents.length > 0) {
-      updateEventsQueries(polledEvents, queryClient);
+    if (events && events.length > 0) {
+      updateEventsQueries(events, queryClient);
 
-      for (const event of polledEvents) {
+      for (const event of events) {
         handleGlobalToast(event);
         handleEvent(event);
       }
     }
-  }, [polledEvents]);
+  }, [events]);
 
   return null;
 };
@@ -208,6 +244,25 @@ export const useMarkEventsAsSeen = () => {
   return useMutation<{}, APIError[], number>({
     mutationFn: (eventId) => markEventSeen(eventId),
     onSuccess: (_, eventId) => {
+      // Update Initial Query
+      queryClient.setQueryData>(
+        ['events', 'initial'],
+        (prev) => {
+          if (!prev) {
+            return undefined;
+          }
+
+          for (const event of prev.data) {
+            if (event.id <= eventId) {
+              event.seen = true;
+            }
+          }
+
+          return prev;
+        }
+      );
+
+      // Update Infinite Queries
       queryClient.setQueriesData>>(
         { queryKey: ['events', 'infinite'] },
         (prev) => {
@@ -218,14 +273,9 @@ export const useMarkEventsAsSeen = () => {
             };
           }
 
-          let foundLatestSeenEvent = false;
-
           for (const page of prev.pages) {
             for (const event of page.data) {
-              if (event.id === eventId) {
-                foundLatestSeenEvent = true;
-              }
-              if (foundLatestSeenEvent) {
+              if (event.id <= eventId) {
                 event.seen = true;
               }
             }
@@ -269,6 +319,8 @@ export const updateEventsQueries = (
 
       updateEventsQuery(filteredEvents, queryKey, queryClient);
     });
+
+  updateInitialEventsQuery(events, queryClient);
 };
 
 /**
@@ -333,3 +385,44 @@ export const updateEventsQuery = (
     }
   );
 };
+
+export const updateInitialEventsQuery = (
+  events: Event[],
+  queryClient: QueryClient
+) => {
+  queryClient.setQueryData>(
+    ['events', 'initial'],
+    (prev) => {
+      if (!prev) {
+        return undefined;
+      }
+      const updatedEventIndexes: number[] = [];
+
+      for (let i = 0; i < events.length; i++) {
+        const indexOfEvent = prev.data.findIndex((e) => e.id === events[i].id);
+
+        if (indexOfEvent !== -1) {
+          prev.data[indexOfEvent] = events[i];
+          updatedEventIndexes.push(i);
+        }
+      }
+
+      const newEvents: Event[] = [];
+
+      for (let i = 0; i < events.length; i++) {
+        if (!updatedEventIndexes.includes(i)) {
+          newEvents.push(events[i]);
+        }
+      }
+
+      if (newEvents.length > 0) {
+        // For all events, that remain, append them to the top of the events list
+        prev.data = [...newEvents, ...prev.data];
+
+        prev.results += newEvents.length;
+      }
+
+      return prev;
+    }
+  );
+};
diff --git a/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.test.ts b/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.test.ts
index 018500f605d..6b147ec6c8a 100644
--- a/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.test.ts
+++ b/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.test.ts
@@ -43,4 +43,17 @@ describe('generateAnsibleConfig', () => {
 
     expect(generateAnsibleConfig(config)).toEqual(expectedOutput);
   });
+
+  it('should safely escape extra backslash characters in YAML strings', () => {
+    const config = {
+      label: 'Linode with ] and also \\[, }, and \\{',
+      region: 'us-central',
+      root_pass: 'securePass123',
+      type: 'g6-standard-1',
+    };
+
+    const expectedOutput = `- name: Create a new Linode instance.\n  linode.cloud.instance:\n    state: "present"\n    label: "Linode with \\] and also \\\\\\[, \\}, and \\\\\\{"\n    type: "g6-standard-1"\n    region: "us-central"\n    root_pass: "securePass123"\n`;
+
+    expect(generateAnsibleConfig(config)).toEqual(expectedOutput);
+  });
 });
diff --git a/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.ts b/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.ts
index d04cf80cdd7..968bbed7d12 100644
--- a/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.ts
+++ b/packages/manager/src/utilities/codesnippets/generate-ansibleConfig.ts
@@ -6,7 +6,7 @@ import type { CreateLinodeRequest } from '@linode/api-v4/lib/linodes';
  * @returns {string} - The safely escaped string.
  */
 function escapeYAMLString(str: string) {
-  return str.replace(/(["':\[\]\{\}])/g, '\\$1').replace(/\n/g, '\\n');
+  return str.replace(/(["':\\\[\\\]\\\{\\\}])/g, '\\$1').replace(/\n/g, '\\n');
 }
 
 /**