diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 8e000d746a0..4060db60e8f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,21 @@ +## [2024-05-13] - v0.117.0 + + +### Added: + +- 'edge' Linode type class ([#10415](https://github.com/linode/manager/pull/10415)) + +### Changed: + +- Allow `backup_id` to be `null` in `CreateLinodeRequest` ([#10404](https://github.com/linode/manager/pull/10404)) +- Add disk_encryption to Linode, Disk, CreateLinodeRequest, RebuildRequest, and KubeNodePoolResponse interfaces ([#10413](https://github.com/linode/manager/pull/10413)) +- Allow null for Placement Groups maximum_pgs_per_customer ([#10433](https://github.com/linode/manager/pull/10433)) + +### Upcoming Features: + +- Update Placement Group event types ([#10420](https://github.com/linode/manager/pull/10420)) + + ## [2024-05-06] - v0.116.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 7b9f7ea6139..96d71f158c9 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.116.0", + "version": "0.117.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 1cc3e89e7bd..0d37153205c 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -359,11 +359,12 @@ export type EventAction = | 'nodebalancer_update' | 'password_reset' | 'placement_group_assign' - | 'placement_group_created' - | 'placement_group_assigned' - | 'placement_group_unassigned' - | 'placement_group_updated' - | 'placement_group_deleted' + | 'placement_group_became_non_compliant' + | 'placement_group_became_compliant' + | 'placement_group_create' + | 'placement_group_unassign' + | 'placement_group_update' + | 'placement_group_delete' | 'profile_update' | 'stackscript_create' | 'stackscript_delete' diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index 56db9687cc0..c8d25118e35 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -1,3 +1,5 @@ +import type { EncryptionStatus } from 'src/linodes'; + export interface KubernetesCluster { created: string; updated: string; @@ -16,6 +18,7 @@ export interface KubeNodePoolResponse { nodes: PoolNodeResponse[]; type: string; autoscaler: AutoscaleSettings; + disk_encryption?: EncryptionStatus; // @TODO LDE: remove optionality once LDE is fully rolled out } export interface PoolNodeResponse { diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 745d30b718d..05f52e39d96 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -5,6 +5,8 @@ import type { PlacementGroupPayload } from '../placement-groups/types'; export type Hypervisor = 'kvm' | 'zen'; +export type EncryptionStatus = 'enabled' | 'disabled'; + export interface LinodeSpecs { disk: number; memory: number; @@ -18,6 +20,7 @@ export interface Linode { alerts: LinodeAlerts; backups: LinodeBackups; created: string; + disk_encryption?: EncryptionStatus; // @TODO LDE: Remove optionality once LDE is fully rolled out region: string; image: string | null; group: string; @@ -267,6 +270,7 @@ export interface Disk { filesystem: Filesystem; created: string; updated: string; + disk_encryption?: EncryptionStatus; // @TODO LDE: remove optionality once LDE is fully rolled out } export type DiskStatus = 'ready' | 'not ready' | 'deleting'; @@ -374,7 +378,7 @@ export interface CreateLinodeRequest { * * This field and the image field are mutually exclusive. */ - backup_id?: number; + backup_id?: number | null; /** * When deploying from an Image, this field is optional, otherwise it is ignored. * This is used to set the swap disk size for the newly-created Linode. @@ -447,9 +451,14 @@ export interface CreateLinodeRequest { */ firewall_id?: number | null; /** - * An object that assigns this the Linode to a placment group upon creation. + * An object that assigns this the Linode to a placement group upon creation. */ placement_group?: CreateLinodePlacementGroupPayload; + /** + * A property with a string literal type indicating whether the Linode is encrypted or unencrypted. + * @default 'enabled' (if the region supports LDE) + */ + disk_encryption?: EncryptionStatus; } export interface MigrateLinodeRequest { @@ -485,6 +494,7 @@ export interface RebuildRequest { stackscript_id?: number; stackscript_data?: any; booted?: boolean; + disk_encryption?: EncryptionStatus; } export interface LinodeDiskCreationData { diff --git a/packages/api-v4/src/placement-groups/types.ts b/packages/api-v4/src/placement-groups/types.ts index dc69fa56402..e092d401ff3 100644 --- a/packages/api-v4/src/placement-groups/types.ts +++ b/packages/api-v4/src/placement-groups/types.ts @@ -6,7 +6,7 @@ export const AFFINITY_TYPES = { } as const; export type AffinityType = keyof typeof AFFINITY_TYPES; -export type AffinityEnforcement = 'Strict' | 'Flexible'; +export type AffinityTypeEnforcement = 'Strict' | 'Flexible'; export interface PlacementGroup { id: number; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index b651e8b9930..01a7bd31e64 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -32,7 +32,7 @@ export interface Region { country: Country; capabilities: Capabilities[]; placement_group_limits: { - maximum_pgs_per_customer: number; + maximum_pgs_per_customer: number | null; // This value can be unlimited for some customers, for which the API returns the `null` value. maximum_linodes_per_pg: number; }; status: RegionStatus; diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 548b95adbb2..7356fbbda3a 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-05-13] - v1.119.0 + + +### Changed: + +- Update Account Closure Dialog Wording ([#10406](https://github.com/linode/manager/pull/10406)) +- Implement GPUv2 plan divider & cleanup/consolidate plan selection components ([#10407](https://github.com/linode/manager/pull/10407)) [#10450](https://github.com/linode/manager/pull/10450) + +### Fixed: + +- Object ACL select field enabled in loading state ([#10412](https://github.com/linode/manager/pull/10412)) +- Modification of Linode config 'interfaces' array on no changes ([#10423](https://github.com/linode/manager/pull/10423)) +- Table component props forwarding ([#10424](https://github.com/linode/manager/pull/10424)) + +### Tech Stories: + +- Remove `linodeCloneUiChanges` feature flag and clean up usages ([#10385](https://github.com/linode/manager/pull/10385)) +- Query Key Factory for Volumes ([#10414](https://github.com/linode/manager/pull/10414)) +- Query Key Factory for Kubernetes ([#10428](https://github.com/linode/manager/pull/10428)) +- Clean up Main Content Banner ([#10430](https://github.com/linode/manager/pull/10430)) +- Clean up Database feature flagging logic ([#10435](https://github.com/linode/manager/pull/10435)) + +### Tests: + +- Add Cypress test coverage for Firewall renaming ([#10384](https://github.com/linode/manager/pull/10384)) +- Add Cypress test for Domain cloning ([#10403](https://github.com/linode/manager/pull/10403)) +- Fix VPC subnet Linode assignment integration test failures ([#10405](https://github.com/linode/manager/pull/10405)) +- Fix access key test failure when user has many OBJ buckets ([#10405](https://github.com/linode/manager/pull/10405)) +- Refactor Linode config end-to-end tests ([#10405](https://github.com/linode/manager/pull/10405)) +- Fix failing OBJ E2E tests following API release ([#10417](https://github.com/linode/manager/pull/10417)) +- Add Cypress tests for Placement Group deletion flows ([#10425](https://github.com/linode/manager/pull/10425)) +- Add Placement Group create flow UI test ([#10445](https://github.com/linode/manager/pull/10445)) +- Fix One-Click App test by using Ubuntu 22.04 image ([#10447](https://github.com/linode/manager/pull/10447)) + +### Upcoming Features: + +- Add dialog to refresh proxy tokens as time expires ([#10361](https://github.com/linode/manager/pull/10361)) +- Update Placement Groups text copy ([#10399](https://github.com/linode/manager/pull/10399)) +- Linode Create Refactor: + - Marketplace - Part 1 ([#10401](https://github.com/linode/manager/pull/10401)) + - Backups (#10404) + - Marketplace - Part 2 (#10419) + - Cloning ([#10421](https://github.com/linode/manager/pull/10421)) +- Update Placement Group Table Row linodes tooltip and SelectPlacementGroup option label ([#10408](https://github.com/linode/manager/pull/10408)) +- Add content to the ResourcesSection of the PG landing page in empty state ([#10411](https://github.com/linode/manager/pull/10411)) +- Use 'edge'-class plans in edge regions ([#10415](https://github.com/linode/manager/pull/10415)) +- Add disk_encryption to several factories for mocked data ([#10418](https://github.com/linode/manager/pull/10418)) +- Fix Placement Group action event formatting ([#10420](https://github.com/linode/manager/pull/10420)) +- Replace remaining feature flag implementation with `useIsPlacementGroupsEnabled` utility function ([#10431](https://github.com/linode/manager/pull/10431)) +- Update Placement Groups final copy ([#10434](https://github.com/linode/manager/pull/10434)) +- Add support for Placement Groups in Linode CLI tool ([#10438](https://github.com/linode/manager/pull/10438)) +- Set PlacementGroupSelect clearOnBlur to true ([#10427](https://github.com/linode/manager/pull/10427)) +- Update Placement Groups maximum_pgs_per_customer UI (#10433) +- Add DiskEncryption component ([#10439](https://github.com/linode/manager/pull/10439)) + + ## [2024-05-06] - v1.118.1 diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index a4e15d14991..3c114a06a7d 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -79,7 +79,9 @@ describe('Account cancellation', () => { }); ui.dialog - .findByTitle('Are you sure you want to close your Linode account?') + .findByTitle( + 'Are you sure you want to close your cloud computing services account?' + ) .should('be.visible') .within(() => { cy.findByText(cancellationDataLossWarning, { exact: false }).should( @@ -179,7 +181,9 @@ describe('Account cancellation', () => { // Fill out cancellation dialog and attempt submission. ui.dialog - .findByTitle('Are you sure you want to close your Linode account?') + .findByTitle( + 'Are you sure you want to close your cloud computing services account?' + ) .should('be.visible') .within(() => { cy.findByLabelText( @@ -353,7 +357,9 @@ describe('Parent/Child account cancellation', () => { }); ui.dialog - .findByTitle('Are you sure you want to close your Linode account?') + .findByTitle( + 'Are you sure you want to close your cloud computing services account?' + ) .should('be.visible') .within(() => { cy.findByText(cancellationDataLossWarning, { exact: false }).should( diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts new file mode 100644 index 00000000000..f85218804e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -0,0 +1,137 @@ +import { Domain } from '@linode/api-v4'; +import { domainFactory } from '@src/factories'; +import { getClick, fbtClick, fbltClick } from 'support/helpers'; +import { authenticate } from 'support/api/authentication'; +import { randomDomainName } from 'support/util/random'; +import { createDomain } from '@linode/api-v4/lib/domains'; +import { createDomainRecords } from 'support/constants/domains'; +import { interceptCreateDomainRecord } from 'support/intercepts/domains'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('Clone a Domain', () => { + before(() => { + cleanUp('domains'); + }); + + /* + * - Clicks "Clone" action menu item for domain but cancels operation. + * - Clicks "Clone" action menu item for domain and confirms operation. + * - Confirms that a "Domain is not valid." error is yielded when entering an invalid domain name. + * - Confirms that the user is redirected to the new Domain's details page after cloning. + * - Confirms that domain is still in landing page list after canceled operation. + * - Confirms that cloned domains contain the same records as the original Domain. + */ + it('clones a domain', () => { + const domainRequest = domainFactory.build({ + domain: randomDomainName(), + group: 'test-group', + }); + + const invalidDomainName = 'invalid-domain-name'; + const clonedDomainName = randomDomainName(); + + const domainRecords = createDomainRecords(); + + cy.defer(createDomain(domainRequest), 'creating domain').then( + (domain: Domain) => { + // Add records to the domain. + cy.visitWithLogin(`/domains/${domain.id}`); + + domainRecords.forEach((rec) => { + interceptCreateDomainRecord().as('apiCreateRecord'); + fbtClick(rec.name); + rec.fields.forEach((f) => { + getClick(f.name).type(f.value); + }); + fbtClick('Save'); + cy.wait('@apiCreateRecord'); + }); + + cy.visitWithLogin('/domains'); + + // Confirm that domain is listed and initiate deletion. + cy.findByText(domain.domain) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for Domain ${domain}`) + .should('be.visible') + .click(); + }); + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); + + // Cancel cloning when prompted to confirm. + ui.drawer + .findByTitle(`Clone Domain`) + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that no new domain is added and initiate cloning again. + cy.findByText(domain.domain) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for Domain ${domain}`) + .should('be.visible') + .click(); + }); + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); + + // Confirm cloning. + ui.drawer + .findByTitle(`Clone Domain`) + .should('be.visible') + .within(() => { + // The button should be disabled before confirming the correct domain + ui.buttonGroup + .findButtonByTitle('Create Domain') + .should('be.visible') + .should('be.disabled'); + + // Confirm that an error is displayed when entering an invalid domain name + fbltClick('New Domain').type(invalidDomainName); + ui.buttonGroup + .findButtonByTitle('Create Domain') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Domain is not valid.').should('be.visible'); + + fbltClick('New Domain').clear().type(clonedDomainName); + ui.buttonGroup + .findButtonByTitle('Create Domain') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // After cloning a Domain, the user is redirected to the new Domain's details page + cy.url().should('endWith', 'domains'); + + // Confirm that domain is cloned and cloned domains contain the same records as the original Domain. + cy.visitWithLogin('/domains'); + cy.findByText(domain.domain).should('be.visible'); + cy.findByText(clonedDomainName).should('be.visible').click(); + domainRecords.forEach((rec) => { + cy.get(`[aria-label="${rec.tableAriaLabel}"]`).within((_table) => { + rec.fields.forEach((f) => { + if (f.skipCheck) { + return; + } + cy.findByText(f.value, { exact: !f.approximate }); + }); + }); + }); + } + ); + }); +}); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index b051f09b61d..379386f9258 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -4,95 +4,7 @@ import { createDomain } from 'support/api/domains'; import { fbtClick, getClick } from 'support/helpers'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { cleanUp } from 'support/util/cleanup'; -import { - randomDomainName, - randomIp, - randomLabel, - randomString, -} from 'support/util/random'; - -const createRecords = () => [ - { - name: 'Add an A/AAAA Record', - tableAriaLabel: 'List of Domains A/AAAA Record', - fields: [ - { - name: '[data-qa-target="Hostname"]', - value: randomLabel(), - skipCheck: false, - }, - { - name: '[data-qa-target="IP Address"]', - value: `${randomIp()}`, - skipCheck: false, - }, - ], - }, - { - name: 'Add a CNAME Record', - tableAriaLabel: 'List of Domains CNAME Record', - fields: [ - { - name: '[data-qa-target="Hostname"]', - value: randomLabel(), - skipCheck: false, - }, - { - name: '[data-qa-target="Alias to"]', - value: `${randomLabel()}.net`, - skipCheck: false, - }, - ], - }, - { - name: 'Add a TXT Record', - tableAriaLabel: 'List of Domains TXT Record', - fields: [ - { - name: '[data-qa-target="Hostname"]', - value: randomLabel(), - skipCheck: false, - }, - { - name: '[data-qa-target="Value"]', - value: `${randomLabel()}=${randomString()}`, - skipCheck: false, - }, - ], - }, - { - name: 'Add an SRV Record', - tableAriaLabel: 'List of Domains SRV Record', - fields: [ - { - name: '[data-qa-target="Service"]', - value: randomLabel(), - skipCheck: true, - }, - { - name: '[data-qa-target="Target"]', - value: randomLabel(), - approximate: true, - }, - ], - }, - { - name: 'Add a CAA Record', - tableAriaLabel: 'List of Domains CAA Record', - fields: [ - { - name: '[data-qa-target="Name"]', - value: randomLabel(), - skipCheck: false, - }, - { - name: '[data-qa-target="Value"]', - value: randomDomainName(), - skipCheck: false, - }, - ], - }, -]; +import { createDomainRecords } from 'support/constants/domains'; authenticate(); describe('Creates Domains record with Form', () => { @@ -100,7 +12,7 @@ describe('Creates Domains record with Form', () => { cleanUp('domains'); }); - createRecords().forEach((rec) => { + createDomainRecords().forEach((rec) => { return it(rec.name, () => { createDomain().then((domain) => { // intercept create api record request diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index c8e25a1fbc6..dbbde69165f 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -396,4 +396,65 @@ describe('update firewall', () => { }); }); }); + + /* + * - Confirms that firewall's label can be updated on landing page. + */ + it("updates a firewall's label", () => { + const region = chooseRegion(); + + const linodeRequest = createLinodeRequestFactory.build({ + label: randomLabel(), + region: region.id, + root_pass: randomString(16), + }); + + const firewallRequest = firewallFactory.build({ + label: randomLabel(), + rules: { + inbound: [], + outbound: [], + }, + }); + + const newFirewallLabel = randomLabel(); + + cy.defer( + createLinodeAndFirewall(linodeRequest, firewallRequest), + 'creating Linode and firewall' + ).then(([_linode, firewall]) => { + cy.visitWithLogin('/firewalls'); + + // Confirm that firewall is listed on landing page with expected configuration. + cy.findByText(firewall.label) + .closest('tr') + .within(() => { + cy.findByText(firewall.label).should('be.visible'); + cy.findByText('Enabled').should('be.visible'); + cy.findByText('No rules').should('be.visible'); + cy.findByText('None assigned').should('be.visible'); + }); + + cy.visitWithLogin(`/firewalls/${firewall.id}`); + + cy.findByLabelText(`Edit ${firewall.label}`).click(); + cy.get(`[id="edit-${firewall.label}-label"]`) + .click() + .clear() + .type(`${newFirewallLabel}{enter}`); + + // Confirm Firewall label updates in breadcrumbs. + ui.entityHeader.find().within(() => { + cy.findByText(newFirewallLabel).should('be.visible'); + cy.findByText('firewalls').click(); + }); + + // Confirm firewall label is updated on landing page without refresh. + cy.findByText(newFirewallLabel).should('be.visible'); + + // Confirm firewall label is updated on landing page after refresh. + cy.reload(); + cy.findByText(newFirewallLabel).should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 141495ccfdf..51b32e4ca54 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -1,13 +1,7 @@ -import { createLinode } from 'support/api/linodes'; -import { containsVisible } from 'support/helpers'; +import { createTestLinode } from 'support/util/linodes'; import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetVPC } from 'support/intercepts/vpc'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { getRegionById } from 'support/util/regions'; @@ -16,9 +10,12 @@ import { interceptRebootLinode, mockGetLinodeDetails, mockGetLinodeDisks, + mockGetLinodeKernels, mockGetLinodeVolumes, + mockGetLinodeKernel, } from 'support/intercepts/linodes'; import { + interceptGetLinodeConfigs, interceptDeleteLinodeConfig, interceptCreateLinodeConfigs, interceptUpdateLinodeConfigs, @@ -26,519 +23,618 @@ import { mockCreateLinodeConfigs, mockUpdateLinodeConfigs, } from 'support/intercepts/configs'; +import { fetchLinodeConfigs } from 'support/util/linodes'; import { - createLinodeAndGetConfig, - createAndBootLinode, -} from 'support/util/linodes'; -import { + kernelFactory, vpcFactory, linodeFactory, linodeConfigFactory, VLANFactory, + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, } from '@src/factories'; import { randomNumber, randomLabel } from 'support/util/random'; +import { fetchAllKernels, findKernelById } from 'support/util/kernels'; +import { NOT_NATTED_HELPER_TEXT } from 'src/features/VPCs/constants'; + +import type { CreateTestLinodeOptions } from 'support/util/linodes'; +import type { + Config, + CreateLinodeRequest, + InterfacePurpose, + Linode, + VLAN, + Region, + Kernel, +} from '@linode/api-v4'; + +/** + * Returns a Promise that resolves to a new test Linode and its first config object. + * + * @param interfaces - Interfaces with which to create test Linode. + * + * @throws If created Linode does not have any configs. + */ +const createLinodeAndGetConfig = async ( + payload?: Partial | null, + options?: Partial +) => { + const linode = await createTestLinode(payload, options); + + const config = (await fetchLinodeConfigs(linode.id))[0]; + if (!config) { + throw new Error( + `Linode '${linode.label}' (ID ${linode.id}) does not have any configs` + ); + } -import type { Config, Linode, VLAN, Disk, Region } from '@linode/api-v4'; + return [linode, config]; +}; +let kernels: Kernel[] = []; authenticate(); +describe('Linode Config management', () => { + describe('End-to-End', () => { + before(() => { + cleanUp('linodes'); + + // Fetch Linode kernel data from the API. + // We'll use this data in the tests to confirm that config labels are rendered correctly. + cy.defer(fetchAllKernels(), 'Fetching Linode kernels...').then( + (fetchedKernels) => { + kernels = fetchedKernels; + } + ); + }); -describe('Linode Config', () => { - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - - before(() => { - mockConfig.interfaces.splice(2, 1); - }); - - beforeEach(() => { - cleanUp(['linodes']); - }); - - it('Creates a new config and list all configs', () => { - createLinode().then((linode: Linode) => { - interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); - - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + /* + * - Tests Linode config creation end-to-end using real API requests. + * - Confirms that a config is listed after a Linode has been created. + * - Confirms that config creation can be initiated and completed successfully. + * - Confirms that new config is automatically listed after being created. + */ + it('Creates a config', () => { + // Wait for Linode to be created for kernel data to be retrieved. + cy.defer(createTestLinode(), 'Creating Linode').then((linode: Linode) => { + interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); + interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); - cy.findByLabelText('List of Configurations').within(() => { - containsVisible('My Debian 10 Disk Profile – GRUB 2'); - }); - containsVisible('My Debian 10 Disk Profile – GRUB 2'); + cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - cy.findByText('Add Configuration').click(); - ui.dialog - .findByTitle('Add Configuration') - .should('be.visible') - .within(() => { - cy.get('#label').type(`${linode.id}-test-config`); - ui.buttonGroup - .findButtonByTitle('Add Configuration') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); + // Confirm that initial config is listed in Linode configurations table. + cy.wait('@getLinodeConfigs'); + cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`).should( + 'be.visible' + ); + }); + }); }); - cy.wait('@postLinodeConfigs') - .its('response.statusCode') - .should('eq', 200); + // Add new configuration. + cy.findByText('Add Configuration').click(); + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + cy.get('#label').type(`${linode.id}-test-config`); + ui.buttonGroup + .findButtonByTitle('Add Configuration') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible( - `${linode.id}-test-config – Latest 64 bit (6.7.9-x86_64-linode163)` - ); - containsVisible('eth0 – Public Internet'); + // Confirm that config creation request was successful. + cy.wait('@postLinodeConfigs') + .its('response.statusCode') + .should('eq', 200); + + // Confirm that new config and existing config are both listed. + cy.wait('@getLinodeConfigs'); + cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('eth0 – Public Internet').should('be.visible'); + }); + }); + }); + }); }); }); - }); - it('Creates a new config and assigns a VPC as a network interface', () => { - const mockLinode = linodeFactory.build({ - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); + /** + * - Tests Linode config edit flow end-to-end using real API requests. + * - Confirms that an existing config can be edited. + * - Confirms that updated config data is automatically displayed after editing. + */ + it('Edits a config', () => { + // Config interfaces to use when creating test Linode. + const interfaces = [ + { + ipam_address: '', + label: '', + purpose: 'public' as InterfacePurpose, + }, + { + ipam_address: '', + label: 'testvlan', + purpose: 'vlan' as InterfacePurpose, + }, + ]; - const mockVPC = vpcFactory.build({ - id: 1, - label: randomLabel(), - }); + // Create a Linode and wait for its Config to be fetched before proceeding. + cy.defer( + createLinodeAndGetConfig({ interfaces }, { waitForDisks: true }), + 'creating a linode and getting its config' + ).then(([linode, config]: [Linode, Config]) => { + // Get kernel info for config. + const kernel = findKernelById(kernels, config.kernel); + const newIpamAddress = '192.0.2.0/25'; - mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeConfigs(mockLinode.id, []); - mockGetVPC(mockVPC).as('getVPC'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getDisks', - '@getVolumes', - ]); - - // Confirm that there is no configuration yet. - cy.findByLabelText('List of Configurations').within(() => { - cy.contains(`${mockConfig.label} – GRUB 2`).should('not.exist'); - }); + cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + interceptUpdateLinodeConfigs(linode.id, config.id).as( + 'putLinodeConfigs' + ); - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockCreateLinodeConfigs(mockLinode.id, mockConfig).as('createLinodeConfig'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - cy.findByText('Add Configuration').click(); - ui.dialog - .findByTitle('Add Configuration') - .should('be.visible') - .within(() => { - cy.get('#label').type(`${mockConfig.label}`); - // Confirm that "VPC" can be selected for either "eth0", "eth1", or "eth2". - // Add VPC to eth0 - cy.get('[data-qa-textfield-label="eth0"]') - .scrollIntoView() - .parent() - .parent() - .within(() => { - ui.select - .findByText('Public Internet') - .should('be.visible') - .click() - .type('VPC{enter}'); - }); - // Add VPC to eth1 - cy.get('[data-qa-textfield-label="eth1"]') - .scrollIntoView() - .parent() - .parent() - .within(() => { - ui.select - .findByText('None') - .should('be.visible') - .click() - .type('VPC{enter}'); - }); - // Add VPC to eth2 - cy.get('[data-qa-textfield-label="eth2"]') - .scrollIntoView() - .parent() - .parent() + // Confirm that config is listed as expected, then click "Edit". + cy.contains(`${config.label} – ${kernel.label}`).should('be.visible'); + cy.findByText('Edit').click(); + + // Enter a new IPAM address for eth1 (VLAN), then click "Save Changes" + ui.dialog + .findByTitle('Edit Configuration') + .should('be.visible') .within(() => { - ui.select - .findByText('None') + cy.get('#ipam-input-1').type(newIpamAddress); + ui.button + .findByTitle('Save Changes') + .scrollIntoView() .should('be.visible') - .click() - .type('VPC{enter}'); + .should('be.enabled') + .click(); }); - ui.buttonGroup - .findButtonByTitle('Add Configuration') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - cy.wait(['@createLinodeConfig', '@getLinodeConfigs', '@getVPC']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); - }); - }); - it('Edits an existing config', () => { - cy.defer( - createLinodeAndGetConfig({ - waitForLinodeToBeRunning: false, - linodeConfigRequestOverride: { - label: 'cy-test-edit-config-linode', - interfaces: [ - { - ipam_address: '', - label: '', - purpose: 'public', - }, - { - ipam_address: '', - label: 'testvlan', - purpose: 'vlan', - }, - ], - region: 'us-east', - }, - }), - 'creating a linode and getting its config' - ).then(([linode, config]: [Linode, Config]) => { - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - interceptUpdateLinodeConfigs(linode.id, config.id).as('putLinodeConfigs'); - - containsVisible('My Debian 10 Disk Profile – GRUB 2'); - cy.findByText('Edit').click(); + // Confirm that config update request succeeded and that toast appears. + cy.wait('@putLinodeConfigs') + .its('response.statusCode') + .should('eq', 200); + ui.toast.assertMessage( + `Configuration ${config.label} successfully updated` + ); - ui.dialog - .findByTitle('Edit Configuration') - .should('be.visible') - .within(() => { - cy.get('#ipam-input-1').type('192.0.2.0/25'); - ui.button - .findByTitle('Save Changes') - .scrollIntoView() + // Confirm that updated IPAM is automatically listed in config table. + cy.findByLabelText('List of Configurations').within(() => { + const configKernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${configKernel.label}`) .should('be.visible') - .should('be.enabled') - .click(); + .closest('tr') + .within(() => { + cy.contains('eth0 – Public Internet').should('be.visible'); + cy.contains(`eth1 – VLAN: testvlan (${newIpamAddress})`).should( + 'be.visible' + ); + }); }); - - cy.wait('@putLinodeConfigs').its('response.statusCode').should('eq', 200); - - cy.findByLabelText('List of Configurations').within(() => { - containsVisible('eth0 – Public Internet'); - containsVisible('eth1 – VLAN: testvlan (192.0.2.0/25)'); }); }); - }); - it('Edits an existing config and assigns a VPC as a network interface', () => { - const mockLinode = linodeFactory.build({ - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const mockVPC = vpcFactory.build({ - id: 1, - label: randomLabel(), - }); + /* + * - Confirms Linode config boot flow end-to-end using real API requests. + * - Confirms that API reboot request succeeds and Cloud UI automatically updates to reflect reboot. + */ + it('Boots a config', () => { + cy.defer( + createLinodeAndGetConfig(null, { waitForBoot: true }), + 'Creating and booting test Linode' + ).then(([linode, config]: [Linode, Config]) => { + const kernel = findKernelById(kernels, config.kernel); - mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getConfig'); - mockGetVPC(mockVPC).as('getVPC'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getConfig', - '@getDisks', - '@getVolumes', - ]); - - cy.findByLabelText('List of Configurations').within(() => { - containsVisible(`${mockConfig.label} – GRUB 2`); - }); - cy.findByText('Edit').click(); + cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + interceptRebootLinode(linode.id).as('rebootLinode'); - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockUpdateLinodeConfigs(mockLinode.id, mockConfig).as( - 'updateLinodeConfigs' - ); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - ui.dialog - .findByTitle('Edit Configuration') - .should('be.visible') - .within(() => { - // Change eth0 to VPC - cy.get('[data-qa-textfield-label="eth0"]') - .scrollIntoView() - .parent() - .parent() - .within(() => { - ui.select - .findByText('Public Internet') - .should('be.visible') - .click() - .type('VPC{enter}'); - }); - // Change eth1 to VPC - cy.get('[data-qa-textfield-label="eth1"]') - .scrollIntoView() - .parent() - .parent() + // Confirm that Linode config is listed, then click its "Boot" button. + cy.findByText(`${config.label} – ${kernel.label}`) + .should('be.visible') + .closest('tr') .within(() => { - ui.select - .findByText('VLAN') - .should('be.visible') - .click() - .type('VPC{enter}'); + cy.findByText('Boot').click(); }); - // Change eth2 to VPC - cy.get('[data-qa-textfield-label="eth2"]') - .scrollIntoView() - .parent() - .parent() + + // Proceed through boot confirmation dialog. + ui.dialog + .findByTitle('Confirm Boot') + .should('be.visible') .within(() => { - ui.select - .findByText('VPC') + cy.contains( + `Are you sure you want to boot "${config.label}"?` + ).should('be.visible'); + ui.button + .findByTitle('Boot') .should('be.visible') - .click() - .type('VPC{enter}'); + .should('be.enabled') + .click(); }); + + // Confirm that API request succeeds, toast appears, and UI updates to reflect reboot. + cy.wait('@rebootLinode').its('response.statusCode').should('eq', 200); + ui.toast.assertMessage(`Successfully booted config ${config.label}`); + cy.findByText('REBOOTING').should('be.visible'); + }); + }); + + /* + * - Confirms Linode config clone flow end-to-end using real API requests. + * - Confirms that API config clone requests succeed. + * - Confirms that Cloud UI automatically updates to reflect clone-in-progress. + */ + it('Clones a config', () => { + // Create clone source and destination Linodes. + const createCloneTestLinodes = async () => { + return Promise.all([ + createTestLinode(null, { waitForBoot: true }), + createTestLinode(), + ]); + }; + + // Create clone and source destination Linodes, then proceed with clone flow. + cy.defer( + createCloneTestLinodes(), + 'Waiting for 2 Linodes to be created' + ).then(([sourceLinode, destLinode]: [Linode, Linode]) => { + const kernel = findKernelById(kernels, 'linode/latest-64bit'); + const sharedConfigLabel = 'cy-test-sharable-config'; + + cy.visitWithLogin(`/linodes/${sourceLinode.id}/configurations`); + + // Add a new configuration that we can share across our Linodes. ui.button - .findByTitle('Save Changes') - .scrollIntoView() - .should('be.visible') + .findByTitle('Add Configuration') .should('be.enabled') + .should('be.visible') .click(); - }); - cy.wait(['@updateLinodeConfigs', '@getLinodeConfigs', '@getVPC']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); - }); - }); - it('Boots an existing config', () => { - cy.defer(createAndBootLinode()).then((linode: Linode) => { - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - interceptRebootLinode(linode.id).as('rebootLinode'); + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(sharedConfigLabel); + + cy.findByText('Select a Kernel') + .scrollIntoView() + .click() + .type('Latest 64 bit{enter}'); - containsVisible('My Debian 10 Disk Profile – GRUB 2'); - cy.findByText('Boot').click(); + ui.buttonGroup + .findButtonByTitle('Add Configuration') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); - ui.dialog - .findByTitle('Confirm Boot') - .should('be.visible') - .within(() => { - containsVisible( - 'Are you sure you want to boot "My Debian 10 Disk Profile"?' - ); - ui.button - .findByTitle('Boot') + // Confirm that new configuration is listed in table. + cy.findByLabelText('List of Configurations').within(() => { + cy.findByText(`${sharedConfigLabel} – ${kernel.label}`) .should('be.visible') - .should('be.enabled') - .click(); + .closest('tr') + .within(() => { + cy.findByText('eth0 – Public Internet').should('be.visible'); + }); }); - cy.wait('@rebootLinode').its('response.statusCode').should('eq', 200); - - ui.toast.assertMessage( - 'Successfully booted config My Debian 10 Disk Profile' - ); - cy.findByText('REBOOTING').should('be.visible'); - }); - }); + // Initiate configuration clone flow. + ui.actionMenu + .findByTitle(`Action menu for Linode Config ${sharedConfigLabel}`) + .should('be.visible') + .click(); - it('Clones an existing config', () => { - // Create a destination Linode to clone to - // And delete the default config - createLinode({ - label: 'cy-test-clone-destination-linode', - }).then((linode: Linode) => { - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); - ui.actionMenu - .findByTitle('Action menu for Linode Config My Debian 10 Disk Profile') - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + cy.findByTestId('config-clone-selection-details') + .should('be.visible') + .within(() => { + ui.button.findByTitle('Clone').should('be.disabled'); + cy.findByLabelText('Linode').should('be.visible').click(); - ui.dialog - .findByTitle('Confirm Delete') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); + ui.select.findItemByText(destLinode.label).click(); + ui.button.findByTitle('Clone').should('be.enabled').click(); + }); - ui.toast.assertMessage( - 'Configuration My Debian 10 Disk Profile successfully deleted' - ); - cy.findByLabelText('List of Configurations').within(() => { - containsVisible('No data to display.'); + // Confirm toast message and that UI updates to reflect clone in progress. + ui.toast.assertMessage( + `Linode ${sourceLinode.label} successfully cloned to ${destLinode.label}.` + ); + cy.findByText(/CLONING \(\d+%\)/).should('be.visible'); }); + }); - // Create a source Linode to clone from + /* + * - Confirms Linode config delete flow end-to-end using real API requests. + * - Confirms that config can be deleted and related API requests succeed. + * - Confirms that Cloud Manager UI automatically updates to reflect deleted config. + */ + it('Deletes a config', () => { cy.defer( - createLinodeAndGetConfig({ - waitForLinodeToBeRunning: true, - linodeConfigRequestOverride: { - label: 'cy-test-clone-origin-linode', - }, - }), + createLinodeAndGetConfig(), 'creating a linode and getting its config' ).then(([linode, config]: [Linode, Config]) => { + // Get kernel info for config to be deleted. + const kernel = findKernelById(kernels, config.kernel); + interceptDeleteLinodeConfig(linode.id, config.id).as( 'deleteLinodeConfig' ); cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - // Add a sharable config to the source Linode - cy.findByText('Add Configuration').click(); + // Confirm that config is listed and initiate deletion. + cy.findByText(`${config.label} – ${kernel.label}`).should('be.visible'); + ui.actionMenu + .findByTitle(`Action menu for Linode Config ${config.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + // Confirm config deletion. ui.dialog - .findByTitle('Add Configuration') + .findByTitle('Confirm Delete') .should('be.visible') .within(() => { - cy.get('#label').type(`sharable-configuration`); - ui.buttonGroup - .findButtonByTitle('Add Configuration') + ui.button + .findByTitle('Delete') .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); }); + // Confirm request succeeds, toast appears, and config is removed from list. + cy.wait('@deleteLinodeConfig') + .its('response.statusCode') + .should('eq', 200); + + ui.toast.assertMessage( + `Configuration ${config.label} successfully deleted` + ); + cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible( - `sharable-configuration – Latest 64 bit (6.7.9-x86_64-linode163)` - ); - containsVisible('eth0 – Public Internet'); + cy.contains('No data to display.').should('be.visible'); }); + }); + }); + }); - // Clone the thing - ui.actionMenu - .findByTitle('Action menu for Linode Config sharable-configuration') - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); + describe('Mocked', () => { + const region: Region = getRegionById('us-southeast'); + const mockKernel = kernelFactory.build(); + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); - cy.findByTestId('config-clone-selection-details') - .should('be.visible') - .within(() => { - ui.button.findByTitle('Clone').should('be.disabled'); - cy.findByRole('combobox').should('be.visible').click(); - ui.select - .findItemByText('cy-test-clone-destination-linode') - .click(); - ui.button.findByTitle('Clone').should('be.enabled').click(); + // Mock config with public internet for eth0 and VLAN for eth1. + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + label: randomLabel(), + kernel: mockKernel.id, + interfaces: [ + LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + label: null, + }), + LinodeConfigInterfaceFactory.build({ + label: randomLabel(), + purpose: 'vlan', + }), + ], + }); + + const mockVLANs: VLAN[] = VLANFactory.buildList(2); + + /* + * - Tests Linode config create and VPC interface assignment UI flows using mock API data. + * - Confirms that VPC can be assigned as eth0, eth1, and eth2. + * - Confirms public internet access/NAT helper text appears when VPC is set as eth0. + * - Confirms that "REBOOT NEEDED" status indicator appears upon creating VPC config. + */ + it('Creates a new config and assigns a VPC as a network interface', () => { + const mockLinode = linodeFactory.build({ + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + // Mock config with VPC for eth0 and no other interfaces. + const mockConfigWithVpc: Config = { + ...mockConfig, + interfaces: [ + LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + active: false, + label: null, + }), + ], + }; + + // Mock a Linode with no existing configs, then visit its details page. + mockGetLinodeKernel(mockKernel.id, mockKernel); + mockGetLinodeKernels([mockKernel]); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeDisks(mockLinode.id, []).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + mockGetLinodeConfigs(mockLinode.id, []).as('getConfigs'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetVLANs(mockVLANs); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); + cy.wait(['@getConfigs', '@getDisks', '@getLinode', '@getVolumes']); + + // Confirm that there are no configurations displayed. + cy.findByLabelText('List of Configurations').within(() => { + cy.findByText('No data to display.').should('be.visible'); + }); + + // Mock requests to create new config and re-fetch configs. + mockCreateLinodeConfigs(mockLinode.id, mockConfigWithVpc).as( + 'createLinodeConfig' + ); + mockGetLinodeConfigs(mockLinode.id, [mockConfigWithVpc]).as( + 'getLinodeConfigs' + ); + + // Create new config. + cy.findByText('Add Configuration').click(); + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + cy.get('#label').type(`${mockConfigWithVpc.label}`); + + // Confirm that "VPC" can be selected for either "eth0", "eth1", or "eth2". + // Add VPC to eth0 + cy.get('[data-qa-textfield-label="eth0"]') + .scrollIntoView() + .click() + .type('VPC'); + + ui.select.findItemByText('VPC').should('be.visible').click(); + + // Confirm that internet access warning is displayed when eth0 is set + // to VPC. + cy.findByText(NOT_NATTED_HELPER_TEXT).should('be.visible'); + + // Confirm that VPC is an option for eth1 and eth2, but don't select them. + ['eth1', 'eth2'].forEach((interfaceName) => { + cy.get(`[data-qa-textfield-label="${interfaceName}"]`) + .scrollIntoView() + .click() + .type('VPC'); + + ui.select.findItemByText('VPC').should('be.visible'); + + cy.get(`[data-qa-textfield-label="${interfaceName}"]`).click(); }); - ui.toast.assertMessage( - 'Linode cy-test-clone-origin-linode successfully cloned to cy-test-clone-destination-linode.' + ui.buttonGroup + .findButtonByTitle('Add Configuration') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createLinodeConfig', '@getLinodeConfigs', '@getVPC']); + + // Confirm that VPC has been assigned to eth0, and that "REBOOT NEEDED" + // status message is shown. + cy.findByLabelText('List of Configurations').within(() => { + cy.contains(`${mockConfig.label} – ${mockKernel.label}`).should( + 'be.visible' ); + cy.contains(`eth0 – VPC: ${mockVPC.label}`).should('be.visible'); }); + + cy.findByText('REBOOT NEEDED').should('be.visible'); }); - }); - it('Deletes an existing config', () => { - cy.defer( - createLinodeAndGetConfig({ - linodeConfigRequestOverride: { - label: 'cy-test-delete-config-linode', - }, - }), - 'creating a linode and getting its config' - ).then(([linode, config]: [Linode, Config]) => { - interceptDeleteLinodeConfig(linode.id, config.id).as( - 'deleteLinodeConfig' - ); - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + /* + * - Tests Linode config edit and VPC interface assignment UI flows using mock API data. + * - Confirms that VPC can be assigned as eth2 in addition to existing interfaces. + * - Confirms that "REBOOT NEEDED" status indicator appears upon creating VPC config. + */ + it('Edits an existing config and assigns a VPC as a network interface', () => { + const mockLinode = linodeFactory.build({ + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); - containsVisible('My Debian 10 Disk Profile – GRUB 2'); - ui.actionMenu - .findByTitle('Action menu for Linode Config My Debian 10 Disk Profile') - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + // Mock config with public internet eth0, VLAN eth1, and VPC eth2. + const mockConfigWithVpc: Config = { + ...mockConfig, + interfaces: [ + ...mockConfig.interfaces, + LinodeConfigInterfaceFactoryWithVPC.build({ + label: undefined, + vpc_id: mockVPC.id, + active: false, + }), + ], + }; + + mockGetLinodeKernel(mockKernel.id, mockKernel); + mockGetLinodeKernels([mockKernel]); + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + + mockGetLinodeDisks(mockLinode.id, []).as('getDisks'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getConfig'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + + cy.visitWithLogin(`/linodes/${mockLinode.id}/configurations`); + cy.wait(['@getLinode', '@getConfig', '@getDisks', '@getVolumes']); + + // Find configuration in list and click its "Edit" button. + cy.findByLabelText('List of Configurations').within(() => { + cy.findByText(`${mockConfig.label} – ${mockKernel.label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button.findByTitle('Edit').click(); + }); + }); + + // Set up mocks for config update. + mockGetVLANs(mockVLANs); + mockGetVPC(mockVPC).as('getVPC'); + mockUpdateLinodeConfigs(mockLinode.id, mockConfigWithVpc).as( + 'updateLinodeConfigs' + ); + mockGetLinodeConfigs(mockLinode.id, [mockConfigWithVpc]).as( + 'getLinodeConfigs' + ); ui.dialog - .findByTitle('Confirm Delete') + .findByTitle('Edit Configuration') .should('be.visible') .within(() => { + // Set eth2 to VPC and submit. + cy.get('[data-qa-textfield-label="eth2"]') + .scrollIntoView() + .click() + .type('VPC{enter}'); + ui.button - .findByTitle('Delete') + .findByTitle('Save Changes') .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); }); - cy.wait('@deleteLinodeConfig') - .its('response.statusCode') - .should('eq', 200); - ui.toast.assertMessage( - 'Configuration My Debian 10 Disk Profile successfully deleted' - ); + cy.wait(['@updateLinodeConfigs', '@getLinodeConfigs', '@getVPC']); + + // Confirm that VLAN and VPC have been assigned. cy.findByLabelText('List of Configurations').within(() => { - containsVisible('No data to display.'); + cy.contains(`${mockConfig.label} – ${mockKernel.label}`).should( + 'be.visible' + ); + cy.contains('eth0 – Public Internet').should('be.visible'); + cy.contains(`eth2 – VPC: ${mockVPC.label}`).should('be.visible'); }); + + cy.findByText('REBOOT NEEDED').should('be.visible'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index afcf629e103..86838505220 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -1,6 +1,5 @@ // TODO: Cypress // Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 -import { fbtClick } from 'support/helpers'; import { ui } from 'support/ui'; import { regionFactory, @@ -13,6 +12,13 @@ import { mockGetRegionAvailability, } from 'support/intercepts/regions'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; + +import type { Flags } from 'src/featureFlags'; const mockRegions = [ regionFactory.build({ @@ -72,6 +78,11 @@ const mockGPUType = [ label: 'gpu-1', class: 'gpu', }), + linodeTypeFactory.build({ + id: 'gpu-2', + label: 'gpu-2 Ada', + class: 'gpu', + }), ]; const mockLinodeTypes = [ @@ -99,7 +110,7 @@ const k8PlansPanel = '[data-qa-tp="Add Node Pools"]'; const planSelectionTable = 'List of Linode Plans'; const notices = { - limitedAvailability: '[data-testid="limited-availability"]', + limitedAvailability: '[data-testid="disabled-plan-tooltip"]', unavailable: '[data-testid="notice-error"]', }; @@ -136,9 +147,12 @@ describe('displays linode plans panel based on availability', () => { cy.findAllByRole('row').should('have.length', 5); cy.get('[id="dedicated-1"]').should('be.enabled'); cy.get('[id="dedicated-2"]').should('be.enabled'); + cy.get( + '[aria-label="dedicated-3 - This plan has limited deployment availability."]' + ); cy.get('[id="dedicated-3"]').should('be.disabled'); cy.get('[id="g6-dedicated-64"]').should('be.disabled'); - cy.findAllByTestId('limited-availability').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); }); }); @@ -147,7 +161,7 @@ describe('displays linode plans panel based on availability', () => { // Should contain 3 plans (4 rows including the header row) // Should have 0 disabled plan // Should have no tooltip for the disabled plan - fbtClick('Shared CPU'); + cy.findByText('Shared CPU').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 0); @@ -156,7 +170,7 @@ describe('displays linode plans panel based on availability', () => { cy.get('[id="shared-1"]').should('be.enabled'); cy.get('[id="shared-2"]').should('be.enabled'); cy.get('[id="shared-3"]').should('be.enabled'); - cy.findAllByTestId('limited-availability').should('have.length', 0); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); @@ -165,7 +179,7 @@ describe('displays linode plans panel based on availability', () => { // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) - fbtClick('High Memory'); + cy.findByText('High Memory').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.limitedAvailability).should('be.visible'); @@ -173,24 +187,7 @@ describe('displays linode plans panel based on availability', () => { cy.findByRole('table', { name: planSelectionTable }).within(() => { cy.findAllByRole('row').should('have.length', 2); cy.get('[id="highmem-1"]').should('be.disabled'); - cy.findAllByTestId('limited-availability').should('have.length', 1); - }); - }); - - // GPU tab - // Should have the unavailable notice - // Should contain 1 plan (2 rows including the header row) - // Should have its panel disabled - // Should not have tooltip for the disabled plan (not needed on disabled panels) - fbtClick('GPU'); - cy.get(linodePlansPanel).within(() => { - cy.findAllByRole('alert').should('have.length', 1); - cy.get(notices.unavailable).should('be.visible'); - - cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 2); - cy.get('[id="gpu-1"]').should('be.disabled'); - cy.findAllByTestId('limited-availability').should('have.length', 0); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -200,7 +197,7 @@ describe('displays linode plans panel based on availability', () => { // Should contain 1 plan (2 rows including the header row) // Should have its whole panel disabled // Should not have tooltip for the disabled plan (not needed on disabled panels) - fbtClick('Premium CPU'); + cy.findByText('Premium CPU').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.unavailable).should('be.visible'); @@ -208,7 +205,7 @@ describe('displays linode plans panel based on availability', () => { cy.findByRole('table', { name: planSelectionTable }).within(() => { cy.findAllByRole('row').should('have.length', 2); cy.get('[id="g7-premium-64"]').should('be.disabled'); - cy.findAllByTestId('limited-availability').should('have.length', 0); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); }); @@ -265,9 +262,15 @@ describe('displays kubernetes plans panel based on availability', () => { cy.get('[data-qa-plan-row="dedicated-3"]').within(() => { cy.get('[data-testid="decrement-button"]').should('be.disabled'); cy.get('[data-testid="increment-button"]').should('be.disabled'); - cy.findByRole('button', { name: 'Add' }).should('be.disabled'); + cy.get('[data-testid="Button"]') + .should( + 'have.attr', + 'aria-label', + 'This plan has limited deployment availability.' + ) + .should('be.disabled'); }); - cy.findAllByTestId('limited-availability').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); }); }); @@ -276,7 +279,7 @@ describe('displays kubernetes plans panel based on availability', () => { // Should contain 3 plans (4 rows including the header row) // Should have 1 disabled plan // Should have tooltip for the disabled plan (not more than half disabled plans in the panel) - fbtClick('Shared CPU'); + cy.findByText('Shared CPU').click(); cy.get(k8PlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 0); @@ -294,7 +297,7 @@ describe('displays kubernetes plans panel based on availability', () => { 'not.have.attr', 'disabled' ); - cy.findAllByTestId('limited-availability').should('have.length', 0); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); @@ -303,7 +306,7 @@ describe('displays kubernetes plans panel based on availability', () => { // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) - fbtClick('High Memory'); + cy.findByText('High Memory').click(); cy.get(k8PlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.limitedAvailability).should('be.visible'); @@ -314,7 +317,7 @@ describe('displays kubernetes plans panel based on availability', () => { 'have.attr', 'disabled' ); - cy.findAllByTestId('limited-availability').should('have.length', 1); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -324,7 +327,7 @@ describe('displays kubernetes plans panel based on availability', () => { // Should contain 1 plan (2 rows including the header row) // Should have its whole panel disabled // Should not have tooltip for the disabled plan (not needed on disabled panels) - fbtClick('Premium CPU'); + cy.findByText('Premium CPU').click(); cy.get(k8PlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.unavailable).should('be.visible'); @@ -335,7 +338,54 @@ describe('displays kubernetes plans panel based on availability', () => { 'have.attr', 'disabled' ); - cy.findAllByTestId('limited-availability').should('have.length', 0); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); + }); + }); + }); +}); + +describe('displays specific linode plans for GPU', () => { + before(() => { + mockGetRegions(mockRegions).as('getRegions'); + mockGetLinodeTypes(mockLinodeTypes).as('getLinodeTypes'); + mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( + 'getRegionAvailability' + ); + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + planDivider: true, + }), + }); + mockGetFeatureFlagClientstream(); + }); + + it('Should render divided tables when GPU divider enabled', () => { + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + // GPU tab + // Should display two separate tables + cy.findByText('GPU').click(); + cy.get(linodePlansPanel).within(() => { + cy.findAllByRole('alert').should('have.length', 1); + cy.get(notices.unavailable).should('be.visible'); + + cy.findByRole('table', { + name: 'List of NVIDIA RTX 4000 Ada Plans', + }).within(() => { + cy.findByText('NVIDIA RTX 4000 Ada').should('be.visible'); + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="gpu-2"]').should('be.disabled'); + }); + + cy.findByRole('table', { + name: 'List of NVIDIA Quadro RTX 6000 Plans', + }).within(() => { + cy.findByText('NVIDIA Quadro RTX 6000').should('be.visible'); + cy.findAllByRole('row').should('have.length', 2); + cy.get('[id="gpu-1"]').should('be.disabled'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index 72bd950181b..848aeb2fc87 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -1,8 +1,8 @@ -import { createLinode } from 'support/api/linodes'; -import { containsVisible, fbtVisible } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; +import { createTestLinode } from 'support/util/linodes'; +import type { Linode } from '@linode/api-v4'; authenticate(); describe('switch linode state', () => { @@ -10,13 +10,19 @@ describe('switch linode state', () => { cleanUp(['linodes']); }); + /* + * - Confirms that a Linode can be shut down from the Linodes landing page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that landing page UI updates to reflect Linode power state. + * - Does not wait for Linode to finish being shut down before succeeding. + */ it('powers off a linode from landing page', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode()).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Running'); + cy.contains('Running').should('be.visible'); }); ui.actionMenu @@ -40,17 +46,22 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Shutting Down'); - cy.contains('Offline', { timeout: 300000 }).should('be.visible'); + cy.contains('Shutting Down').should('be.visible'); }); }); }); + /* + * - Confirms that a Linode can be shut down from its details page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that details page UI updates to reflect Linode power state. + * - Waits for Linode to fully shut down before succeeding. + */ it('powers off a linode from details page', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode()).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); - fbtVisible(linode.label); + cy.contains('RUNNING').should('be.visible'); + cy.findByText(linode.label).should('be.visible'); cy.findByText('Power Off').should('be.visible').click(); ui.dialog @@ -63,18 +74,24 @@ describe('switch linode state', () => { .should('be.enabled') .click(); }); - containsVisible('SHUTTING DOWN'); + cy.contains('SHUTTING DOWN').should('be.visible'); cy.contains('OFFLINE', { timeout: 300000 }).should('be.visible'); }); }); + /* + * - Confirms that a Linode can be booted from the Linode landing page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that landing page UI updates to reflect Linode power state. + * - Waits for Linode to finish booting up before succeeding. + */ it('powers on a linode from landing page', () => { - createLinode({ booted: false }).then((linode) => { + cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Offline'); + cy.contains('Offline').should('be.visible'); }); ui.actionMenu @@ -98,17 +115,23 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Booting'); + cy.contains('Booting').should('be.visible'); cy.contains('Running', { timeout: 300000 }).should('be.visible'); }); }); }); + /* + * - Confirms that a Linode can be booted from its details page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that details page UI updates to reflect Linode power state. + * - Does not wait for Linode to finish booting up before succeeding. + */ it('powers on a linode from details page', () => { - createLinode({ booted: false }).then((linode) => { + cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('OFFLINE'); - fbtVisible(linode.label); + cy.contains('OFFLINE').should('be.visible'); + cy.findByText(linode.label).should('be.visible'); cy.findByText('Power On').should('be.visible').click(); ui.dialog @@ -121,18 +144,24 @@ describe('switch linode state', () => { .should('be.enabled') .click(); }); - containsVisible('BOOTING'); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + + cy.contains('BOOTING').should('be.visible'); }); }); + /* + * - Confirms that a Linode can be rebooted from the Linode landing page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that landing page UI updates to reflect Linode power state. + * - Does not wait for Linode to finish rebooting before succeeding. + */ it('reboots a linode from landing page', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode()).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Running'); + cy.contains('Running').should('be.visible'); }); ui.actionMenu @@ -156,17 +185,22 @@ describe('switch linode state', () => { cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') .within(() => { - containsVisible('Rebooting'); - cy.contains('Running', { timeout: 300000 }).should('be.visible'); + cy.contains('Rebooting').should('be.visible'); }); }); }); + /* + * - Confirms that a Linode can be rebooted from its details page. + * - Confirms flow end-to-end using real API requests. + * - Confirms that details page UI updates to reflect Linode power state. + * - Waits for Linode to finish rebooting before succeeding. + */ it('reboots a linode from details page', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode()).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); - fbtVisible(linode.label); + cy.contains('RUNNING').should('be.visible'); + cy.findByText(linode.label).should('be.visible'); cy.findByText('Reboot').should('be.visible').click(); ui.dialog @@ -179,7 +213,7 @@ describe('switch linode state', () => { .should('be.enabled') .click(); }); - containsVisible('REBOOTING'); + cy.contains('REBOOTING').should('be.visible'); cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); }); }); 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 e0285bc6751..3594b8d7eab 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 @@ -120,6 +120,8 @@ describe('object storage access key end-to-end tests', () => { const bucketRequest = objectStorageBucketFactory.build({ label: bucketLabel, cluster: bucketCluster, + // Default factory sets `cluster` and `region`, but API does not accept `region` yet. + region: undefined, }); // Create a bucket before creating access key. 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 37841935c51..84d67db2cb4 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 @@ -69,6 +69,7 @@ describe('object storage access keys smoke tests', () => { cy.findByLabelText('Label').click().type(mockAccessKey.label); ui.buttonGroup .findButtonByTitle('Create Access Key') + .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); 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 1bd55fd7712..c53fdf988c3 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 @@ -53,7 +53,14 @@ const getNonEmptyBucketMessage = (bucketLabel: string) => { * @returns Promise that resolves to created Bucket. */ const setUpBucket = (label: string, cluster: string) => { - return createBucket(objectStorageBucketFactory.build({ label, cluster })); + return createBucket( + objectStorageBucketFactory.build({ + label, + cluster, + // Default factory sets `region`, but API does not accept it yet. + region: undefined, + }) + ); }; /** @@ -343,8 +350,10 @@ describe('object storage end-to-end tests', () => { assertStatusForUrlAtAlias('@bucketObjectUrl', 403); // Make object public, confirm it can be accessed, then close drawer. - cy.findByText('Access Control List (ACL)') + cy.findByLabelText('Access Control List (ACL)') .should('be.visible') + .should('not.have.value', 'Loading access...') + .should('have.value', 'Private') .click() .type('Public Read'); @@ -417,8 +426,10 @@ describe('object storage end-to-end tests', () => { cy.wait('@getBucketAccess'); // Make object public, confirm it can be accessed. - cy.findByText('Access Control List (ACL)') + cy.findByLabelText('Access Control List (ACL)') .should('be.visible') + .should('not.have.value', 'Loading access...') + .should('have.value', 'Private') .click() .type('Public Read'); 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 5386f787eef..604f7550039 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 @@ -131,7 +131,7 @@ describe('OneClick Apps (OCA)', () => { description: 'Minecraft OCA', ordinal: 10, logo_url: 'assets/Minecraft.svg', - images: ['linode/debian11', 'linode/ubuntu20.04'], + images: ['linode/debian11', 'linode/ubuntu22.04'], deployments_total: 18854, deployments_active: 412, is_public: true, @@ -161,7 +161,7 @@ describe('OneClick Apps (OCA)', () => { const firstName = randomLabel(); const password = randomString(16); - const image = 'linode/ubuntu20.04'; + const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); const region = chooseRegion(); const linodeLabel = randomLabel(); 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 new file mode 100644 index 00000000000..088b40ce4c1 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -0,0 +1,141 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory, placementGroupFactory } from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { ui } from 'support/ui/'; + +import type { Flags } from 'src/featureFlags'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreatePlacementGroup, + mockGetPlacementGroups, +} from 'support/intercepts/placement-groups'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +const mockAccount = accountFactory.build(); + +describe('Placement Group create flow', () => { + beforeEach(() => { + // TODO Remove feature flag mocks when `placementGroups` flag is retired. + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }); + mockGetFeatureFlagClientstream(); + mockGetAccount(mockAccount); + }); + + /* + * - Confirms Placement Group create UI flow using mock API data. + * - Confirms that outgoing Placement Group create request contains expected data. + * - Confirms that Cloud automatically updates to list new Placement Group on landing page. + */ + it('can create Placement Group', () => { + const mockRegions = regionFactory.buildList(5, { + placement_group_limits: { + maximum_pgs_per_customer: randomNumber(), + }, + capabilities: [ + 'Linodes', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Placement Group', + 'Vlans', + 'Premium Plans', + ], + }); + + const mockPlacementGroupRegion = chooseRegion({ + regions: mockRegions, + capabilities: ['Placement Group'], + }); + + const mockPlacementGroup = placementGroupFactory.build({ + label: randomLabel(), + region: mockPlacementGroupRegion.id, + affinity_type: 'anti_affinity:local', + is_strict: true, + is_compliant: true, + }); + + const placementGroupLimitMessage = `Maximum placement groups in region: ${mockPlacementGroupRegion.placement_group_limits.maximum_pgs_per_customer}`; + const affinityTypeMessage = + 'Once you create a placement group, you cannot change its Affinity Type Enforcement setting.'; + + mockGetRegions(mockRegions); + mockGetPlacementGroups([]).as('getPlacementGroups'); + mockCreatePlacementGroup(mockPlacementGroup).as('createPlacementGroup'); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + // Confirm that create button is disabled before user selects region, etc. + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.disabled'); + + // Enter label, select region, and submit form. + cy.findByLabelText('Label').type(mockPlacementGroup.label); + + cy.findByLabelText('Region') + .click() + .type(`${mockPlacementGroupRegion.label}{enter}`); + + cy.findByText(placementGroupLimitMessage).should('be.visible'); + cy.findByText(affinityTypeMessage).should('be.visible'); + + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for outgoing API request and confirm that payload contains + // the options/data chosen by the user. + cy.wait('@createPlacementGroup').then((xhr) => { + const requestPayload = xhr.request?.body; + expect(requestPayload['affinity_type']).to.equal('anti_affinity:local'); + expect(requestPayload['is_strict']).to.equal(true); + expect(requestPayload['label']).to.equal(mockPlacementGroup.label); + expect(requestPayload['region']).to.equal(mockPlacementGroupRegion.id); + }); + + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully created.` + ); + + // Confirm that Cloud automatically updates to list the new Placement Group, + // and that the expected information is displayed. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Anti-affinity').should('be.visible'); + cy.findByText('Strict').should('be.visible'); + cy.findByText(mockPlacementGroupRegion.label).should('be.visible'); + cy.findByText('Non-compliant').should('not.exist'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts new file mode 100644 index 00000000000..e99a3ffecf7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -0,0 +1,259 @@ +/** + * @file Cypress integration tests for VM Placement Groups deletion flows. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; + +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockDeletePlacementGroup, + mockGetPlacementGroups, + mockUnassignPlacementGroupLinodes, +} from 'support/intercepts/placement-groups'; +import { + accountFactory, + linodeFactory, + placementGroupFactory, +} from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; +import { mockGetLinodes } from 'support/intercepts/linodes'; + +// Mock an account with 'Placement Group' capability. +const mockAccount = accountFactory.build(); + +// Warning stating that Placement Group deletion is permanent. +const deletionWarning = + 'Deleting a placement group is permanent and cannot be undone.'; + +// Warning stating that Linodes must be unassigned before Placement Group deletion. +const unassignWarning = + 'You need to unassign all Linodes before deleting a placement group.'; + +// Landing page empty state text. +const emptyStateMessage = + 'Control the physical placement or distribution of Linode instances within a data center or availability zone.'; + +describe('Placement Group deletion', () => { + beforeEach(() => { + // TODO Remove feature flag mocks when `placementGroups` flag is retired. + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }); + mockGetFeatureFlagClientstream(); + mockGetAccount(mockAccount); + }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. + * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + */ + it('can delete without Linodes assigned', () => { + const mockPlacementGroupRegion = chooseRegion(); + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + // Click "Delete" button next to the mock Placement Group. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); + mockGetPlacementGroups([]).as('getPlacementGroups'); + + // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByText(deletionWarning).should('be.visible'); + cy.findByText(unassignWarning).should('not.exist'); + + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that UI updates to reflect deleted Placement Group. + cy.wait(['@deletePlacementGroup', '@getPlacementGroups']); + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully deleted.` + ); + cy.findByText(emptyStateMessage).should('be.visible'); + }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms deletion flow when Placement Group has one or more Linodes assigned to it. + * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. + * - Confirms that UI automatically updates to reflect unassigned Linodes during deletion. + * - Confirms that UI automatically updates to reflect deleted Placement Group. + */ + it('can delete with Linodes assigned', () => { + const mockPlacementGroupRegion = chooseRegion(); + + // Linodes that are assigned to the Placement Group being deleted. + const mockPlacementGroupLinodes = buildArray(3, () => + linodeFactory.build({ + label: randomLabel(), + id: randomNumber(), + region: mockPlacementGroupRegion.id, + }) + ); + + // Placement Group that will be deleted. + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: mockPlacementGroupLinodes.map((linode) => ({ + linode_id: linode.id, + is_compliant: true, + })), + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + // Second unrelated Placement Group to verify landing page content after deletion. + const secondMockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); + mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( + 'getPlacementGroups' + ); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + // Click "Delete" button next to the mock Placement Group. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm deletion warning appears and that form cannot be submitted + // while Linodes are assigned. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByText(deletionWarning).should('be.visible'); + cy.findByText(unassignWarning).should('be.visible'); + + // Confirm that type-to-confirm and submit button are disabled while + // Linodes remain assigned. + cy.findByLabelText('Placement Group').should('be.disabled'); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.disabled'); + + // Unassign each Linode. + cy.get('[data-qa-selection-list]').within(() => { + mockPlacementGroupLinodes.forEach((mockLinode, i) => { + // Update Placement Group mock to reflect each unassignment. + const placementGroupAfterUnassignment = { + ...mockPlacementGroup, + members: mockPlacementGroup.members.slice(i + 1), + }; + + mockUnassignPlacementGroupLinodes( + mockPlacementGroup.id, + placementGroupAfterUnassignment + ).as('unassignLinode'); + mockGetPlacementGroups([ + placementGroupAfterUnassignment, + secondMockPlacementGroup, + ]).as('getPlacementGroups'); + + cy.findByText(mockLinode.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@unassignLinode'); + cy.findByText(mockLinode.label).should('not.exist'); + }); + }); + + // Confirm that Type-to-Confirm is now enabled, enter label, and submit. + cy.findByLabelText('Placement Group') + .should('be.enabled') + .type(mockPlacementGroup.label); + + mockDeletePlacementGroup(mockPlacementGroup.id).as( + 'deletePlacementGroup' + ); + mockGetPlacementGroups([secondMockPlacementGroup]).as( + 'getPlacementGroups' + ); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deletePlacementGroup', '@getPlacementGroups']); + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully deleted.` + ); + + // Confirm that deleted Placement Group has been removed from list and that + // other Placement Group remains. + cy.findByText(mockPlacementGroup.label).should('not.exist'); + cy.findByText(secondMockPlacementGroup.label).should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts index 7709b82b298..ebea3984ab5 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts @@ -3,7 +3,7 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { mockGetPlacementGroups } from 'support/intercepts/vm-placement'; +import { mockGetPlacementGroups } from 'support/intercepts/placement-groups'; import { ui } from 'support/ui'; import { accountFactory } from 'src/factories'; @@ -39,7 +39,7 @@ describe('VM Placement landing page', () => { }); ui.button - .findByTitle('Create Placement Groups') + .findByTitle('Create Placement Group') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 31bffd13a22..1d168718f81 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -131,6 +131,7 @@ describe('volumes', () => { id: randomNumber(), }); const newVolume = volumeFactory.build({ + linode_id: mockLinode.id, label: randomLabel(), }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 43da412e2f0..cf132d44e2c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -59,6 +59,7 @@ describe('VPC assign/unassign flows', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(2), label: randomLabel(), + linodes: [], }); const mockVPC = vpcFactory.build({ diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 7b8135af09b..6865519b8ac 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -46,6 +46,11 @@ export const requestBody = (data: Partial) => { return linodeRequest({ label, ...defaultLinodeRequestBody, ...data }); }; +/** + * Deprecated. Use `createTestLinode()` with `cy.defer()` instead. + * + * @deprecated + */ export const createLinode = (data = {}) => { return requestBody(data).then((resp) => { apiCheckErrors(resp); diff --git a/packages/manager/cypress/support/constants/domains.ts b/packages/manager/cypress/support/constants/domains.ts new file mode 100644 index 00000000000..0eedbece6c2 --- /dev/null +++ b/packages/manager/cypress/support/constants/domains.ts @@ -0,0 +1,90 @@ +import { + randomLabel, + randomIp, + randomString, + randomDomainName, +} from 'support/util/random'; + +// Array of domain records for which to test creation. +export const createDomainRecords = () => [ + { + name: 'Add an A/AAAA Record', + tableAriaLabel: 'List of Domains A/AAAA Record', + fields: [ + { + name: '[data-qa-target="Hostname"]', + value: randomLabel(), + skipCheck: false, + }, + { + name: '[data-qa-target="IP Address"]', + value: `${randomIp()}`, + skipCheck: false, + }, + ], + }, + { + name: 'Add a CNAME Record', + tableAriaLabel: 'List of Domains CNAME Record', + fields: [ + { + name: '[data-qa-target="Hostname"]', + value: randomLabel(), + skipCheck: false, + }, + { + name: '[data-qa-target="Alias to"]', + value: `${randomLabel()}.net`, + skipCheck: false, + }, + ], + }, + { + name: 'Add a TXT Record', + tableAriaLabel: 'List of Domains TXT Record', + fields: [ + { + name: '[data-qa-target="Hostname"]', + value: randomLabel(), + skipCheck: false, + }, + { + name: '[data-qa-target="Value"]', + value: `${randomLabel()}=${randomString()}`, + skipCheck: false, + }, + ], + }, + { + name: 'Add an SRV Record', + tableAriaLabel: 'List of Domains SRV Record', + fields: [ + { + name: '[data-qa-target="Service"]', + value: randomLabel(), + skipCheck: true, + }, + { + name: '[data-qa-target="Target"]', + value: randomLabel(), + approximate: true, + }, + ], + }, + { + name: 'Add a CAA Record', + tableAriaLabel: 'List of Domains CAA Record', + fields: [ + { + name: '[data-qa-target="Name"]', + value: randomLabel(), + skipCheck: false, + }, + { + name: '[data-qa-target="Value"]', + value: randomDomainName(), + skipCheck: false, + }, + ], + }, +]; diff --git a/packages/manager/cypress/support/helpers.ts b/packages/manager/cypress/support/helpers.ts index eec339af751..4cbccea82dd 100644 --- a/packages/manager/cypress/support/helpers.ts +++ b/packages/manager/cypress/support/helpers.ts @@ -2,38 +2,83 @@ finding and asserting visible without having to chain. They don't chain off of cy */ const visible = 'be.visible'; +/** + * Deprecated. Use `cy.contains(text).should('be.visible')` instead. + * + * @deprecated + */ export const containsVisible = (text: string) => { return cy.contains(text).should(visible); }; +/** + * Deprecated. Use `cy.contains(text).click()` instead. + * + * @deprecated + */ export const containsClick = (text: string) => { return cy.contains(text).click(); }; +/** + * Deprecated. Use `cy.findByPlaceholderText(text).click()` instead. + * + * @deprecated + */ export const containsPlaceholderClick = (text: string) => { return cy.get(`[placeholder="${text}"]`).click(); }; +/** + * Deprecated. Use `cy.get(element).should('be.visible')` instead. + * + * @deprecated + */ export const getVisible = (element: string) => { return cy.get(element).should(visible); }; +/** + * Deprecated. Use `cy.get(element).click()` instead. + * + * @deprecated + */ export const getClick = (element: string) => { return cy.get(element).click(); }; +/** + * Deprecated. Use `cy.findByText(text).should('be.visible')` instead. + * + * @deprecated + */ export const fbtVisible = (text: string) => { return cy.findByText(text).should(visible); }; +/** + * Deprecated. Use `cy.findByText(text).click()` instead. + * + * @deprecated + */ export const fbtClick = (text: string) => { return cy.findByText(text).click(); }; +/** + * Deprecated. Use `cy.findByLabelText(text).should('be.visible')` instead. + * + * @deprecated + */ export const fbltVisible = (text: string) => { return cy.findByLabelText(text).should(visible); }; +/** + * Deprecated. Use `cy.findByLabelText(text).click()` instead. + * + * @deprecated + */ export const fbltClick = (text: string) => { return cy.findByLabelText(text).click(); }; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 66e88b4a096..89d5f525be0 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -6,7 +6,7 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Disk, Linode, LinodeType, Volume } from '@linode/api-v4'; +import type { Disk, Linode, LinodeType, Kernel, Volume } from '@linode/api-v4'; import { makeErrorResponse } from 'support/util/errors'; /** @@ -374,3 +374,39 @@ export const mockMigrateLinode = ( {} ); }; + +/** + * Intercepts GET request to fetch Linode kernels and mocks response. + * + * @param mockKernels - Array of Kernel objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeKernels = ( + mockKernels: Kernel[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('linode/kernels*'), + paginateResponse(mockKernels) + ); +}; + +/** + * Intercepts GET request to fetch a Linode kernel and mocks response. + * + * @param kernelId - ID of Kernel for which to mock response. + * @param mockKernel - Kernel object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeKernel = ( + kernelId: string, + mockKernel: Kernel +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/kernels/${kernelId}`), + makeResponse(mockKernel) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/placement-groups.ts b/packages/manager/cypress/support/intercepts/placement-groups.ts new file mode 100644 index 00000000000..6f2bca0d5d1 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/placement-groups.ts @@ -0,0 +1,75 @@ +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; + +import type { PlacementGroup } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; + +/** + * Intercepts GET request to fetch Placement Groups and mocks response. + * + * @param placementGroups - Array of Placement Group objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetPlacementGroups = ( + placementGroups: PlacementGroup[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('placement/groups*'), + paginateResponse(placementGroups) + ); +}; + +/** + * Intercept POST request to create a Placement Group and mocks response. + * + * @param placementGroup - Placement group object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreatePlacementGroup = ( + placementGroup: PlacementGroup +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('placement/groups'), + makeResponse(placementGroup) + ); +}; + +/** + * Intercepts DELETE request to delete Placement Group and mocks response. + * + * @param placementGroupId - ID of Placement Group for which to intercept delete request. + * + * @returns Cypress chainable. + */ +export const mockDeletePlacementGroup = ( + placementGroupId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`placement/groups/${placementGroupId}`), + makeResponse({}) + ); +}; + +/** + * Intercepts POST request to unassign Linode from Placement Group and mocks response. + * + * @param placementGroupId - ID of Placement Group for which to intercept unassign request. + * @param placementGroup - Placement Group object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUnassignPlacementGroupLinodes = ( + placementGroupId: number, + placementGroup: PlacementGroup +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`placement/groups/${placementGroupId}/unassign`), + makeResponse(placementGroup) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/vm-placement.ts b/packages/manager/cypress/support/intercepts/vm-placement.ts deleted file mode 100644 index 11bc8b8a4c4..00000000000 --- a/packages/manager/cypress/support/intercepts/vm-placement.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { apiMatcher } from 'support/util/intercepts'; -import { paginateResponse } from 'support/util/paginate'; - -import type { PlacementGroup } from '@linode/api-v4'; - -/** - * Intercepts GET request to fetch Placement Groups and mocks response. - * - * @param placementGroups - Array of Placement Group objects with which to mock response. - * - * @returns Cypress chainable. - */ -export const mockGetPlacementGroups = ( - placementGroups: PlacementGroup[] -): Cypress.Chainable => { - return cy.intercept( - 'GET', - apiMatcher('placement/groups*'), - paginateResponse([]) - ); -}; diff --git a/packages/manager/cypress/support/util/kernels.ts b/packages/manager/cypress/support/util/kernels.ts new file mode 100644 index 00000000000..0df35d498ea --- /dev/null +++ b/packages/manager/cypress/support/util/kernels.ts @@ -0,0 +1,38 @@ +/** + * @file Utilities for Linode kernel retrieval and management. + */ + +import { getLinodeKernels } from '@linode/api-v4'; +import { depaginate } from './paginate'; +import { pageSize } from 'support/constants/api'; + +import type { Kernel } from '@linode/api-v4'; + +/** + * Fetches all Linode kernels. + * + * @returns Promise that resolves to an array of `Kernel` instances. + */ +export const fetchAllKernels = async (): Promise => { + return depaginate((page) => + getLinodeKernels({ page, page_size: pageSize }) + ); +}; + +/** + * Finds a `Kernel` in an array of `Kernel`s by its ID. + * + * @param kernels - Array of Kernels from which to search. + * @param kernelId - ID of Kernel to find. + * + * @throws When a Kernel with ID `kernelId` does not exist in `kernels`. + * + * @return Kernel instance with given ID. + */ +export const findKernelById = (kernels: Kernel[], kernelId: string) => { + const kernel = kernels.find((kernel) => kernel.id === kernelId); + if (!kernel) { + throw new Error(`Unable to find a Linode kernel with ID '${kernelId}'`); + } + return kernel; +}; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 3a0cb15f843..68d33007a7b 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -4,91 +4,147 @@ import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { depaginate } from './paginate'; +import { pageSize } from 'support/constants/api'; -import type { Config, Linode, LinodeConfigCreationData } from '@linode/api-v4'; +import type { Config, Linode } from '@linode/api-v4'; import type { CreateLinodeRequest } from '@linode/api-v4'; /** - * Creates a Linode and waits for it to be in "running" state. - * - * @param createPayload - Optional Linode create payload options. - * - * @returns Promis that resolves when Linode is created and booted. + * Options to control the behavior of test Linode creation. */ -export const createAndBootLinode = async ( - createPayload?: Partial -): Promise => { - const payload = createLinodeRequestFactory.build({ - label: randomLabel(), - region: chooseRegion().id, - ...(createPayload ?? {}), - }); - const linode = await createLinode(payload); +export interface CreateTestLinodeOptions { + /** Whether to wait for created Linode disks to be available before resolving. */ + waitForDisks: boolean; - await pollLinodeStatus( - linode.id, - 'running', - new SimpleBackoffMethod(5000, { - initialDelay: 15000, - maxAttempts: 25, - }) - ); + /** Whether to wait for created Linode to boot before resolving. */ + waitForBoot: boolean; +} - return linode; +/** + * Default test Linode creation options. + */ +export const defaultCreateTestLinodeOptions = { + waitForDisks: false, + waitForBoot: false, }; -interface LinodeConfigRequestOverride - extends Omit, - LinodeConfigCreationData {} - /** - * Creates a Linode and returns the first config for that Linode. + * Creates a Linode to use during tests. + * + * @param createRequestPayload - Partial Linode request payload to override default payload. + * @param options - Linode create and polling options. + * + * @returns Promise that resolves to the created Linode. */ -export const createLinodeAndGetConfig = async ({ - linodeConfigRequestOverride = {}, - waitForLinodeToBeRunning = false, -}: { - linodeConfigRequestOverride?: Partial; - waitForLinodeToBeRunning?: boolean; -}): Promise<[Linode, Config]> => { - const createPayload = createLinodeRequestFactory.build({ - label: randomLabel(), - region: chooseRegion().id, - }); - const linode = await createLinode({ - ...createPayload, - ...linodeConfigRequestOverride, - }); +export const createTestLinode = async ( + createRequestPayload?: Partial | null, + options?: Partial +): Promise => { + const resolvedOptions = { + ...defaultCreateTestLinodeOptions, + ...(options || {}), + }; - const { data: configs } = await getLinodeConfigs(linode.id); + const resolvedCreatePayload = { + ...createLinodeRequestFactory.build({ + label: randomLabel(), + image: 'linode/debian11', + region: chooseRegion().id, + }), + ...(createRequestPayload || {}), + }; + + // Display warnings for certain combinations of options/request payloads... + if (resolvedOptions.waitForDisks && resolvedOptions.waitForBoot) { + console.warn( + 'Ignoring `waitForDisks` option because `waitForBoot` takes precedence.' + ); + } + + if (!resolvedCreatePayload.booted && resolvedOptions.waitForBoot) { + console.warn( + 'Using `waitForBoot` option when Linode payload `booted` is false will cause a timeout.' + ); + } - // we may want the linode to be booted to interact with the config - waitForLinodeToBeRunning && - (await pollLinodeStatus( + const linode = await createLinode(resolvedCreatePayload); + + // Wait for disks to become available if `waitForDisks` option is set. + // We skip this step if `waitForBoot` is set, however, because waiting for boot + // implicitly waits for disks. + // + if (resolvedOptions.waitForDisks && !resolvedOptions.waitForBoot) { + // Wait 7.5 seconds before initial check, then poll again every 5 seconds. + await pollLinodeDiskStatuses( linode.id, - 'running', + 'ready', new SimpleBackoffMethod(5000, { - initialDelay: 15000, + initialDelay: 7500, maxAttempts: 25, }) - )); + ); + } - // If we don't wait for the Linode to boot, we wait for the disks to be ready. - // Wait 7.5 seconds, then poll the Linode disks every 5 seconds until they are ready. - !waitForLinodeToBeRunning && - (await pollLinodeDiskStatuses( + // Wait for Linode status to be 'running' if `waitForBoot` is true. + if (resolvedOptions.waitForBoot) { + // Wait 15 seconds before initial check, then poll again every 5 seconds. + await pollLinodeStatus( linode.id, - 'ready', + 'running', new SimpleBackoffMethod(5000, { - initialDelay: 7500, + initialDelay: 15000, maxAttempts: 25, }) - )); - - // Throw if Linode has no config. - if (!configs[0] || !linode.id) { - throw new Error('Created Linode does not have any config'); + ); } - return [linode, configs[0]]; + Cypress.log({ + name: 'createTestLinode', + message: `Create Linode '${linode.label}' (ID ${linode.id})`, + consoleProps: () => { + return { + options: resolvedOptions, + payload: resolvedCreatePayload, + linode, + }; + }, + }); + + return linode; +}; + +/** + * Creates a Linode and waits for it to be in "running" state. + * + * Deprecated. Use `createTestLinode` with `waitForBoot` set to `true`. + * + * @param createPayload - Optional Linode create payload options. + * + * @deprecated + * + * @returns Promis that resolves when Linode is created and booted. + */ +export const createAndBootLinode = async ( + createPayload?: Partial +): Promise => { + console.warn( + '`createAndBootLinode()` is deprecated. Use `createTestLinode()` instead.' + ); + return createTestLinode(createPayload, { waitForBoot: true }); +}; + +/** + * Retrieves all Config objects belonging to a Linode. + * + * @param linodeId - ID of Linode for which to retrieve Configs. + * + * @returns Promise that resolves to an array of Config objects for the given Linode. + */ +export const fetchLinodeConfigs = async ( + linodeId: number +): Promise => { + return depaginate((page) => + getLinodeConfigs(linodeId, { page, page_size: pageSize }) + ); }; diff --git a/packages/manager/package.json b/packages/manager/package.json index bdf1ae58949..d7e788a0647 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.118.1", + "version": "1.119.0", "private": true, "type": "module", "bugs": { diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index fd55ff3a646..c028a452077 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -10,6 +10,7 @@ import { useIsACLBEnabled } from './features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useAccountManagement } from './hooks/useAccountManagement'; import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; +import { useIsDatabasesEnabled } from './features/Databases/utilities'; const useStyles = makeStyles()((theme: Theme) => ({ input: { @@ -62,6 +63,7 @@ export const GoTo = React.memo(() => { const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); + const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); const onClose = () => { @@ -120,6 +122,11 @@ export const GoTo = React.memo(() => { hide: !isPlacementGroupsEnabled, href: '/placement-groups', }, + { + display: 'Databases', + hide: !isDatabasesEnabled, + href: '/databases', + }, { display: 'Domains', href: '/domains', diff --git a/packages/manager/src/MainContent.test.ts b/packages/manager/src/MainContent.test.ts deleted file mode 100644 index 676fc7a38d6..00000000000 --- a/packages/manager/src/MainContent.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - checkFlagsForMainContentBanner, - checkPreferencesForBannerDismissal, -} from './MainContent'; - -const mainContentBanner = { - key: 'Test Text Key', - link: { - text: 'Test anchor text', - url: 'https://linode.com', - }, - text: 'Test Text', -}; - -describe('checkFlagsForMainContentBanner', () => { - it('returns `true` if a valid banner is present in the flag set', () => { - expect(checkFlagsForMainContentBanner({ mainContentBanner })).toBe(true); - expect(checkFlagsForMainContentBanner({})).toBe(false); - expect( - checkFlagsForMainContentBanner({ mainContentBanner: {} as any }) - ).toBe(false); - }); -}); - -describe('checkPreferencesForBannerDismissal', () => { - it('returns `true if the specified key is preset in preferences banner dismissals', () => { - expect( - checkPreferencesForBannerDismissal( - { - main_content_banner_dismissal: { key1: true }, - }, - 'key1' - ) - ).toBe(true); - expect( - checkPreferencesForBannerDismissal( - { - main_content_banner_dismissal: { key1: true }, - }, - 'another-key' - ) - ).toBe(false); - expect(checkPreferencesForBannerDismissal({}, 'key1')).toBe(false); - }); -}); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index c42231b0688..0594cef7b01 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,6 +1,5 @@ import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { isEmpty } from 'ramda'; import * as React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; @@ -21,20 +20,19 @@ import { useNotificationContext, } from 'src/features/NotificationCenter/NotificationContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; -import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; -import { useDatabaseEnginesQuery } from 'src/queries/databases'; import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { ManagerPreferences } from 'src/types/ManagerPreferences'; -import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; +import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; -import { FlagSet } from './featureFlags'; +import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsACLBEnabled } from './features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; +import { useAccountSettings } from './queries/account/settings'; +import { useProfile } from './queries/profile'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { @@ -200,44 +198,23 @@ export const MainContent = () => { isOpen: false, }); + const SessionExpirationProvider = sessionExpirationContext.Provider; + const sessionExpirationContextValue = useDialogContext({ + isOpen: false, + }); + const [menuIsOpen, toggleMenu] = React.useState(false); - const { - _isManagedAccount, - account, - accountError, - profile, - } = useAccountManagement(); + const { data: profile } = useProfile(); const username = profile?.username || ''; - const [bannerDismissed, setBannerDismissed] = React.useState(false); - - const checkRestrictedUser = !Boolean(flags.databases) && !!accountError; - const { - error: enginesError, - isLoading: enginesLoading, - } = useDatabaseEnginesQuery(checkRestrictedUser); - - const showDatabases = - isFeatureEnabled( - 'Managed Databases', - Boolean(flags.databases), - account?.capabilities ?? [] - ) || - (checkRestrictedUser && !enginesLoading && !enginesError); - + const { isDatabasesEnabled } = useIsDatabasesEnabled(); const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); - const defaultRoot = _isManagedAccount ? '/managed' : '/linodes'; + const { data: accountSettings } = useAccountSettings(); - const shouldDisplayMainContentBanner = - !bannerDismissed && - checkFlagsForMainContentBanner(flags) && - !checkPreferencesForBannerDismissal( - preferences ?? {}, - flags?.mainContentBanner?.key - ); + const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; /** * this is the case where the user has successfully completed signup @@ -292,24 +269,12 @@ export const MainContent = () => { }); }; - /** - * otherwise just show the rest of the app. - */ return (
- - - - <> - {shouldDisplayMainContentBanner ? ( - setBannerDismissed(true)} - url={flags.mainContentBanner?.link?.url ?? ''} - /> - ) : null} + + + + toggleMenu(false)} collapse={desktopMenuIsOpen || false} @@ -322,6 +287,7 @@ export const MainContent = () => { (desktopMenuIsOpen && desktopMenuIsOpen === true), })} > + { - {showDatabases && ( + {isDatabasesEnabled && ( )} {flags.selfServeBetas && ( @@ -393,29 +359,11 @@ export const MainContent = () => {
- - -