diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d256562a42c..e7a80a5f190 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -3,7 +3,7 @@ This directory contains the GitHub Actions workflows that power continuous integration and end-to-end testing for Linode Cloud Manager. ## Continuous Integration -The `ci` workflow handles testing, building, and publishing of packages in this repository. Tests are run via [Jest](https://jestjs.io/) for `api-v4` and `manager`. +The `ci` workflow handles testing, building, and publishing of packages in this repository. Tests are run using [Vitest](https://vitest.dev/) for `api-v4` and `manager`. If the continuous integration workflow was triggered via a push to the `master` branch, the built packages are published: @@ -54,4 +54,4 @@ Cypress tests are parallelized across four containers, and tests are automatical * [_Introduction to Cypress_](https://docs.cypress.io/guides/core-concepts/introduction-to-cypress) (`docs.cypress.io`) * [Cypress: _GitHub Actions_](https://docs.cypress.io/guides/continuous-integration/github-actions#Cypress-GitHub-Action) (`docs.cypress.io`) * [Cypress: _Parallelization_](https://docs.cypress.io/guides/guides/parallelization) (`docs.cypress.io`) -* [Jest: _Getting Started_](https://jestjs.io/docs/getting-started) (`jestjs.io`) +* [Vitest: _Getting Started_](https://vitest.dev/guide/) (`vitest.dev`) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000000..e55968c0cdc --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,94 @@ +name: Code Coverage + +on: [pull_request] + +jobs: + base_branch: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.base_ref }} # The base branch of the PR (develop) + + - name: Use Node.js v18.14.0 + uses: actions/setup-node@v3 + with: + node-version: "18.14" + + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + - name: Install Dependencies + run: yarn --frozen-lockfile + + - name: Run build + run: yarn build + + - name: Run Base Branch Coverage + run: yarn coverage:summary + + - name: Write Base Coverage to an Artifact + run: | + coverage_json=$(cat ./packages/manager/coverage/coverage-summary.json) + pct=$(echo "$coverage_json" | jq -r '.total.statements.pct') + echo "$pct" > ref_code_coverage.txt + + - name: Upload Base Coverage Artifact + uses: actions/upload-artifact@v3 + with: + name: ref_code_coverage + path: ref_code_coverage.txt + + current_branch: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + needs: base_branch + + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js v18.14.0 + uses: actions/setup-node@v3 + with: + node-version: "18.14" + + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + - name: Install Dependencies + run: yarn --frozen-lockfile + + - name: Run Build + run: yarn build + + - name: Run Current Branch Coverage + run: yarn coverage:summary + + - name: Write PR Number to an Artifact + run: | + echo "${{ github.event.number }}" > pr_number.txt + + - name: Write Current Coverage to an Artifact + run: | + coverage_json=$(cat ./packages/manager/coverage/coverage-summary.json) + pct=$(echo "$coverage_json" | jq -r '.total.statements.pct') + echo "$pct" > current_code_coverage.txt + + - name: Upload PR Number Artifact + uses: actions/upload-artifact@v3 + with: + name: pr_number + path: pr_number.txt + + - name: Upload Current Coverage Artifact + uses: actions/upload-artifact@v3 + with: + name: current_code_coverage + path: current_code_coverage.txt diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml new file mode 100644 index 00000000000..b4cf419261c --- /dev/null +++ b/.github/workflows/coverage_badge.yml @@ -0,0 +1,52 @@ +name: Coverage Badge + +on: + push: + branches: + - master + +jobs: + generate-coverage-badge: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Use Node.js v18.14.0 + uses: actions/setup-node@v3 + with: + node-version: "18.14" + + - uses: actions/cache@v3 + with: + path: | + **/node_modules + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + - name: Install Dependencies + run: yarn --frozen-lockfile + + - name: Run Build + run: yarn build + + - name: Run Base Branch Coverage + run: yarn coverage:summary + + - name: Generate Coverage Badge + uses: jaywcjlove/coverage-badges-cli@7f0781807ef3e7aba97a145beca881d36451b7b7 # v1.1.1 + with: + label: '@linode/manager coverage' + source: ./packages/manager/coverage/coverage-summary.json + output: ./packages/manager/coverage/badges.svg + + - uses: jakejarvis/s3-sync-action@7ed8b112447abb09f1da74f3466e4194fc7a6311 # v0.5.1 + with: + args: --acl public-read --follow-symlinks --delete + env: + AWS_S3_ENDPOINT: https://us-east-1.linodeobjects.com + AWS_S3_BUCKET: ${{ secrets.COVERAGE_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.COVERAGE_BUCKET_ACCESS }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.COVERAGE_BUCKET_SECRET }} + AWS_REGION: us-east-1 + SOURCE_DIR: ./packages/manager/coverage \ No newline at end of file diff --git a/.github/workflows/coverage_comment.yml b/.github/workflows/coverage_comment.yml new file mode 100644 index 00000000000..c5d0970f431 --- /dev/null +++ b/.github/workflows/coverage_comment.yml @@ -0,0 +1,67 @@ +name: Coverage Comment + +on: + workflow_run: + workflows: ["Code Coverage"] + types: + - completed + +permissions: + pull-requests: write + +jobs: + comment: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Use Node.js v18.14.0 + uses: actions/setup-node@v3 + with: + node-version: "18.14" + + - name: Download PR Number Artifact + uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 + with: + workflow: "coverage.yml" + run_id: ${{ github.event.workflow_run.id }} + name: pr_number + + - name: Download Base Coverage Artifact + uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 + with: + workflow: "coverage.yml" + run_id: ${{ github.event.workflow_run.id }} + name: ref_code_coverage + + - name: Download Current Coverage Artifact + uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 + with: + workflow: "coverage.yml" + run_id: ${{ github.event.workflow_run.id }} + name: current_code_coverage + + - name: Set PR Number Environment Variables + run: | + echo "PR_NUMBER=$(cat pr_number.txt)" >> $GITHUB_ENV + + - name: Generate Coverage Comment + run: | + base_coverage=$(cat ref_code_coverage.txt) + current_coverage=$(cat current_code_coverage.txt) + if (( $(echo "$current_coverage < $base_coverage" | bc -l) )); then + icon="❌" # Error icon + else + icon="✅" # Check mark icon + fi + comment_message="**Coverage Report:** $icon
Base Coverage: $base_coverage%
Current Coverage: $current_coverage%" + echo "Coverage: $comment_message" + echo "$comment_message" > updated_comment.txt + + - name: Post Comment + uses: mshick/add-pr-comment@7c0890544fb33b0bdd2e59467fbacb62e028a096 #v2.8.1 + with: + issue: ${{ env.PR_NUMBER }} + message-path: updated_comment.txt diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index 2959616b1de..003ff965966 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -24,6 +24,8 @@ jobs: matrix: user: ["USER_1", "USER_2", "USER_3", "USER_4"] steps: + - name: install command line utilities + run: sudo apt-get install -y expect - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: diff --git a/CODEOWNERS b/CODEOWNERS index a675f778bc0..8b6b2774004 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1,5 @@ # Default code owners * @linode/frontend + +# Frontend SDET code owners for Cypress tests +/packages/manager/cypress/ @linode/frontend-sdet diff --git a/README.md b/README.md index 8ce868b7120..eb4fd54f52b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ Akamai Connected Cloud Manager +

+ Linode Manager Code Coverage +

+

CI Build Stats on develop diff --git a/docker-compose.yml b/docker-compose.yml index 2bcbb3f183b..229dd704059 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,7 @@ x-e2e-runners: condition: service_healthy env_file: ./packages/manager/.env volumes: *default-volumes - entrypoint: ['yarn', 'cy:ci'] + entrypoint: ['yarn', 'cy:e2e'] services: # Serves a local instance of Cloud Manager for Cypress to use for its tests. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 53880337b01..5f817f1f662 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -2,7 +2,7 @@ ## Unit Tests -The unit tests for Cloud Manager are written in Typescript using the [Jest](https://facebook.github.io/jest/) testing framework. Unit tests end with either `.test.tsx` or `.test.ts` file extensions and can be found throughout the codebase. +The unit tests for Cloud Manager are written in Typescript using the [Vitest](https://vitest.dev/) testing framework. Unit tests end with either `.test.tsx` or `.test.ts` file extensions and can be found throughout the codebase. To run tests, first build the **api-v4** package: @@ -29,7 +29,7 @@ yarn test myFile.test.tsx yarn test src/some-folder ``` -Jest has built-in pattern matching, so you can also do things like run all tests whose filename contains "Linode" with: +Vitest has built-in pattern matching, so you can also do things like run all tests whose filename contains "Linode" with: ``` yarn test linode @@ -45,7 +45,7 @@ Test execution will stop at the debugger statement, and you will be able to use ### React Testing Library -We have some older tests that still use the Enzyme framework, but for new tests we generally use [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). This library provides a set of tools to render React components from within the Jest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. +We have some older tests that still use the Enzyme framework, but for new tests we generally use [React Testing Library](https://testing-library.com/docs/react-testing-library/intro). This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. A simple test using this library will look something like this: @@ -68,7 +68,7 @@ import { fireEvent } from "@testing-library/react"; import { renderWithTheme } from "src/utilities/testHelpers"; import Component from "./wherever"; -const props = { onClick: jest.fn() }; +const props = { onClick: vi.fn() }; describe("My component", () => { it("should have some text", () => { @@ -92,19 +92,23 @@ await wait(() => fireEvent.click(getByText('Delete'))); ### Mocking -Jest has substantial built-in mocking capabilities, and we use many of the available patterns. We generally use them to avoid making network requests in unit tests, but there are some other cases (mentioned below). +Vitest has substantial built-in mocking capabilities, and we use many of the available patterns. We generally use them to avoid making network requests in unit tests, but there are some other cases (mentioned below). -In general, components that make network requests should take any request handlers as props. Then testing is as simple as passing `someProp: jest.fn()` and making assertions normally. When that isn't possible, you can do the following: +In general, components that make network requests should take any request handlers as props. Then testing is as simple as passing `someProp: vi.fn()` and making assertions normally. When that isn't possible, you can do the following: ```js -jest.mock("@linode/api-v4/lib/kubernetes", () => ({ - getKubeConfig: () => jest.fn(), -})); +vi.mock('@linode/api-v4/lib/kubernetes', async () => { + const actual = await vi.importActual('@linode/api-v4/lib/kubernetes'); + return { + ...actual, + getKubeConfig: () => vi.fn(), + }; +}); ``` -Some components, such as our ActionMenu, don't lend themselves well to unit testing (they often have complex DOM structures from MUI and it's hard to target). We have mocks for most of these components in a `__mocks__` directory adjacent to their respective components. To make use of these, just tell Jest to use the mock: +Some components, such as our ActionMenu, don't lend themselves well to unit testing (they often have complex DOM structures from MUI and it's hard to target). We have mocks for most of these components in a `__mocks__` directory adjacent to their respective components. To make use of these, just tell Vitest to use the mock: - jest.mock('src/components/ActionMenu/ActionMenu'); + vi.mock('src/components/ActionMenu/ActionMenu'); Any ``s rendered by the test will be simplified versions that are easier to work with. @@ -140,8 +144,7 @@ const { getByTestId } = renderWithTheme(, { We support mocking API requests both in test suites and the browser using the [msw](https://www.npmjs.com/package/msw) library. See [07-mocking-data](07-mocking-data.md) for more details. -These mocks are automatically enabled for tests (using `beforeAll` and `afterAll` in src/setupTests.ts, which is run when setting up -the Jest environment). +These mocks are automatically enabled for tests (using `beforeAll` and `afterAll` in src/setupTests.ts, which is run when setting up the Vitest environment). ## End-to-End tests diff --git a/package.json b/package.json index 875e610b018..a4be2aceb0f 100644 --- a/package.json +++ b/package.json @@ -32,16 +32,18 @@ "start:manager": "yarn workspace linode-manager start", "start:manager:ci": "yarn workspace linode-manager start:ci", "clean": "rm -rf node_modules && rm -rf packages/@linode/api-v4/node_modules && rm -rf packages/manager/node_modules && rm -rf packages/@linode/validation/node_modules", - "test": "yarn workspace linode-manager test --maxWorkers=4", + "test": "yarn workspace linode-manager test", "package-versions": "node ./scripts/package-versions/index.js", "storybook": "yarn workspace linode-manager storybook", "cy:run": "yarn workspace linode-manager cy:run", "cy:e2e": "yarn workspace linode-manager cy:e2e", - "cy:ci": "yarn cypress install && yarn cy:e2e", + "cy:ci": "yarn cy:e2e", "cy:debug": "yarn workspace linode-manager cy:debug", "cy:rec-snap": "yarn workspace linode-manager cy:rec-snap", "changeset": "node scripts/changelog/changeset.mjs", - "generate-changelogs": "node scripts/changelog/generate-changelogs.mjs" + "generate-changelogs": "node scripts/changelog/generate-changelogs.mjs", + "coverage": "yarn workspace linode-manager coverage", + "coverage:summary": "yarn workspace linode-manager coverage:summary" }, "resolutions": { "minimist": "^1.2.3", @@ -53,7 +55,6 @@ "lodash": "^4.17.21", "glob-parent": "^5.1.2", "hosted-git-info": "^5.0.0", - "minimatch": "^9.0.2", "@types/react": "^17", "yaml": "^2.3.0", "word-wrap": "^1.2.4", @@ -70,4 +71,4 @@ "node": "18.14.1" }, "dependencies": {} -} \ No newline at end of file +} diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 03da879534f..89cf1b68b8c 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,22 @@ +## [2023-12-11] - v0.106.0 + + +### Added: + +- Beta flag DC Get Well endpoints ([#9904](https://github.com/linode/manager/pull/9904)) + +### Tech Stories: + +- Update `axios` to `1.6.1` ([#9911](https://github.com/linode/manager/pull/9911)) + +### Upcoming Features: + +- Add validation to AGLB `createLoadbalancerConfiguration` and correct `routes` to `route_ids` ([#9870](https://github.com/linode/manager/pull/9870)) +- Add `protocol` to AGLB `ServiceTargetPayload` ([#9891](https://github.com/linode/manager/pull/9891)) +- Change `ca_certificate` to `certificate_id` in AGLB `ServiceTargetPayload` ([#9891](https://github.com/linode/manager/pull/9891)) +- Add `user_type` and `child_account_access` fields for Parent/Child account switching ([#9942](https://github.com/linode/manager/pull/9942)) +- Add new endpoints for Parent/Child account switching ([#9944](https://github.com/linode/manager/pull/9944)) + ## [2023-11-13] - v0.105.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 40b7618db27..da52fac15de 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.105.0", + "version": "0.106.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -41,15 +41,14 @@ "unpkg": "./lib/index.global.js", "dependencies": { "@linode/validation": "*", - "axios": "~0.21.4", + "axios": "~1.6.1", "ipaddr.js": "^2.0.0", "yup": "^0.32.9" }, "scripts": { "start": "concurrently --raw \"tsc -w --preserveWatchOutput\" \"tsup --watch\"", "build": "concurrently --raw \"tsc\" \"tsup\"", - "test": "jest", - "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand", + "test": "yarn vitest run", "lint": "yarn run eslint . --quiet --ext .js,.ts,.tsx", "typecheck": "tsc --noEmit true --emitDeclarationOnly false", "precommit": "lint-staged" @@ -58,19 +57,17 @@ "lib" ], "devDependencies": { - "@swc/jest": "^0.2.22", - "@types/jest": "^26.0.13", "@types/node": "^12.7.1", "@types/yup": "^0.29.13", - "axios-mock-adapter": "^1.18.1", + "axios-mock-adapter": "^1.22.0", "concurrently": "^4.1.1", "eslint": "^6.8.0", "eslint-plugin-ramda": "^2.5.1", "eslint-plugin-sonarjs": "^0.5.0", - "jest": "~26.4.2", "lint-staged": "^13.2.2", "prettier": "~2.2.1", - "tsup": "^6.7.0" + "tsup": "^7.2.0", + "vitest": "^0.34.6" }, "lint-staged": { "*.{ts,tsx,js}": [ @@ -80,19 +77,5 @@ ".{ts,tsx}": [ "tsc -p tsconfig.json --noEmit true --emitDeclarationOnly false" ] - }, - "jest": { - "transform": { - "^.+\\.(t)sx?$": "@swc/jest" - }, - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(tsx?)$", - "moduleFileExtensions": [ - "ts", - "tsx", - "js", - "jsx", - "json", - "node" - ] } } diff --git a/packages/api-v4/src/account/account.ts b/packages/api-v4/src/account/account.ts index fb585984394..b612da3d082 100644 --- a/packages/api-v4/src/account/account.ts +++ b/packages/api-v4/src/account/account.ts @@ -5,6 +5,7 @@ import { import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, + setHeaders, setMethod, setURL, setParams, @@ -18,8 +19,10 @@ import { CancelAccountPayload, Agreements, RegionalNetworkUtilization, + ChildAccountPayload, } from './types'; -import { Filter, ResourcePage as Page, Params } from '../types'; +import type { Filter, ResourcePage, Params, RequestOptions } from '../types'; +import type { Token } from '../profile'; /** * getAccountInfo @@ -115,8 +118,8 @@ export const getAccountAgreements = () => * */ export const getAccountAvailabilities = (params?: Params, filter?: Filter) => - Request>( - setURL(`${API_ROOT}/account/availability`), + Request>( + setURL(`${BETA_API_ROOT}/account/availability`), setMethod('GET'), setParams(params), setXFilter(filter) @@ -131,7 +134,9 @@ export const getAccountAvailabilities = (params?: Params, filter?: Filter) => */ export const getAccountAvailability = (regionId: string) => Request( - setURL(`${API_ROOT}/account/availability/${encodeURIComponent(regionId)}`), + setURL( + `${BETA_API_ROOT}/account/availability/${encodeURIComponent(regionId)}` + ), setMethod('GET') ); @@ -147,3 +152,55 @@ export const signAgreement = (data: Partial) => { setData(data) ); }; + +/** + * getChildAccounts + * + * This endpoint will return a paginated list of all Child Accounts with a Parent Account. + * The response will be similar to /account, except that it will list details for multiple accounts. + */ +export const getChildAccounts = ({ filter, params, headers }: RequestOptions) => + Request>( + setURL(`${API_ROOT}/account/child-accounts`), + setMethod('GET'), + setHeaders(headers), + setParams(params), + setXFilter(filter) + ); + +/** + * getChildAccount + * + * This endpoint will function similarly to /account/child-accounts, + * except that it will return account details for only a specific euuid. + */ +export const getChildAccount = ({ euuid, headers }: ChildAccountPayload) => + Request( + setURL(`${API_ROOT}/account/child-accounts/${encodeURIComponent(euuid)}`), + setMethod('GET'), + setHeaders(headers) + ); + +/** + * createChildAccountPersonalAccessToken + * + * This endpoint will allow Parent Account Users with the "child_account_access" grant to + * create an ephemeral token for their proxy user on a child account, using the euuid of + * that child account. As noted in previous sections, this Token will inherit the + * permissions of the Proxy User, and the token itself will not be subject to additional + * restrictions. + * + * setHeaders() will be used for creating tokens from within the proxy account. + */ +export const createChildAccountPersonalAccessToken = ({ + euuid, + headers, +}: ChildAccountPayload) => + Request( + setURL( + `${API_ROOT}/account/child-accounts/${encodeURIComponent(euuid)}/token` + ), + setMethod('POST'), + setHeaders(headers), + setData(euuid) + ); diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index e691e362903..2e98373bb62 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -1,20 +1,10 @@ -import { APIWarning } from '../types'; +import type { APIWarning, RequestOptions } from '../types'; import type { Capabilities, Region } from '../regions'; +export type UserType = 'child' | 'parent' | 'proxy'; + export interface User { - username: string; email: string; - restricted: boolean; - ssh_keys: string[]; - tfa_enabled: boolean; - verified_phone_number: string | null; - /** - * The date of when a password was set on a user. - * `null` if this user has not created a password yet - * @example 2022-02-09T16:19:26 - * @example null - */ - password_created: string | null; /** * Information for the most recent login attempt for this User. * `null` if no login attempts have been made since creation of this User. @@ -29,6 +19,19 @@ export interface User { */ status: AccountLoginStatus; } | null; + /** + * The date of when a password was set on a user. + * `null` if this user has not created a password yet + * @example 2022-02-09T16:19:26 + * @example null + */ + password_created: string | null; + restricted: boolean; + ssh_keys: string[]; + tfa_enabled: boolean; + username: string; + user_type: UserType | null; + verified_phone_number: string | null; } export interface Account { @@ -70,7 +73,7 @@ export type AccountCapability = | 'VPCs'; export interface AccountAvailability { - id: string; // will be ID of region + region: string; // will be slug of dc (matches id field of region object returned by API) unavailable: Capabilities[]; } @@ -165,18 +168,19 @@ export interface Grant { label: string; } export type GlobalGrantTypes = - | 'add_linodes' - | 'add_longview' - | 'longview_subscription' | 'account_access' - | 'cancel_account' | 'add_domains' - | 'add_stackscripts' - | 'add_nodebalancers' + | 'add_firewalls' | 'add_images' + | 'add_linodes' + | 'add_longview' + | 'add_nodebalancers' + | 'add_stackscripts' | 'add_volumes' - | 'add_firewalls' - | 'add_vpcs'; + | 'add_vpcs' + | 'cancel_account' + | 'child_account_access' + | 'longview_subscription'; export interface GlobalGrants { global: Record; @@ -222,6 +226,10 @@ export interface CancelAccountPayload { comments: string; } +export interface ChildAccountPayload extends RequestOptions { + euuid: string; +} + export type AgreementType = 'eu_model' | 'privacy_policy'; export interface Agreements { diff --git a/packages/api-v4/src/aglb/configurations.ts b/packages/api-v4/src/aglb/configurations.ts index e98a31e6967..7570fef3231 100644 --- a/packages/api-v4/src/aglb/configurations.ts +++ b/packages/api-v4/src/aglb/configurations.ts @@ -12,7 +12,10 @@ import type { ConfigurationPayload, UpdateConfigurationPayload, } from './types'; -import { UpdateConfigurationSchema } from '@linode/validation'; +import { + CreateConfigurationSchema, + UpdateConfigurationSchema, +} from '@linode/validation'; /** * getLoadbalancerConfigurations @@ -68,7 +71,7 @@ export const createLoadbalancerConfiguration = ( loadbalancerId )}/configurations` ), - setData(data), + setData(data, CreateConfigurationSchema), setMethod('POST') ); diff --git a/packages/api-v4/src/aglb/types.ts b/packages/api-v4/src/aglb/types.ts index 525678e92f0..7dcae7df052 100644 --- a/packages/api-v4/src/aglb/types.ts +++ b/packages/api-v4/src/aglb/types.ts @@ -31,7 +31,7 @@ export interface UpdateLoadbalancerPayload { configuration_ids?: number[]; } -type Protocol = 'tcp' | 'http' | 'https'; +export type Protocol = 'tcp' | 'http' | 'https'; type RouteProtocol = 'tcp' | 'http'; @@ -46,6 +46,7 @@ export type MatchField = 'path_prefix' | 'query' | 'host' | 'header' | 'method'; export interface RoutePayload { label: string; + protocol: Protocol; rules: RuleCreatePayload[]; } @@ -116,7 +117,7 @@ export type UpdateConfigurationPayload = Partial<{ port: number; protocol: Protocol; certificates: CertificateConfig[]; - routes: number[]; + route_ids: number[]; }>; export interface CertificateConfig { @@ -130,7 +131,7 @@ export interface RuleCreatePayload { } export interface MatchCondition { - hostname: string; + hostname: string | null; match_field: MatchField; match_value: string; session_stickiness_cookie: string | null; @@ -144,8 +145,10 @@ export interface RouteServiceTargetPayload { export interface ServiceTargetPayload { label: string; + protocol: Protocol; + percentage: number; endpoints: Endpoint[]; - ca_certificate: string; + certificate_id: number | null; load_balancing_policy: Policy; healthcheck: HealthCheck; } @@ -156,8 +159,8 @@ interface HealthCheck { timeout: number; unhealthy_threshold: number; healthy_threshold: number; - path: string; - host: string; + path?: string | null; + host?: string | null; } export interface ServiceTarget extends ServiceTargetPayload { @@ -166,7 +169,7 @@ export interface ServiceTarget extends ServiceTargetPayload { export interface Endpoint { ip: string; - host?: string; + host?: string | null; port: number; rate_capacity: number; } @@ -176,7 +179,7 @@ type CertificateType = 'ca' | 'downstream'; export interface Certificate { id: number; label: string; - certificate: string; + certificate?: string; // Not returned for Alpha type: CertificateType; } diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index e54f3718d3e..5d5148b6f3b 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -2,7 +2,7 @@ export type FirewallStatus = 'enabled' | 'disabled' | 'deleted'; export type FirewallRuleProtocol = 'ALL' | 'TCP' | 'UDP' | 'ICMP' | 'IPENCAP'; -export type FirewallDeviceEntityType = 'linode'; +export type FirewallDeviceEntityType = 'linode' | 'nodebalancer'; export type FirewallPolicyType = 'ACCEPT' | 'DROP'; diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index 1173a16ad1f..42401c3fb4c 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -17,6 +17,7 @@ import { NodeBalancerStats, } from './types'; import { combineNodeBalancerConfigNodeAddressAndPort } from './utils'; +import type { Firewall } from '../firewalls/types'; /** * getNodeBalancers @@ -107,3 +108,25 @@ export const getNodeBalancerStats = (nodeBalancerId: number) => { setMethod('GET') ); }; + +/** + * getNodeBalancerFirewalls + * + * View Firewall information for Firewalls associated with this NodeBalancer + */ + +export const getNodeBalancerFirewalls = ( + nodeBalancerId: number, + params?: Params, + filter?: Filter +) => + Request>( + setURL( + `${API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/firewalls` + ), + setMethod('GET'), + setXFilter(filter), + setParams(params) + ); diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index c3d9aab28d9..f8e93154b0b 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -106,9 +106,7 @@ export interface CreateNodeBalancerConfigNode { weight?: number; } -export type UpdateNodeBalancerConfigNode = Partial< - CreateNodeBalancerConfigNode ->; +export type UpdateNodeBalancerConfigNode = Partial; export interface NodeBalancerConfigNode { address: string; @@ -130,4 +128,6 @@ export interface CreateNodeBalancerPayload { label?: string; client_conn_throttle?: number; configs: any; + firewall_id?: number; + tags?: string[]; } diff --git a/packages/manager/src/components/EnhancedSelect/variants/RegionSelect/utils.ts b/packages/api-v4/src/regions/constants.ts similarity index 91% rename from packages/manager/src/components/EnhancedSelect/variants/RegionSelect/utils.ts rename to packages/api-v4/src/regions/constants.ts index 164bd55ff43..42a13dcf0fc 100644 --- a/packages/manager/src/components/EnhancedSelect/variants/RegionSelect/utils.ts +++ b/packages/api-v4/src/regions/constants.ts @@ -1,4 +1,12 @@ -// Thank you https://github.com/BRIXTOL/country-continent +export const CONTINENT_CODE_TO_CONTINENT = Object.freeze({ + AF: 'Africa', + AN: 'Antartica', + AS: 'Asia', + EU: 'Europe', + NA: 'North America', + OC: 'Oceania', + SA: 'South America', +}); export const COUNTRY_CODE_TO_CONTINENT_CODE = Object.freeze({ AD: 'EU', @@ -232,7 +240,7 @@ export const COUNTRY_CODE_TO_CONTINENT_CODE = Object.freeze({ TZ: 'AF', UA: 'EU', UG: 'AF', - UK: 'EU', // for compatability with the Linode API + UK: 'EU', // for compatibility with the Linode API UM: 'OC', US: 'NA', UY: 'SA', @@ -253,17 +261,3 @@ export const COUNTRY_CODE_TO_CONTINENT_CODE = Object.freeze({ ZM: 'AF', ZW: 'AF', }); - -export type Country = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE; - -export const CONTINENT_CODE_TO_CONTINENT = Object.freeze({ - AF: 'Africa', - AN: 'Antartica', - AS: 'Asia', - EU: 'Europe', - NA: 'North America', - OC: 'Oceania', - SA: 'South America', -}); - -export type ContinentNames = typeof CONTINENT_CODE_TO_CONTINENT[keyof typeof CONTINENT_CODE_TO_CONTINENT]; diff --git a/packages/api-v4/src/regions/index.ts b/packages/api-v4/src/regions/index.ts index a9e6d211f7c..1135cdf68be 100644 --- a/packages/api-v4/src/regions/index.ts +++ b/packages/api-v4/src/regions/index.ts @@ -1,3 +1,5 @@ +export * from './constants'; + export * from './regions'; export * from './types'; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index b5893bce3a0..f03c852531c 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -1,3 +1,5 @@ +import { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; + export type Capabilities = | 'Bare Metal' | 'Block Storage' @@ -23,7 +25,7 @@ export type RegionStatus = 'ok' | 'outage'; export interface Region { id: string; label: string; - country: string; + country: Country; capabilities: Capabilities[]; status: RegionStatus; resolvers: DNSResolvers; @@ -34,3 +36,7 @@ export interface RegionAvailability { plan: string; region: string; } + +type ContinentCode = keyof typeof COUNTRY_CODE_TO_CONTINENT_CODE; + +export type Country = Lowercase; diff --git a/packages/api-v4/src/request.test.ts b/packages/api-v4/src/request.test.ts index 7f19e224ed1..97ffeb98d7a 100644 --- a/packages/api-v4/src/request.test.ts +++ b/packages/api-v4/src/request.test.ts @@ -18,7 +18,7 @@ mock.onAny().reply(200, { data: {} }); beforeEach(() => { mock.resetHistory(); - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('Linode JS SDK', () => { @@ -124,7 +124,7 @@ describe('Linode JS SDK', () => { name: string().required('This is required!'), }); - const spy = jest.spyOn(testSchema, 'validateSync'); + const spy = vi.spyOn(testSchema, 'validateSync'); it('should validate the schema before submitting a request', async () => { await request(setData({ name: 'valid-name' }, testSchema)); diff --git a/packages/api-v4/src/request.ts b/packages/api-v4/src/request.ts index 0413913a3f2..926e6a5bd57 100644 --- a/packages/api-v4/src/request.ts +++ b/packages/api-v4/src/request.ts @@ -1,4 +1,9 @@ -import Axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; +import Axios, { + AxiosError, + AxiosHeaders, + AxiosRequestConfig, + AxiosResponse, +} from 'axios'; import { ValidationError, AnySchema } from 'yup'; import { APIError, Filter, Params } from './types'; @@ -14,14 +19,16 @@ export const baseRequest = Axios.create({ baseRequest.interceptors.request.use((config) => { const isRunningInNode = typeof process === 'object'; - const newConfig = { - ...config, - headers: { - ...config.headers, - 'User-Agent': 'linodejs', - }, - }; - return isRunningInNode ? newConfig : config; + + if (!isRunningInNode) { + return config; + } + + const headers = new AxiosHeaders(config.headers); + + headers.set('User-Agent', 'linodejs'); + + return { ...config, headers }; }); /** @@ -34,13 +41,11 @@ baseRequest.interceptors.request.use((config) => { */ export const setToken = (token: string) => { return baseRequest.interceptors.request.use((config) => { - return { - ...config, - headers: { - ...config.headers, - Authorization: `Bearer ${token}`, - }, - }; + const headers = new AxiosHeaders(config.headers); + + headers.set('Authorization', `Bearer ${token}`); + + return { ...config, headers }; }); }; @@ -205,7 +210,9 @@ export const mockAPIError = ( status, statusText, headers: {}, - config: {}, + config: { + headers: new AxiosHeaders(), + }, }) ), process.env.NODE_ENV === 'test' ? 0 : 250 @@ -258,13 +265,11 @@ export const CancellableRequest = ( */ export const setUserAgentPrefix = (prefix: string) => { return baseRequest.interceptors.request.use((config) => { - return { - ...config, - headers: { - ...config.headers, - 'User-Agent': `${prefix}/${config.headers['User-Agent']}`, - }, - }; + const headers = new AxiosHeaders(config.headers); + + headers.set('User-Agent', `${prefix}/${config.headers['User-Agent']}`); + + return { ...config, headers }; }); }; diff --git a/packages/api-v4/src/types.ts b/packages/api-v4/src/types.ts index 96403180dd8..e25f09fd38f 100644 --- a/packages/api-v4/src/types.ts +++ b/packages/api-v4/src/types.ts @@ -31,6 +31,12 @@ export interface Params { page_size?: number; } +export interface RequestOptions { + params?: Params; + filter?: Filter; + headers?: RequestHeaders; +} + interface FilterConditionTypes { '+and'?: Filter[]; '+or'?: Filter[] | string[]; @@ -88,3 +94,24 @@ type LinodeFilter = // }, // ], // }; + +type RequestHeaderValue = string | string[] | number | boolean | null; + +type RequestContentType = + | RequestHeaderValue + | 'application/json' + | 'application/octet-stream' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data' + | 'text/html' + | 'text/plain'; + +export interface RequestHeaders { + [key: string]: RequestHeaderValue | undefined; + Accept?: string; + Authorization?: string; + 'Content-Encoding'?: string; + 'Content-Length'?: number; + 'User-Agent'?: string; + 'Content-Type'?: RequestContentType; +} diff --git a/packages/api-v4/tsconfig.json b/packages/api-v4/tsconfig.json index 150f3bcf516..099ebaf3a2e 100644 --- a/packages/api-v4/tsconfig.json +++ b/packages/api-v4/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "esnext", + "types": ["vitest/globals"], "module": "umd", "emitDeclarationOnly": true, "declaration": true, @@ -21,4 +22,4 @@ "node_modules/**/*", "**/__tests__/*" ] -} \ No newline at end of file +} diff --git a/packages/api-v4/vitest.config.ts b/packages/api-v4/vitest.config.ts new file mode 100644 index 00000000000..7382f40e7d2 --- /dev/null +++ b/packages/api-v4/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index e1f0e5e33fb..5e83954a674 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -16,6 +16,8 @@ import { themes } from '@storybook/theming'; import { worker } from '../src/mocks/testBrowser'; import '../src/index.css'; +// TODO: M3-6705 Remove this when replacing @reach/tabs with MUI Tabs +import '@reach/tabs/styles.css'; MINIMAL_VIEWPORTS.mobile1.styles = { height: '667px', diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 88f79fb213b..d207b1bf4ab 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,96 @@ 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/). +## [2023-12-11] - v1.108.0 + +### Added: + +- Ensure EU consent box shows for new European countries ([#9901](https://github.com/linode/manager/pull/9901)) +- NodeBalancer support for Firewalls ([#9760](https://github.com/linode/manager/pull/9760)) +- Implement DC Get Well disabled regions in RegionSelect ([#9907](https://github.com/linode/manager/pull/9907), [#9909](https://github.com/linode/manager/pull/9909), [#9943](https://github.com/linode/manager/pull/9943), [#9945](https://github.com/linode/manager/pull/9945), [#9953](https://github.com/linode/manager/pull/9953)) + +### Changed: + +- Improve MainConcept Transcoders Marketplace app name, description, and website ([#9858](https://github.com/linode/manager/pull/9858)) +- Move Linode Details Add/Edit Config button alignment to the right ([#9925](https://github.com/linode/manager/pull/9925)) +- Add pricing Docs Link to create/clone flows and remove DC-specific pricing warning notice ([#9946](https://github.com/linode/manager/pull/9946)) +- Update MainConcept app names to include “Demo” ([#9950](https://github.com/linode/manager/pull/9950)) + + +### Fixed: + +- Overflow for VPC and StackScript detail descriptions and cut off placeholder text in VPC search bar ([#9887](https://github.com/linode/manager/pull/9887)) +- Missing region ID param in Linode Detail clone action menu item ([#9888](https://github.com/linode/manager/pull/9888)) +- Linode Network Transfer History graph back button incorrectly appearing to be disabled ([#9900](https://github.com/linode/manager/pull/9900)) +- 'Oh snap' errors from MSW ([#9910](https://github.com/linode/manager/pull/9910)) +- `TableCell` with `ActionMenu` incorrect size and border placement ([#9915](https://github.com/linode/manager/pull/9915)) +- Images landing page guide section title typo ([#9930](https://github.com/linode/manager/pull/9930)) +- `TableCell` styling for `ActionMenu`s ([#9954](https://github.com/linode/manager/pull/9954)) + +### Removed: + +- `dcSpecificPricing` and `objDcSpecificPricing` feature flags ([#9881](https://github.com/linode/manager/pull/9881)) + +### Tech Stories: + +- Refactor `RegionSelect` to use `Autocomplete` ([#9873](https://github.com/linode/manager/pull/9873)) +- Clean up App.tsx ([#9897](https://github.com/linode/manager/pull/9897)) +- Update `axios` to `1.6.1` ([#9911](https://github.com/linode/manager/pull/9911)) +- Remove unused container files ([#9928](https://github.com/linode/manager/pull/9928)) +- MUI v5 Migration - `SRC > Components > Breadcrumb` ([#9877](https://github.com/linode/manager/pull/9877)) +- MUI v5 Migration - `SRC > Features > Support` ([#9882](https://github.com/linode/manager/pull/9882)) +- MUI v5 Migration - `SRC > Components > EditableInput` ([#9884](https://github.com/linode/manager/pull/9884)) +- NodeBalancer Config Node - Remove one-off-styled Chip ([#9883](https://github.com/linode/manager/pull/9883)) +- Toggle v7 story migration ([#9905](https://github.com/linode/manager/pull/9905)) +- EnhancedSelect and RegionSelect stories cleanup ([#9906](https://github.com/linode/manager/pull/9906)) +- Dismissible Banner Storybook v7 story migration ([#9932](https://github.com/linode/manager/pull/9932)) +- Tabs Storybook v7 story migration ([#9937](https://github.com/linode/manager/pull/9937)) +- Tile and ShowMoreExpansion Storybook v7 story migration ([#9913](https://github.com/linode/manager/pull/9913)) +- ActionMenu Storybook v7 story migration ([#9927](https://github.com/linode/manager/pull/9927)) +- TopMenu and TagsList Storybook v7 story migration ([#9948](https://github.com/linode/manager/pull/9948)) +- SideMenu & UserMenu Storybook v7 story migration ([#9956](https://github.com/linode/manager/pull/9956)) +- Payment Method Row Storybook v7 story migration ([#9958](https://github.com/linode/manager/pull/9958)) +- Use `LinodeSelect` and `NodeBalancerSelect` components for Firewall create drawer ([#9886](https://github.com/linode/manager/pull/9886)) + +### Tests: + +- Remove `dcSpecificPricing` and `objDcSpecificPricing` feature flags ([#9881](https://github.com/linode/manager/pull/9881)) +- Update tests for DC-specific pricing docs link ([#9946](https://github.com/linode/manager/pull/9946)) +- Upgrade Cypress from 13.4.0 to 13.5.0 ([#9892](https://github.com/linode/manager/pull/9892)) +- Improve stability for Longview, Rebuild, and Rescue tests ([#9902](https://github.com/linode/manager/pull/9902)) +- Code coverage implementation ([#9917](https://github.com/linode/manager/pull/9917)) +- Add unit tests and additional integration test for VPC delete dialog ([#9920](https://github.com/linode/manager/pull/9920)) +- Add AGLB Configuration create and delete e2e tests ([#9924](https://github.com/linode/manager/pull/9924)) +- Add maintenance mode integration test ([#9934](https://github.com/linode/manager/pull/9934)) +- Combine billing cypress tests ([#9940](https://github.com/linode/manager/pull/9940)) +- Add account cancellation UI tests ([#9952](https://github.com/linode/manager/pull/9952)) +- Fix warm resize test by updating notice text ([#9972](https://github.com/linode/manager/pull/9972)) +- Add integration tests for VPC assign/unassign flows ([#9818](https://github.com/linode/manager/pull/9818)) + +### Upcoming Features: + +- Add AGLB create flow Stepper details content ([#9849](https://github.com/linode/manager/pull/9849)) +- Add AGLB Configuration Create Flow ([#9870](https://github.com/linode/manager/pull/9870)) +- Add AGLB Feedback Link ([#9885](https://github.com/linode/manager/pull/9885)) +- Add AGLB copy and docs links ([#9908](https://github.com/linode/manager/pull/9908)) +- Add AGLB Service Target `protocol` field in Create/Edit Service Target drawer and "Protocol" column to Service Targets table ([#9891](https://github.com/linode/manager/pull/9891)) +- Add AGLB Configuration e2e tests, improve error handling, and improve UX ([#9941](https://github.com/linode/manager/pull/9941)) +- Add AGLB copy changes and improvements ([#9954](https://github.com/linode/manager/pull/9954)) +- Fix AGLB Configuration creation by fixing port type and other refinement ([#9903](https://github.com/linode/manager/pull/9903)) +- Add `parentChildAccountAccess` feature flag ([#9919](https://github.com/linode/manager/pull/9919)) +- Update existing user account and grant factories and mocks for Parent/Child account switching ([#9942](https://github.com/linode/manager/pull/9942)) +- Add new grants and React Query queries for Parent/Child account switching ([#9944](https://github.com/linode/manager/pull/9944)) +- Add `Reboot Needed` status for Linodes assigned to VPCs ([#9893](https://github.com/linode/manager/pull/9893)) +- Indicate unrecommended Linode configurations on VPC Detail page ([#9914](https://github.com/linode/manager/pull/9914)) +- Tweak VPC landing page empty state copy and add resource links ([#9951](https://github.com/linode/manager/pull/9951)) +- Display warning notices for unrecommended configurations in Linode Add/Edit Config dialog ([#9916](https://github.com/linode/manager/pull/9916)) +- Disable Public IP Address for VPC-only Linodes in the Linode's details page ([#9899](https://github.com/linode/manager/pull/9899)) +- Update copy on VPC Create page ([#9962](https://github.com/linode/manager/pull/9962)) +- Update VPC-related copy for Reboot Needed tooltip ([#9966](https://github.com/linode/manager/pull/9966)) +- Update copy for VPC Panel in Linode Create flow and VPC-related copy in Linode Add/Edit Config dialog ([#9968](https://github.com/linode/manager/pull/9968)) +- Create feature flag to support OBJ Multi Cluster UI changes ([#9970](https://github.com/linode/manager/pull/9970)) +- Replace Linode Network Transfer History chart with Recharts ([#9938](https://github.com/linode/manager/pull/9938)) + ## [2023-11-13] - v1.107.0 diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index b50a3f031fb..cd3e70e8bc1 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -15,18 +15,36 @@ RUN apt-get update \ CMD yarn start:manager:ci +# e2e-build +# +# Builds an image containing Cypress and miscellaneous system utilities required +# by the tests. +FROM cypress/included:13.5.0 as e2e-build +RUN apt-get update \ + && apt-get install -y expect openssh-client \ + && rm -rf /var/cache/apt/* \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# e2e-install +# +# Installs Cypress and sets up cache directories. +FROM e2e-build as e2e-install +USER node +WORKDIR /home/node/app +VOLUME /home/node/app +ENV CYPRESS_CACHE_FOLDER=/home/node/.cache/Cypress +RUN mkdir -p /home/node/.cache/Cypress && cypress install + # `e2e` # # Runs Cloud Manager Cypress tests. -FROM cypress/included:12.17.3 as e2e +FROM e2e-install as e2e WORKDIR /home/node/app VOLUME /home/node/app USER node -RUN mkdir -p /home/node/.cache/Cypress ENV CI=1 ENV NO_COLOR=1 ENV HOME=/home/node/ ENV CYPRESS_CACHE_FOLDER=/home/node/.cache/Cypress ENTRYPOINT yarn cy:ci - -# TODO Add `e2e-m1`. diff --git a/packages/manager/config/jest/cssTransform.js b/packages/manager/config/jest/cssTransform.js deleted file mode 100644 index f1534f6fb59..00000000000 --- a/packages/manager/config/jest/cssTransform.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; - -// This is a custom Jest transformer turning style imports into empty objects. -// http://facebook.github.io/jest/docs/tutorial-webpack.html - -module.exports = { - process() { - return 'module.exports = {};'; - }, - getCacheKey() { - // The output is always the same. - return 'cssTransform'; - }, -}; diff --git a/packages/manager/config/jest/fileTransform.js b/packages/manager/config/jest/fileTransform.js deleted file mode 100644 index ffce0da29a9..00000000000 --- a/packages/manager/config/jest/fileTransform.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -const path = require('path'); - -// This is a custom Jest transformer turning file imports into filenames. -// http://facebook.github.io/jest/docs/tutorial-webpack.html - -module.exports = { - process(src, filename) { - return `module.exports = ${JSON.stringify(path.basename(filename))};`; - }, -}; diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index b5d85c93d6c..ff201ae504e 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -22,7 +22,6 @@ import { enableJunitReport } from './cypress/support/plugins/junit-report'; */ export default defineConfig({ trashAssetsBeforeRuns: false, - projectId: '5rhsif', // Browser configuration. chromeWebSecurity: false, @@ -35,6 +34,11 @@ export default defineConfig({ defaultCommandTimeout: 80000, pageLoadTimeout: 60000, + // Recording and test troubleshooting. + projectId: '5rhsif', + screenshotOnRunFailure: true, + video: true, + // Only retry test when running via CI. retries: process.env['CI'] ? 2 : 0, diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts new file mode 100644 index 00000000000..b9ea9698ebb --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -0,0 +1,200 @@ +/** + * @file Integration tests for Cloud Manager account cancellation flows. + */ + +import { profileFactory } from 'src/factories/profile'; +import { accountFactory } from 'src/factories/account'; +import { + mockGetAccount, + mockCancelAccount, + mockCancelAccountError, +} from 'support/intercepts/account'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { + randomDomainName, + randomPhrase, + randomString, +} from 'support/util/random'; +import type { CancelAccount } from '@linode/api-v4'; +import { mockWebpageUrl } from 'support/intercepts/general'; + +// Data loss warning which is displayed in the account cancellation dialog. +const cancellationDataLossWarning = + 'Please note this is an extremely destructive action. Closing your account \ +means that all services Linodes, Volumes, DNS Records, etc will be lost and \ +may not be able be restored.'; + +// Error message that appears when a payment failure occurs upon cancellation attempt. +const cancellationPaymentErrorMessage = + 'We were unable to charge your credit card for services rendered. \ +We cannot cancel this account until the balance has been paid.'; + +describe('Account cancellation', () => { + /* + * - Confirms that a user can cancel their account from the Account Settings page. + * - Confirms that user is warned that account cancellation is destructive. + * - Confirms that Cloud Manager displays a notice when an error occurs during cancellation. + * - Confirms that Cloud Manager includes user comments in cancellation request payload. + * - Confirms that Cloud Manager shows a survey CTA which directs the user to the expected URL. + */ + it('users can cancel account', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + username: 'mock-user', + restricted: false, + }); + const mockCancellationResponse: CancelAccount = { + survey_link: `https://${randomDomainName()}/${randomString(5)}`, + }; + + const cancellationComments = randomPhrase(); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockCancelAccountError(cancellationPaymentErrorMessage, 409).as( + 'cancelAccount' + ); + mockWebpageUrl( + mockCancellationResponse.survey_link, + 'This is a mock webpage to confirm Cloud Manager survey link behavior' + ).as('getSurveyPage'); + + // Navigate to Account Settings page, click "Close Account" button. + cy.visitWithLogin('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + ui.accordion + .findByTitle('Close Account') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.dialog + .findByTitle('Are you sure you want to close your Linode account?') + .should('be.visible') + .within(() => { + cy.findByText(cancellationDataLossWarning, { exact: false }).should( + 'be.visible' + ); + + // Confirm that submit button is disabled before entering required info. + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.disabled'); + + // Enter username, confirm that submit button becomes enabled, and click + // the submit button. + cy.findByLabelText( + `Please enter your Username (${mockProfile.username}) to confirm.` + ) + .should('be.visible') + .should('be.enabled') + .type(mockProfile.username); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that request payload contains expected data and API error + // message is displayed in the dialog. + cy.wait('@cancelAccount').then((intercept) => { + expect(intercept.request.body['comments']).to.equal(''); + }); + + cy.findByText(cancellationPaymentErrorMessage).should('be.visible'); + + // Enter account cancellation comments, click "Close Account" again, + // and this time mock a successful account cancellation response. + mockCancelAccount(mockCancellationResponse).as('cancelAccount'); + cy.contains('Comments (optional)').click().type(cancellationComments); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@cancelAccount').then((intercept) => { + expect(intercept.request.body['comments']).to.equal( + cancellationComments + ); + }); + }); + + // Confirm that Cloud presents account cancellation screen and prompts the + // user to complete the exit survey. Confirm that clicking survey button + // directs the user to the expected URL. + cy.findByText('It’s been our pleasure to serve you.').should('be.visible'); + ui.button + .findByTitle('Take our exit survey') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@getSurveyPage'); + cy.url().should('equal', mockCancellationResponse.survey_link); + }); + + /* + * - Confirms Cloud Manager behavior when a restricted user attempts to close an account. + * - Confirms that API error response message is displayed in cancellation dialog. + */ + it('restricted users cannot cancel account', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + username: 'mock-restricted-user', + restricted: true, + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockCancelAccountError('Unauthorized', 403).as('cancelAccount'); + + // Navigate to Account Settings page, click "Close Account" button. + cy.visitWithLogin('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + ui.accordion + .findByTitle('Close Account') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Fill out cancellation dialog and attempt submission. + ui.dialog + .findByTitle('Are you sure you want to close your Linode account?') + .should('be.visible') + .within(() => { + cy.findByLabelText( + `Please enter your Username (${mockProfile.username}) to confirm.` + ) + .should('be.visible') + .should('be.enabled') + .type(mockProfile.username); + + ui.button + .findByTitle('Close Account') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that API unauthorized error message is displayed. + cy.wait('@cancelAccount'); + cy.findByText('Unauthorized').should('be.visible'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index 17f6d458c3b..bccf005463a 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -61,20 +61,18 @@ const checkAccountContactDisplay = (data) => { }; describe('Billing Contact', () => { - it('Check Billing Contact Form', () => { - // intercept get account request and stub response. + it('Edit Contact Info', () => { + // mock the user's account data and confirm that it is displayed correctly upon page load mockGetAccount(accountData).as('getAccount'); cy.visitWithLogin('/account/billing'); checkAccountContactDisplay(accountData); - }); - it('Edit Contact Info', () => { - // intercept create account request and stub response + + // edit the billing contact information mockUpdateAccount(newAccountData).as('updateAccount'); - cy.visitWithLogin('/account/billing'); cy.get('[data-qa-contact-summary]').within((_contact) => { cy.findByText('Edit').should('be.visible').click(); }); - // checking drawer is visible + // check drawer is visible cy.findByLabelText('First Name') .should('be.visible') .click() @@ -137,5 +135,10 @@ describe('Billing Contact', () => { expect(xhr.response?.body).to.eql(newAccountData); }); }); + + // check the page updates to reflect the edits + cy.get('[data-qa-contact-summary]').within(() => { + checkAccountContactDisplay(newAccountData); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index e84d8746146..79d3fe5a475 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -10,14 +10,9 @@ import { mockGetInvoice, mockGetInvoiceItems, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { formatUsd } from 'support/util/currency'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomItem, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; @@ -41,10 +36,13 @@ describe('Account invoices', () => { * - Confirms that subtotals and tax breakdowns are shown in summary. * - Confirms that download buttons are present and enabled. * - Confirms that clicking the "Back to Billing" button redirects to billing page. - * - Confirms that the "Region" column is not present when DC-specific pricing is disabled. + * - Confirms that the "Region" column is present after the MAGIC_DATE_THAT_DC_PRICING_WAS_IMPLEMENTED. + * - Confirms that invoice items that do not have a region are displayed as expected. + * - Confirms that outbound transfer overage items display the associated region when applicable. + * - Confirms that outbound transfer overage items display "Global" when no region is applicable. */ it('lists invoice items on invoice details page', () => { - const mockInvoiceItems = buildArray(20, (i) => { + const mockInvoiceItemsWithRegions = buildArray(20, (i) => { const subtotal = randomNumber(101, 999); const tax = randomNumber(1, 100); const hours = randomNumber(1, 24); @@ -72,12 +70,38 @@ describe('Account invoices', () => { }); }); + // Regular (non-overage) invoice items. + const mockInvoiceItemsWithAndWithoutRegions = [ + ...mockInvoiceItemsWithRegions, + invoiceItemFactory.build({ + amount: 5, + total: 6, + region: null, + tax: 1, + }), + ]; + + // Outbound transfer overage items. + const mockInvoiceItemsOverages = [ + invoiceItemFactory.build({ + label: 'Outbound Transfer Overage', + region: null, + }), + invoiceItemFactory.build({ + label: 'Outbound Transfer Overage', + region: chooseRegion().id, + }), + ]; + // Calculate the sum of each item's tax and subtotal. - const sumTax = mockInvoiceItems.reduce((acc: number, cur: InvoiceItem) => { - return acc + cur.tax; - }, 0); + const sumTax = mockInvoiceItemsWithAndWithoutRegions.reduce( + (acc: number, cur: InvoiceItem) => { + return acc + cur.tax; + }, + 0 + ); - const sumSubtotal = mockInvoiceItems.reduce( + const sumSubtotal = mockInvoiceItemsWithAndWithoutRegions.reduce( (acc: number, cur: InvoiceItem) => { return acc + cur.amount; }, @@ -101,43 +125,86 @@ describe('Account invoices', () => { tax: Math.ceil(sumTax / 2), }, ], + date: MAGIC_DATE_THAT_DC_SPECIFIC_PRICING_WAS_IMPLEMENTED, }); - // TODO: DC Pricing - M3-7073: Remove feature flag mocks when DC specific pricing goes live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); + // All mocked invoice items. + const mockInvoiceItems = [ + ...mockInvoiceItemsWithAndWithoutRegions, + ...mockInvoiceItemsOverages, + ]; + mockGetInvoice(mockInvoice).as('getInvoice'); mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); - cy.wait([ - '@getFeatureFlags', - '@getClientstream', - '@getInvoice', - '@getInvoiceItems', - ]); - - // Confirm that "Region" table column is not present. + cy.wait(['@getInvoice', '@getInvoiceItems']); + + // Confirm that "Region" table column is not present; old invoices will not be backfilled and we don't want to display a blank column. cy.findByLabelText('Invoice Details').within(() => { - cy.get('thead').findByText('Region').should('not.exist'); - }); + // Confirm that 'Region' table column is present. + cy.get('thead').findByText('Region').should('be.visible'); - // Confirm that each invoice item is listed on the page with expected data. - mockInvoiceItems.forEach((invoiceItem: InvoiceItem) => { - cy.findByText(invoiceItem.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText(`${invoiceItem.quantity}`).should('be.visible'); - cy.findByText(`$${invoiceItem.unit_price}`).should('be.visible'); - cy.findByText(`${formatUsd(invoiceItem.amount)}`).should( - 'be.visible' - ); - cy.findByText(`${formatUsd(invoiceItem.tax)}`).should('be.visible'); - cy.findByText(`${formatUsd(invoiceItem.total)}`).should('be.visible'); - }); + // Confirm that each regular invoice item is shown, and that the region is + // displayed as expected. + mockInvoiceItemsWithAndWithoutRegions.forEach( + (invoiceItem: InvoiceItem) => { + cy.findByText(invoiceItem.label) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(`${invoiceItem.quantity}`).should('be.visible'); + cy.findByText(`$${invoiceItem.unit_price}`).should('be.visible'); + cy.findByText(`${formatUsd(invoiceItem.amount)}`).should( + 'be.visible' + ); + cy.findByText(`${formatUsd(invoiceItem.tax)}`).should( + 'be.visible' + ); + cy.findByText(`${formatUsd(invoiceItem.total)}`).should( + 'be.visible' + ); + // If the invoice item has a region, confirm that it is displayed + // in the table row. Otherwise, confirm that the table cell which + // would normally show the region is empty. + !!invoiceItem.region + ? cy + .findByText(getRegionLabel(invoiceItem.region)) + .should('be.visible') + : cy + .get('[data-qa-region]') + .should('be.visible') + .should('be.empty'); + }); + } + ); + + // Confirm that outbound transfer overages are listed as expected. + mockInvoiceItemsOverages.forEach( + (invoiceItem: InvoiceItem, i: number) => { + // There will be multiple instances of the label "Outbound Transfer Overage". + // Select them all, then select the individual item that corresponds to the + // item being iterated upon in the array. + // + // This relies on the items being shown in the same order on-screen as + // they are defined in the array. This may be fragile to breakage if + // we ever change the way invoice items are sorted on this page. + cy.findAllByText(invoiceItem.label) + .should('have.length', 2) + .eq(i) + .closest('tr') + .within(() => { + // If the invoice item has a region, confirm that it is displayed + // in the table row. Otherwise, confirm that "Global" is displayed + // in the region column. + !!invoiceItem.region + ? cy + .findByText(getRegionLabel(invoiceItem.region)) + .should('be.visible') + : cy.findByText('Global').should('be.visible'); + }); + } + ); }); // Confirm that invoice header contains invoice label, total, and download buttons. @@ -181,120 +248,7 @@ describe('Account invoices', () => { cy.url().should('endWith', '/account/billing'); }); - /* - * - Confirms that invoice item region info is shown when DC-specific pricing is enabled. - * - Confirms that table "Region" column is shown when DC-specific pricing is enabled on new invoices. - * - Confirms that invoice items that do not have a region are displayed as expected. - * - Confirms that outbound transfer overage items display the associated region when applicable. - * - Confirms that outbound transfer overage items display "Global" when no region is applicable. - */ - it('lists invoice item region when DC-specific pricing flag is enabled', () => { - // TODO: DC Pricing - M3-7073: Delete most of this test when DC-specific pricing launches and move assertions to above test. Use this test for the region invoice column. - // We don't have to be fancy with the mocks here since we are only concerned with the region and invoice date. - const mockInvoice = invoiceFactory.build({ - id: randomNumber(), - date: MAGIC_DATE_THAT_DC_SPECIFIC_PRICING_WAS_IMPLEMENTED, - }); - - // Regular invoice items. - const mockInvoiceItemsRegular = [ - ...buildArray(10, () => - invoiceItemFactory.build({ region: chooseRegion().id }) - ), - invoiceItemFactory.build({ - region: null, - }), - ]; - - // Outbound transfer overage items. - const mockInvoiceItemsOverages = [ - invoiceItemFactory.build({ - label: 'Outbound Transfer Overage', - region: null, - }), - invoiceItemFactory.build({ - label: 'Outbound Transfer Overage', - region: chooseRegion().id, - }), - ]; - - // All mocked invoice items. - const mockInvoiceItems = [ - ...mockInvoiceItemsRegular, - ...mockInvoiceItemsOverages, - ]; - - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetInvoice(mockInvoice).as('getInvoice'); - mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); - - // Visit invoice details page, wait for relevant requests to resolve. - cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); - cy.wait([ - '@getFeatureFlags', - '@getClientstream', - '@getInvoice', - '@getInvoiceItems', - ]); - - cy.findByLabelText('Invoice Details').within(() => { - // Confirm that 'Region' table column is present. - cy.get('thead').findByText('Region').should('be.visible'); - - // Confirm that each regular invoice item is shown, and that the region is - // displayed as expected. - mockInvoiceItemsRegular.forEach((invoiceItem: InvoiceItem) => { - cy.findByText(invoiceItem.label) - .should('be.visible') - .closest('tr') - .within(() => { - // If the invoice item has a region, confirm that it is displayed - // in the table row. Otherwise, confirm that the table cell which - // would normally show the region is empty. - !!invoiceItem.region - ? cy - .findByText(getRegionLabel(invoiceItem.region)) - .should('be.visible') - : cy - .get('[data-qa-region]') - .should('be.visible') - .should('be.empty'); - }); - - // Confirm that outbound transfer overages are listed as expected. - mockInvoiceItemsOverages.forEach( - (invoiceItem: InvoiceItem, i: number) => { - // There will be multiple instances of the label "Outbound Transfer Overage". - // Select them all, then select the individual item that corresponds to the - // item being iterated upon in the array. - // - // This relies on the items being shown in the same order on-screen as - // they are defined in the array. This may be fragile to breakage if - // we ever change the way invoice items are sorted on this page. - cy.findAllByText(invoiceItem.label) - .should('have.length', 2) - .eq(i) - .closest('tr') - .within(() => { - // If the invoice item has a region, confirm that it is displayed - // in the table row. Otherwise, confirm that "Global" is displayed - // in the region column. - !!invoiceItem.region - ? cy - .findByText(getRegionLabel(invoiceItem.region)) - .should('be.visible') - : cy.findByText('Global').should('be.visible'); - }); - } - ); - }); - }); - }); - - it('does not list the region on past invoices when DC-specific pricing flag is enabled', () => { + it('does not list the region on past invoices', () => { const mockInvoice = invoiceFactory.build({ id: randomNumber(), date: '2023-09-30 00:00:00Z', @@ -305,21 +259,12 @@ describe('Account invoices', () => { ...buildArray(10, () => invoiceItemFactory.build({ region: null })), ]; - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); mockGetInvoice(mockInvoice).as('getInvoice'); mockGetInvoiceItems(mockInvoice, mockInvoiceItems).as('getInvoiceItems'); // Visit invoice details page, wait for relevant requests to resolve. cy.visitWithLogin(`/account/billing/invoices/${mockInvoice.id}`); - cy.wait([ - '@getFeatureFlags', - '@getClientstream', - '@getInvoice', - '@getInvoiceItems', - ]); + cy.wait(['@getInvoice', '@getInvoiceItems']); cy.findByLabelText('Invoice Details').within(() => { // Confirm that "Region" table column is not present in an invoice created before DC-specific pricing was released. diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 4001ff3204b..6f989947027 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -95,10 +95,7 @@ describe('create a database cluster, mocked data', () => { .click() .type(`${configuration.engine} v${configuration.version}{enter}`); - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${configuration.region.label}{enter}`); + ui.regionSelect.find().click().type(`${databaseRegionLabel}{enter}`); // Click either the "Dedicated CPU" or "Shared CPU" tab, according // to the type of cluster being created. diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index d4d76a052a7..1f35a6b20e1 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -19,7 +19,6 @@ import { } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { selectRegionString } from 'support/ui/constants'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber } from 'support/util/random'; import type { Linode, Region } from '@linode/api-v4'; @@ -120,7 +119,7 @@ describe('Migrate Linode With Firewall', () => { // Select migration region. cy.findByText(`North America: Dallas, TX`).should('be.visible'); - cy.contains(selectRegionString).click(); + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionLabel('Singapore, SG').click(); ui.button @@ -216,7 +215,7 @@ describe('Migrate Linode With Firewall', () => { cy.findByText('Accept').should('be.visible').click(); // Select region for migration. - cy.findByText(selectRegionString).click(); + ui.regionSelect.find().click(); ui.regionSelect .findItemByRegionLabel(migrationRegionEnd.label) .click(); 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 a763eb5a833..0e8d2885fed 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -124,7 +124,10 @@ const removeFirewallRules = (ruleLabel: string) => { */ const addLinodesToFirewall = (firewall: Firewall, linode: Linode) => { // Go to Linodes tab - ui.tabList.findTabByTitle('Linodes').should('be.visible').click(); + ui.tabList + .findTabByTitle('Linodes', { exact: false }) + .should('be.visible') + .click(); ui.button.findByTitle('Add Linodes to Firewall').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/general/maintenance-mode.spec.ts b/packages/manager/cypress/e2e/core/general/maintenance-mode.spec.ts new file mode 100644 index 00000000000..71f5d0cebb5 --- /dev/null +++ b/packages/manager/cypress/e2e/core/general/maintenance-mode.spec.ts @@ -0,0 +1,19 @@ +/** + * @file Integration tests for Cloud Manager maintenance mode handling. + */ + +import { mockApiMaintenanceMode } from 'support/intercepts/general'; + +describe('API maintenance mode', () => { + /* + * - Confirms that maintenance mode screen is shown when API responds with maintenance mode header. + */ + it('shows maintenance screen when API is in maintenance mode', () => { + mockApiMaintenanceMode(); + cy.visitWithLogin('/'); + + // Confirm that maintenance message and link to status page are shown. + cy.findByText('We are undergoing maintenance.').should('be.visible'); + cy.contains('status.linode.com').should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 2025ac7ff66..7986d72c32f 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -4,12 +4,15 @@ import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { mockGetAllImages } from 'support/intercepts/images'; import { imageFactory, linodeFactory } from '@src/factories'; import { chooseRegion } from 'support/util/regions'; +import { cleanUp } from 'support/util/cleanup'; import { ui } from 'support/ui'; +import { authenticate } from 'support/api/authentication'; const region = chooseRegion(); const mockLinode = linodeFactory.build({ region: region.id, + id: 123456, }); const mockImage = imageFactory.build({ @@ -42,11 +45,8 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { }); } - getClick('[data-qa-enhanced-select="Select a Region"]').within(() => { - containsClick('Select a Region'); - }); - - ui.regionSelect.findItemByRegionId(region.id).should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionId(region.id).click(); fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"][type="radio"]'); @@ -55,6 +55,8 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); + console.log('mockLinode', mockLinode); + fbtVisible(mockLinode.label); fbtVisible(region.label); fbtVisible(mockLinode.id); diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index cb6584d21de..51846b2e2b2 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -5,7 +5,7 @@ import 'cypress-file-upload'; import { RecPartial } from 'factory.ts'; import { DateTime } from 'luxon'; import { authenticate } from 'support/api/authentication'; -import { fbtClick, fbtVisible, getClick } from 'support/helpers'; +import { fbtVisible, getClick } from 'support/helpers'; import { mockDeleteImage, mockGetCustomImages, @@ -120,8 +120,8 @@ const uploadImage = (label: string) => { cy.visitWithLogin('/images/create/upload'); getClick('[id="label"][data-testid="textfield-input"]').type(label); getClick('[id="description"]').type('This is a machine image upload test'); - fbtClick('Select a Region'); + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).click(); // Pass `null` to `cy.fixture()` to ensure file is encoded as a Cypress buffer object. diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index fc5fcfb1bce..8822380b9f4 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -11,18 +11,14 @@ import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomItem } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { dcPricingLkeCheckoutSummaryPlaceholder, dcPricingLkeHAPlaceholder, dcPricingLkeClusterPlans, dcPricingMockLinodeTypes, dcPricingPlanPlaceholder, - dcPricingRegionNotice, + dcPricingDocsLabel, + dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; @@ -91,11 +87,6 @@ describe('LKE Cluster Creation', () => { .fill(null) .map(() => randomItem(lkeClusterPlans)); - // TODO: DC Pricing - M3-7073: Remove feature flag mocks when DC specific pricing goes live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); interceptCreateCluster().as('createCluster'); cy.visitWithLogin('/kubernetes/clusters'); @@ -108,20 +99,13 @@ describe('LKE Cluster Creation', () => { cy.url().should('endWith', '/kubernetes/create'); - // Confirm that visibility of HA price does not depend on region selection. - // TODO: DC Pricing - M3-7073: Update when feature flag is removed. - cy.contains('$60.00/month').should('be.visible'); - // Fill out LKE creation form label, region, and Kubernetes version fields. cy.findByLabelText('Cluster Label') .should('be.visible') .click() .type(`${clusterLabel}{enter}`); - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${clusterRegion.label}{enter}`); + ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); cy.findByText('Kubernetes Version') .should('be.visible') @@ -221,18 +205,17 @@ describe('LKE Cluster Creation', () => { }); }); -// TODO: DC Pricing - M3-7073: Delete test and add commented pieces of it above. describe('LKE Cluster Creation with DC-specific pricing', () => { before(() => { cleanUp('lke-clusters'); }); /* - * - Confirms that DC-specific pricing notices and prices are present in the LKE create form when the feature flag is on. + * - Confirms that DC-specific prices are present in the LKE create form. + * - Confirms that pricing docs link is shown in "Region" section. * - Confirms that the plan table shows a message in place of plans when a region is not selected. * - Confirms that the cluster summary create button is disabled until a plan and region selection are made. * - Confirms that HA helper text updates dynamically to display pricing when a region is selected. - * - Confirms that the pricing warning notice is visible for a region with DC-specific pricing and not visible otherwise. */ it('can dynamically update prices when creating an LKE cluster based on region', () => { const clusterRegion = getRegionById('us-southeast'); @@ -243,12 +226,6 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .fill(null) .map(() => randomItem(dcPricingLkeClusterPlans)); - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - interceptCreateCluster().as('createCluster'); - cy.visitWithLogin('/kubernetes/clusters'); ui.button @@ -262,7 +239,6 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); cy.wait(['@getLinodeTypes']); - // TODO: DC Pricing - M3-7073: Add to test above. // Confirm that, without a region selected, no pricing information is displayed. // Confirm checkout summary displays helper text and disabled create button. @@ -277,26 +253,19 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { // Confirm that HA pricing displays helper text instead of price. cy.contains(dcPricingLkeHAPlaceholder).should('be.visible'); + // Confirm docs link to pricing page is visible. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + // Fill out LKE creation form label, region, and Kubernetes version fields. cy.findByLabelText('Cluster Label') .should('be.visible') .click() .type(`${clusterLabel}{enter}`); - // Confirm pricing warning notice is visible for a region with DC-specific pricing and not visible otherwise. - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${clusterRegion.label}{enter}`); - cy.findByText(dcPricingRegionNotice).should('not.exist'); - - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${dcSpecificPricingRegion.label}{enter}`); - cy.findByText(dcPricingRegionNotice).should('be.visible'); + ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); - // TODO: DC Pricing - M3-7073: Add to test above. // Confirm that HA price updates dynamically once region selection is made. cy.contains(/\(\$.*\/month\)/).should('be.visible'); @@ -307,7 +276,6 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .click() .type(`${clusterVersion}{enter}`); - // TODO: DC Pricing - M3-7073: Add to test above. // Confirm that with region and HA selections, create button is still disabled until plan selection is made. cy.get('[data-qa-deploy-linode]') .should('contain.text', 'Create Cluster') diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index cada801cbab..f0f6331d34e 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -28,11 +28,6 @@ import { import type { PoolNodeResponse, Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { randomIp, randomLabel } from 'support/util/random'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { getRegionById } from 'support/util/regions'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; @@ -742,14 +737,6 @@ describe('LKE cluster updates', () => { }); describe('LKE cluster updates for DC-specific prices', () => { - beforeEach(() => { - //TODO: DC Pricing - M3-7073: Remove feature flag mocks when DC specific pricing goes live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - }); - /* * - Confirms node pool resize UI flow using mocked API responses. * - Confirms that pool size can be increased and decreased. diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts index b809dcf499b..e2aff4b1a1c 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts @@ -63,10 +63,7 @@ describe('LKE Create Cluster', () => { cy.findByLabelText('Cluster Label').click().type(mockCluster.label); - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${chooseRegion().label}{enter}`); + ui.regionSelect.find().click().type(`${chooseRegion().label}{enter}`); cy.findByText('Kubernetes Version') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index d8de4cb608f..68d5c24693e 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -12,10 +12,6 @@ import { mockGetAccountSettings, mockUpdateAccountSettings, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodes, mockEnableLinodeBackups, @@ -27,7 +23,6 @@ import { } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; import { chooseRegion } from 'support/util/regions'; @@ -265,13 +260,33 @@ describe('"Enable Linode Backups" banner', () => { * - Confirms that backup auto-enrollment settings are shown when auto-enrollment is disabled. * - Confirms that backups drawer lists each Linode which does not have backups enabled. * - Confirms that backups drawer does not list Linodes which already have backups enabled. - * - Confirms that "Region" column is not shown when DC specific pricing is disabled. * - Confirms toast notification appears upon updating Linode backup settings. */ it('can enable Linode backups via "Enable Linode Backups" notice', () => { - const mockLinodesNoBackups = linodeFactory.buildList(3, { - backups: { enabled: false }, - }); + const mockLinodesNoBackups = [ + // `us-central` has a normal pricing structure, whereas `us-east` and `us-west` + // are mocked to have special pricing structures. + // + // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. + linodeFactory.build({ + label: randomLabel(), + region: 'us-east', + backups: { enabled: false }, + type: dcPricingMockLinodeTypesForBackups[0].id, + }), + linodeFactory.build({ + label: randomLabel(), + region: 'us-west', + backups: { enabled: false }, + type: dcPricingMockLinodeTypesForBackups[1].id, + }), + linodeFactory.build({ + label: randomLabel(), + region: 'us-central', + backups: { enabled: false }, + type: 'g6-nanode-1', + }), + ]; const mockLinodesBackups = linodeFactory.buildList(2, { backups: linodeBackupsFactory.build(), @@ -300,113 +315,6 @@ describe('"Enable Linode Backups" banner', () => { } ); - // TODO: DC Pricing - M3-7073: Remove feature flag mocks when DC pricing goes live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); - mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetAccountSettings(mockInitialAccountSettings).as('getAccountSettings'); - mockUpdateAccountSettings(mockUpdatedAccountSettings).as( - 'updateAccountSettings' - ); - - cy.visitWithLogin('/linodes', { - preferenceOverrides: { - backups_cta_dismissed: false, - }, - }); - - cy.wait([ - '@getAccountSettings', - '@getClientstream', - '@getFeatureFlags', - '@getLinodes', - ]); - - // Click "Enable Linode Backups" link within backups notice. - cy.findByText('Enable Linode Backups').should('be.visible').click(); - - ui.drawer - .findByTitle('Enable All Backups') - .should('be.visible') - .within(() => { - // Confirm that auto-enroll setting section is shown. - cy.findByText('Auto Enroll All New Linodes in Backups').should( - 'be.visible' - ); - - // Confirm that Linodes without backups enabled are listed. - mockLinodesNoBackups.forEach((linode: Linode) => { - cy.findByText(linode.label).should('be.visible'); - }); - - // Confirm that Linodes with backups already enabled are not listed. - mockLinodesBackups.forEach((linode: Linode) => { - cy.findByText(linode.label).should('not.exist'); - }); - - // Confirm that "Region" column is not shown. - // TODO: DC Pricing - M3-7073: Remove column assertions when DC pricing goes live. - cy.findByLabelText('List of Linodes without backups') - .should('be.visible') - .within(() => { - cy.findByText('Region').should('not.exist'); - }); - - // Confirm backup changes. - ui.button - .findByTitle('Confirm') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Wait for backups to be enabled and settings to be updated, then confirm - // that toast notification appears in order to confirm the changes. - cy.wait([...enableBackupAliases, '@updateAccountSettings']); - - ui.toast.assertMessage( - '3 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' - ); - }); - - /* - * - Confirms that DC-specific pricing information is displayed in backups drawer when feature is enabled. - */ - it('displays DC-specific pricing information when feature flag is enabled', () => { - const mockAccountSettings = accountSettingsFactory.build({ - backups_enabled: false, - managed: false, - }); - - // TODO: DC Pricing - M3-7073: Move assertions involving pricing to above test when DC-specific pricing goes live. - // TODO: DC Pricing - M3-7073: Remove this test when DC-specific pricing goes live. - const mockLinodes = [ - // `us-central` has a normal pricing structure, whereas `us-east` and `us-west` - // are mocked to have special pricing structures. - // - // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. - linodeFactory.build({ - label: randomLabel(), - region: 'us-east', - backups: { enabled: false }, - type: dcPricingMockLinodeTypesForBackups[0].id, - }), - linodeFactory.build({ - label: randomLabel(), - region: 'us-west', - backups: { enabled: false }, - type: dcPricingMockLinodeTypesForBackups[1].id, - }), - linodeFactory.build({ - label: randomLabel(), - region: 'us-central', - backups: { enabled: false }, - type: 'g6-nanode-1', - }), - ]; - // The expected backup price for each Linode, as shown in backups drawer table. const expectedPrices = [ '$3.57/mo', // us-east mocked price. @@ -421,12 +329,13 @@ describe('"Enable Linode Backups" banner', () => { mockGetLinodeType(dcPricingMockLinodeTypesForBackups[1]); mockGetLinodeTypes(dcPricingMockLinodeTypesForBackups); - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientstream'); mockGetLinodes(mockLinodes).as('getLinodes'); - mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); + + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetAccountSettings(mockInitialAccountSettings).as('getAccountSettings'); + mockUpdateAccountSettings(mockUpdatedAccountSettings).as( + 'updateAccountSettings' + ); cy.visitWithLogin('/linodes', { preferenceOverrides: { @@ -434,21 +343,20 @@ describe('"Enable Linode Backups" banner', () => { }, }); - cy.wait([ - '@getFeatureFlags', - '@getClientstream', - '@getLinodes', - '@getAccountSettings', - ]); + cy.wait(['@getAccountSettings', '@getLinodes']); // Click "Enable Linode Backups" link within backups notice. cy.findByText('Enable Linode Backups').should('be.visible').click(); - // Confirm that DC-specific pricing content is shown in the backups drawer. ui.drawer .findByTitle('Enable All Backups') .should('be.visible') .within(() => { + // Confirm that auto-enroll setting section is shown. + cy.findByText('Auto Enroll All New Linodes in Backups').should( + 'be.visible' + ); + // Confirm that expected total cost is shown. cy.contains(`Total for 3 Linodes: ${expectedTotal}`).should( 'be.visible' @@ -461,8 +369,8 @@ describe('"Enable Linode Backups" banner', () => { cy.findByText('Region').should('be.visible'); }); - // Confirm that each Linode is listed alongside its DC-specific price. - mockLinodes.forEach((linode: Linode, i: number) => { + // Confirm that each Linode without backups enabled is listed alongside its DC-specific price. + mockLinodesNoBackups.forEach((linode: Linode, i: number) => { const expectedPrice = expectedPrices[i]; cy.findByText(linode.label) .should('be.visible') @@ -471,6 +379,26 @@ describe('"Enable Linode Backups" banner', () => { cy.findByText(expectedPrice).should('be.visible'); }); }); + + // Confirm that Linodes with backups already enabled are not listed. + mockLinodesBackups.forEach((linode: Linode) => { + cy.findByText(linode.label).should('not.exist'); + }); + + // Confirm backup changes. + ui.button + .findByTitle('Confirm') + .should('be.visible') + .should('be.enabled') + .click(); }); + + // Wait for backups to be enabled and settings to be updated, then confirm + // that toast notification appears in order to confirm the changes. + cy.wait([...enableBackupAliases, '@updateAccountSettings']); + + ui.toast.assertMessage( + '3 Linodes have been enrolled in automatic backups, and all new Linodes will automatically be backed up.' + ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 58e104becc1..95cb12cca9b 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,9 +1,5 @@ import { Linode, createLinode } from '@linode/api-v4'; import { linodeFactory, createLinodeRequestFactory } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { interceptCloneLinode, mockGetLinodeDetails, @@ -12,11 +8,11 @@ import { mockGetLinodeTypes, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { - dcPricingRegionNotice, dcPricingMockLinodeTypes, dcPricingRegionDifferenceNotice, + dcPricingDocsLabel, + dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { chooseRegion, getRegionById } from 'support/util/regions'; import { randomLabel } from 'support/util/random'; @@ -27,17 +23,13 @@ import { cleanUp } from 'support/util/cleanup'; * Returns the Cloud Manager URL to clone a given Linode. * * @param linode - Linode for which to retrieve clone URL. - * @param withRegion - Whether to append a region query to the URL. * * @returns Cloud Manager Clone URL for Linode. */ -const getLinodeCloneUrl = ( - linode: Linode, - withRegion: boolean = true -): string => { - const regionQuery = withRegion ? `®ionID=${linode.region}` : ''; +const getLinodeCloneUrl = (linode: Linode): string => { + const regionQuery = `®ionID=${linode.region}`; const typeQuery = `&typeID=${linode.type}`; - return `/linodes/create?linodeID=${linode.id}&type=Clone+Linode${typeQuery}${regionQuery}`; + return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; authenticate(); @@ -62,13 +54,10 @@ describe('clone linode', () => { const newLinodeLabel = `${linodePayload.label}-clone`; - // TODO: DC Pricing - M3-7073: Remove feature flag mocks once DC pricing is live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createLinode(linodePayload)).then((linode: Linode) => { + const linodeRegion = getRegionById(linodePayload.region); + const linodeRegionLabel = `${linodeRegion.label} (${linodeRegion.id})`; + interceptCloneLinode(linode.id).as('cloneLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -81,16 +70,11 @@ describe('clone linode', () => { .click(); ui.actionMenuItem.findByTitle('Clone').should('be.visible').click(); - - // Cloning from Linode Details page does not pre-select a region. - // (Cloning from the Linodes landing does pre-select a region, however.) - cy.url().should('endWith', getLinodeCloneUrl(linode, false)); + cy.url().should('endWith', getLinodeCloneUrl(linode)); // Select clone region and Linode type. - cy.findByText('Select a Region') - .should('be.visible') - .click() - .type(`${linodeRegion.label}{enter}`); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionId(linodeRegion.id).click(); cy.findByText('Shared CPU').should('be.visible').click(); @@ -123,7 +107,7 @@ describe('clone linode', () => { /* * - Confirms DC-specific pricing UI flow works as expected during Linode clone. - * - Confirms that pricing notice is shown in "Region" section. + * - Confirms that pricing docs link is shown in "Region" section. * - Confirms that notice is shown when selecting a region with a different price structure. */ it('shows DC-specific pricing information during clone flow', () => { @@ -137,10 +121,6 @@ describe('clone linode', () => { mockGetLinodes([mockLinode]).as('getLinodes'); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); // Mock requests to get all Linode types, and to get individual types. mockGetLinodeType(dcPricingMockLinodeTypes[0]); @@ -148,28 +128,20 @@ describe('clone linode', () => { mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); cy.visitWithLogin(getLinodeCloneUrl(mockLinode)); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getLinodes', - '@getLinodeTypes', - ]); - - cy.findByText(dcPricingRegionNotice, { exact: false }).should('be.visible'); + cy.wait(['@getLinode', '@getLinodes', '@getLinodeTypes']); - // TODO: DC Pricing - M3-7086: Uncomment docs link assertion when docs links are added. - // cy.findByText(dcPricingDocsLabel) - // .should('be.visible') - // .should('have.attr', 'href', dcPricingDocsUrl); + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); // Confirm that DC-specific pricing difference notice is not yet shown. cy.findByText(dcPricingRegionDifferenceNotice, { exact: false }).should( 'not.exist' ); - cy.findByText(`${initialRegion.label} (${initialRegion.id})`) - .should('be.visible') + ui.regionSelect + .findBySelectedItem(`${initialRegion.label} (${initialRegion.id})`) .click() .type(`${newRegion.label}{enter}`); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 81cc21759b2..bd15e14c7f5 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -1,34 +1,61 @@ import { - containsClick, containsVisible, fbtClick, fbtVisible, getClick, } from 'support/helpers'; -import { selectRegionString } from 'support/ui/constants'; import { ui } from 'support/ui'; import { apiMatcher } from 'support/util/intercepts'; import { randomString, randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { getRegionById } from 'support/util/regions'; -import { linodeFactory } from '@src/factories'; +import { linodeFactory, regionFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; +import { mockGetRegions } from 'support/intercepts/regions'; import { - dcPricingRegionNotice, dcPricingPlanPlaceholder, dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { mockCreateLinode } from 'support/intercepts/linodes'; +import { + mockGetLinodeType, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; + +import type { Region } from '@linode/api-v4'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - mockGetLinodeType, - mockGetLinodeTypes, -} from 'support/intercepts/linodes'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }), +]; authenticate(); describe('create linode', () => { @@ -36,6 +63,59 @@ describe('create linode', () => { cleanUp('linodes'); }); + /* + * Region select test. + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + mockAppendFeatureFlags({ + soldOutTokyo: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('linodes/create'); + + cy.wait(['@getClientStream', '@getFeatureFlags', '@getRegions']); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + // Confirm that the selected region is displayed in the input field. + cy.get('[data-testid="textfield-input"]').should( + 'have.value', + 'London, UK (eu-west)' + ); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); + it('creates a nanode', () => { const rootpass = randomString(32); const linodeLabel = randomLabel(); @@ -44,7 +124,8 @@ describe('create linode', () => { cy.get('[data-qa-deploy-linode]'); cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - containsClick(selectRegionString).type(`${chooseRegion().label} {enter}`); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"]'); getClick('#linode-label').clear().type(linodeLabel); @@ -64,13 +145,10 @@ describe('create linode', () => { cy.visitWithLogin('/linodes/create'); - cy.contains('Select a Region').click(); - - ui.regionSelect.findItemByRegionLabel(linodeRegion.label); - + ui.regionSelect.find().click(); ui.autocompletePopper .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('be.visible') + .should('exist') .click(); cy.get('[id="g6-dedicated-2"]').click(); @@ -132,8 +210,7 @@ describe('create linode', () => { /* * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing notice is shown in "Region" section. - * - Confirms that notice is shown when selecting a region with a different price structure. + * - Confirms that pricing docs link is shown in "Region" section. * - Confirms that backups pricing is correct when selecting a region with a different price structure. */ it('shows DC-specific pricing information during create flow', () => { @@ -161,11 +238,6 @@ describe('create linode', () => { (regionPrice) => regionPrice.id === newRegion.id ); - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Mock requests to get individual types. mockGetLinodeType(dcPricingMockLinodeTypes[0]); mockGetLinodeType(dcPricingMockLinodeTypes[1]); @@ -173,7 +245,7 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getLinodeTypes']); + cy.wait(['@getLinodeTypes']); mockCreateLinode(mockLinode).as('linodeCreated'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); @@ -191,8 +263,8 @@ describe('create linode', () => { // Check the 'Backups' add on cy.get('[data-testid="backups"]').should('be.visible').click(); - - containsClick(selectRegionString).type(`${initialRegion.label} {enter}`); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); fbtClick('Shared CPU'); getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); // Confirm that the backup prices are displayed as expected. @@ -213,15 +285,12 @@ describe('create linode', () => { ); }); - // Confirms that a notice is shown in the "Region" section of the Linode Create form informing the user of tiered pricing - cy.findByText(dcPricingRegionNotice, { exact: false }).should('be.visible'); - - // TODO: DC Pricing - M3-7086: Uncomment docs link assertion when docs links are added. - // cy.findByText(dcPricingDocsLabel) - // .should('be.visible') - // .should('have.attr', 'href', dcPricingDocsUrl); + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); - containsClick(initialRegion.label).type(`${newRegion.label} {enter}`); + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); fbtClick('Shared CPU'); getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); // Confirm that the backup prices are displayed as expected. 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 95933ad1bfa..97be417a554 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -3,22 +3,79 @@ import { containsVisible } from 'support/helpers'; import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; -import { interceptRebootLinode } from 'support/intercepts/linodes'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { getRegionById } from 'support/util/regions'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { + interceptRebootLinode, + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; import { interceptDeleteLinodeConfig, interceptCreateLinodeConfigs, interceptUpdateLinodeConfigs, + mockGetLinodeConfigs, + mockCreateLinodeConfigs, + mockUpdateLinodeConfigs, } from 'support/intercepts/configs'; import { createLinodeAndGetConfig, createAndBootLinode, } from 'support/util/linodes'; +import { + vpcFactory, + linodeFactory, + linodeConfigFactory, + subnetFactory, + VLANFactory, +} from '@src/factories'; +import { randomNumber, randomLabel } from 'support/util/random'; -import type { Config, Linode } from '@linode/api-v4'; +import type { Config, Linode, VLAN, VPC, Disk, Region } from '@linode/api-v4'; authenticate(); 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); + const mockVPCs: VPC[] = vpcFactory.buildList(5); + + before(() => { + mockConfig.interfaces.splice(2, 1); + }); + beforeEach(() => { cleanUp(['linodes']); }); @@ -62,6 +119,109 @@ describe('Linode 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, + }); + + const mockVPC = vpcFactory.build({ + id: 1, + label: randomLabel(), + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetVPCs(mockVPCs).as('getVPCs'); + 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', + '@getVPCs', + '@getDisks', + '@getVolumes', + ]); + + // Confirm that there is no configuration yet. + cy.findByLabelText('List of Configurations').within(() => { + cy.contains(`${mockConfig.label} – GRUB 2`).should('not.exist'); + }); + + 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() + .within(() => { + ui.select + .findByText('None') + .should('be.visible') + .click() + .type('VPC{enter}'); + }); + 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({ @@ -113,6 +273,109 @@ describe('Linode 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, + }); + + const mockVPC = vpcFactory.build({ + id: 1, + label: randomLabel(), + }); + + mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetVPCs(mockVPCs).as('getVPCs'); + 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', + '@getVPCs', + '@getDisks', + '@getVolumes', + ]); + + cy.findByLabelText('List of Configurations').within(() => { + containsVisible(`${mockConfig.label} – GRUB 2`); + }); + cy.findByText('Edit').click(); + + 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() + .within(() => { + ui.select + .findByText('VLAN') + .should('be.visible') + .click() + .type('VPC{enter}'); + }); + // Change eth2 to VPC + cy.get('[data-qa-textfield-label="eth2"]') + .scrollIntoView() + .parent() + .parent() + .within(() => { + ui.select + .findByText('VPC') + .should('be.visible') + .click() + .type('VPC{enter}'); + }); + ui.button + .findByTitle('Save Changes') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .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`); @@ -228,7 +491,7 @@ describe('Linode Config', () => { ui.button.findByTitle('Clone').should('be.disabled'); cy.findByRole('combobox').should('be.visible').click(); ui.select - .findLinodeItemByText('cy-test-clone-destination-linode') + .findItemByText('cy-test-clone-destination-linode') .click(); ui.button.findByTitle('Clone').should('be.enabled').click(); }); diff --git a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts index 37eaae91d50..e842e3bc94a 100644 --- a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts @@ -5,16 +5,10 @@ import { mockMigrateLinode, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { apiMatcher } from 'support/util/intercepts'; -import { linodeFactory } from '@src/factories'; +import { linodeFactory, linodeTypeFactory } from '@src/factories'; import { mockGetLinodeDetails } from 'support/intercepts/linodes'; -import { getClick, fbtClick, containsClick } from 'support/helpers'; -import { selectRegionString } from 'support/ui/constants'; +import { getClick, fbtClick } from 'support/helpers'; import { getRegionById } from 'support/util/regions'; import { dcPricingMockLinodeTypes, @@ -27,75 +21,35 @@ import { linodeDiskFactory } from '@src/factories'; authenticate(); describe('Migrate linodes', () => { /* - * - Confirms DC-specific pricing does not show up during Linode migration when the feature flag is not enabled. - * - TODO This test should be updated to ignore the pricing information when the feature is released. + * - Confirms that flow works as expected during Linode migration between two regions without pricing changes. */ - it('can migrate linodes without DC-specific pricing comparison information when feature flag is disabled', () => { + it('can migrate linodes', () => { const initialRegion = getRegionById('us-west'); const newRegion = getRegionById('us-east'); const mockLinode = linodeFactory.build({ region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, }); const mockLinodeDisks = linodeDiskFactory.buildList(3); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - // TODO Remove feature flag mocks once DC pricing is live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Mock request to get Linode volumes and disks mockGetLinodeDisks(mockLinode.id, mockLinodeDisks).as('getLinodeDisks'); mockGetLinodeVolumes(mockLinode.id, []).as('getLinodeVolumes'); - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - cy.visitWithLogin(`/linodes/${mockLinode.id}?migrate=true`); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getLinodeDisks', - '@getLinodeVolumes', - ]); + cy.wait(['@getLinode', '@getLinodeDisks', '@getLinodeVolumes']); ui.button.findByTitle('Enter Migration Queue').should('be.disabled'); cy.findByText(`${initialRegion.label}`).should('be.visible'); getClick('[data-qa-checked="false"]'); cy.findByText(`North America: ${initialRegion.label}`).should('be.visible'); - containsClick(selectRegionString); + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionLabel(newRegion.label).click(); - cy.findByText(dcPricingCurrentPriceLabel).should('not.exist'); - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - ); - cy.get('[data-testid="current-price-panel"]').should('not.exist'); - cy.findByText(`$${currentPrice.monthly.toFixed(2)}`).should('not.exist'); - cy.findByText(`$${currentPrice.hourly}`).should('not.exist'); - cy.findByText( - `$${dcPricingMockLinodeTypes[0].addons.backups.price.monthly.toFixed(2)}` - ).should('not.exist'); - - cy.findByText(dcPricingNewPriceLabel).should('not.exist'); - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - ); - cy.get('[data-testid="new-price-panel"]').should('not.exist'); - cy.findByText(`$${newPrice.monthly.toFixed(2)}`).should('not.exist'); - cy.findByText(`$${newPrice.hourly}`).should('not.exist'); - cy.findByText( - `$${dcPricingMockLinodeTypes[1].addons.backups.price.monthly.toFixed(2)}` - ).should('not.exist'); - // intercept migration request and stub it, respond with 200 cy.intercept( 'POST', @@ -126,12 +80,6 @@ describe('Migrate linodes', () => { mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - // TODO: DC Pricing - M3-7073: Remove feature flag mocks once DC pricing is live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Mock request to get Linode volumes and disks mockGetLinodeDisks(mockLinode.id, mockLinodeDisks).as('getLinodeDisks'); mockGetLinodeVolumes(mockLinode.id, []).as('getLinodeVolumes'); @@ -141,20 +89,14 @@ describe('Migrate linodes', () => { mockGetLinodeType(dcPricingMockLinodeTypes[1]); cy.visitWithLogin(`/linodes/${mockLinode.id}?migrate=true`); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getLinodeDisks', - '@getLinodeVolumes', - ]); + cy.wait(['@getLinode', '@getLinodeDisks', '@getLinodeVolumes']); ui.button.findByTitle('Enter Migration Queue').should('be.disabled'); cy.findByText(`${initialRegion.label}`).should('be.visible'); getClick('[data-qa-checked="false"]'); cy.findByText(`North America: ${initialRegion.label}`).should('be.visible'); - containsClick(selectRegionString); + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionLabel(newRegion.label).click(); cy.findByText(dcPricingCurrentPriceLabel).should('be.visible'); @@ -207,12 +149,6 @@ describe('Migrate linodes', () => { mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); - // TODO: DC Pricing - M3-7073: Remove feature flag mocks once DC pricing is live. - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Mock request to get Linode volumes and disks mockGetLinodeDisks(mockLinode.id, mockLinodeDisks).as('getLinodeDisks'); mockGetLinodeVolumes(mockLinode.id, []).as('getLinodeVolumes'); @@ -222,13 +158,7 @@ describe('Migrate linodes', () => { mockGetLinodeType(dcPricingMockLinodeTypes[1]); cy.visitWithLogin(`/linodes/${mockLinode.id}?migrate=true`); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getLinode', - '@getLinodeDisks', - '@getLinodeVolumes', - ]); + cy.wait(['@getLinode', '@getLinodeDisks', '@getLinodeVolumes']); ui.button.findByTitle('Enter Migration Queue').should('be.disabled'); cy.findByText(`${initialRegion.label}`).should('be.visible'); @@ -243,7 +173,7 @@ describe('Migrate linodes', () => { cy.findByText(dcPricingNewPriceLabel).should('not.exist'); cy.get('[data-testid="new-price-panel"]').should('not.exist'); // Change region selection to another region with the same price structure. - cy.findByText('New Region').click().type(`${newRegion.label}{enter}`); + ui.regionSelect.find().click().clear().type(`${newRegion.label}{enter}`); // Confirm that DC pricing information still does not show up. cy.findByText(dcPricingCurrentPriceLabel).should('not.exist'); cy.get('[data-testid="current-price-panel"]').should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index e2f318e4735..55b5e951243 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -85,6 +85,7 @@ const submitRebuild = () => { ui.button .findByTitle('Rebuild Linode') .scrollIntoView() + .should('have.attr', 'data-qa-form-data-loading', 'false') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index e5416609233..2d62584aa8e 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -6,6 +6,8 @@ import { interceptGetLinodeDetails, interceptRebootLinodeIntoRescueMode, mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, mockRebootLinodeIntoRescueModeError, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; @@ -19,6 +21,7 @@ const rebootInRescueMode = () => { .findByTitle('Reboot into Rescue Mode') .should('be.visible') .should('be.enabled') + .should('have.attr', 'data-qa-form-data-loading', 'false') .click(); }; @@ -92,6 +95,9 @@ describe('Rescue Linodes', () => { }); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); + mockGetLinodeDisks(mockLinode.id, []).as('getLinodeDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getLinodeVolumes'); + mockRebootLinodeIntoRescueModeError(mockLinode.id, 'Linode busy.').as( 'rescueLinode' ); diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index bd443c6da77..f2f75f5e7ce 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -38,7 +38,7 @@ describe('resize linode', () => { // TODO: Unified Migration: [M3-7115] - Replace with copy from API '../notifications.py' cy.contains( - 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' + "Your linode will be warm resized and will automatically attempt to power off and restore to it's previous state." ).should('be.visible'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 160a1a77218..81ba5308122 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -393,7 +393,7 @@ describe('linode landing checks', () => { .closest('[data-qa-linode-card]') .within(() => { cy.findByText('Summary').should('be.visible'); - cy.findByText('IP Addresses').should('be.visible'); + cy.findByText('Public IP Addresses').should('be.visible'); cy.findByText('Access').should('be.visible'); cy.findByText('Plan:').should('be.visible'); @@ -407,7 +407,7 @@ describe('linode landing checks', () => { getVisible('[aria-label="Toggle display"]').should('be.enabled').click(); cy.findByText('Summary').should('not.exist'); - cy.findByText('IP Addresses').should('not.exist'); + cy.findByText('Public IP Addresses').should('not.exist'); cy.findByText('Access').should('not.exist'); cy.findByText('Plan:').should('not.exist'); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts index 8fc87120c34..588c9c27e17 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-configurations.spec.ts @@ -3,27 +3,45 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { loadbalancerFactory, configurationFactory } from '@src/factories'; import { + loadbalancerFactory, + configurationFactory, + certificateFactory, + routeFactory, +} from '@src/factories'; +import { + mockCreateLoadBalancerConfiguration, + mockCreateLoadBalancerConfigurationError, + mockDeleteLoadBalancerConfiguration, + mockDeleteLoadBalancerConfigurationError, mockGetLoadBalancer, + mockGetLoadBalancerCertificates, mockGetLoadBalancerConfigurations, + mockGetLoadBalancerRoutes, + mockUpdateLoadBalancerConfiguration, + mockUpdateLoadBalancerConfigurationError, } from 'support/intercepts/load-balancers'; +import { ui } from 'support/ui'; describe('Akamai Global Load Balancer configurations page', () => { - it('renders configurations', () => { - const loadbalancer = loadbalancerFactory.build(); - const configurations = configurationFactory.buildList(5); - + beforeEach(() => { mockAppendFeatureFlags({ aglb: makeFeatureFlagData(true), }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); + }); + + it('renders configurations', () => { + const loadbalancer = loadbalancerFactory.build(); + const configurations = configurationFactory.buildList(5); + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( 'getConfigurations' ); cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + cy.wait([ '@getFeatureFlags', '@getClientStream', @@ -35,4 +53,555 @@ describe('Akamai Global Load Balancer configurations page', () => { cy.findByText(configuration.label).should('be.visible'); } }); + describe('create', () => { + it('creates a HTTPS configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const certificates = certificateFactory.buildList(1); + const routes = routeFactory.buildList(1); + const configuration = configurationFactory.build(); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( + 'getConfigurations' + ); + mockGetLoadBalancerCertificates(loadbalancer.id, certificates); + mockGetLoadBalancerRoutes(loadbalancer.id, routes); + mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( + 'createConfiguration' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + ui.button.findByTitle('Add Configuration').click(); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .type(configuration.label); + + ui.button.findByTitle('Apply Certificates').should('be.visible').click(); + + ui.drawer.findByTitle('Apply Certificates').within(() => { + cy.findByLabelText('Host Header').should('be.visible').type('*'); + + cy.findByLabelText('Certificate').should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(certificates[0].label) + .should('be.visible') + .click(); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(certificates[0].label); + cy.findByText('*'); + + ui.button.findByTitle('Add Route').click(); + + ui.drawer.findByTitle('Add Route').within(() => { + cy.findByLabelText('Route').click(); + + ui.autocompletePopper.findByTitle(routes[0].label).click(); + + ui.buttonGroup + .findButtonByTitle('Add Route') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.button + .findByTitle('Create Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@createConfiguration', '@getConfigurations']); + }); + it('creates a HTTP configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const routes = routeFactory.buildList(1, { protocol: 'http' }); + const configuration = configurationFactory.build({ + port: 80, + protocol: 'http', + }); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( + 'getConfigurations' + ); + mockGetLoadBalancerRoutes(loadbalancer.id, routes); + mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( + 'createConfiguration' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + ui.button.findByTitle('Add Configuration').click(); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .type(configuration.label); + + cy.findByLabelText('Protocol').click(); + + ui.autocompletePopper + .findByTitle(configuration.protocol.toUpperCase()) + .click(); + + cy.findByLabelText('Port').clear().type(configuration.port); + + // Certificates do not apply to HTTP, so make sure there is not mention of them + cy.findByText('Details') + .closest('form') + .findByText('Certificate') + .should('not.exist'); + + ui.button.findByTitle('Add Route').click(); + + ui.drawer.findByTitle('Add Route').within(() => { + cy.findByLabelText('Route').click(); + + ui.autocompletePopper.findByTitle(routes[0].label).click(); + + ui.buttonGroup + .findButtonByTitle('Add Route') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.button + .findByTitle('Create Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@createConfiguration', '@getConfigurations']); + }); + it('creates a TCP configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const routes = routeFactory.buildList(1, { protocol: 'tcp' }); + const configuration = configurationFactory.build({ + port: 22, + protocol: 'tcp', + }); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( + 'getConfigurations' + ); + mockGetLoadBalancerRoutes(loadbalancer.id, routes); + mockCreateLoadBalancerConfiguration(loadbalancer.id, configuration).as( + 'createConfiguration' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + ui.button.findByTitle('Add Configuration').click(); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .type(configuration.label); + + cy.findByLabelText('Protocol').click(); + + ui.autocompletePopper + .findByTitle(configuration.protocol.toUpperCase()) + .click(); + + cy.findByLabelText('Port').clear().type(configuration.port); + + // Certificates do not apply to HTTP, so make sure there is not mention of them + cy.findByText('Details') + .closest('form') + .findByText('Certificate') + .should('not.exist'); + + ui.button.findByTitle('Add Route').click(); + + ui.drawer.findByTitle('Add Route').within(() => { + cy.findByLabelText('Route').click(); + + ui.autocompletePopper.findByTitle(routes[0].label).click(); + + ui.buttonGroup + .findButtonByTitle('Add Route') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.button + .findByTitle('Create Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@createConfiguration', '@getConfigurations']); + }); + it('shows API errors when creating an HTTPS configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const certificates = certificateFactory.buildList(1); + const routes = routeFactory.buildList(1); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( + 'getConfigurations' + ); + mockGetLoadBalancerCertificates(loadbalancer.id, certificates); + mockGetLoadBalancerRoutes(loadbalancer.id, routes); + + const errors = [ + { field: 'label', reason: 'Must be greater than 2 characters.' }, + { field: 'port', reason: 'Must be a number.' }, + { field: 'protocol', reason: "Can't use UDP." }, + { field: 'route_ids', reason: 'Your routes are messed up.' }, + { + field: 'certificates', + reason: 'Something about your certs is not correct.', + }, + ]; + + mockCreateLoadBalancerConfigurationError(loadbalancer.id, errors).as( + 'createConfiguration' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + ui.button.findByTitle('Add Configuration').click(); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .type('test'); + + ui.button.findByTitle('Apply Certificates').should('be.visible').click(); + + ui.drawer.findByTitle('Apply Certificates').within(() => { + cy.findByLabelText('Host Header').should('be.visible').type('*'); + + cy.findByLabelText('Certificate').should('be.visible').click(); + + ui.autocompletePopper + .findByTitle(certificates[0].label) + .should('be.visible') + .click(); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(certificates[0].label); + cy.findByText('*'); + + ui.button.findByTitle('Add Route').click(); + + ui.drawer.findByTitle('Add Route').within(() => { + cy.findByLabelText('Route').click(); + + ui.autocompletePopper.findByTitle(routes[0].label).click(); + + ui.buttonGroup + .findButtonByTitle('Add Route') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.button + .findByTitle('Create Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait(['@createConfiguration']); + + for (const { reason } of errors) { + cy.findByText(reason).should('be.visible'); + } + }); + }); + + describe('update', () => { + it('edits a HTTPS configuration', () => { + const configuration = configurationFactory.build({ protocol: 'https' }); + const loadbalancer = loadbalancerFactory.build({ + configurations: [{ id: configuration.id, label: configuration.label }], + }); + const certificates = certificateFactory.buildList(3); + const routes = routeFactory.buildList(3); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, [configuration]).as( + 'getConfigurations' + ); + mockGetLoadBalancerCertificates(loadbalancer.id, certificates).as( + 'getCertificates' + ); + mockUpdateLoadBalancerConfiguration(loadbalancer.id, configuration).as( + 'updateConfiguration' + ); + mockGetLoadBalancerRoutes(loadbalancer.id, routes).as('getRoutes'); + + cy.visitWithLogin( + `/loadbalancers/${loadbalancer.id}/configurations/${configuration.id}` + ); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + '@getRoutes', + '@getCertificates', + ]); + + // In edit mode, we will disable the "save" button if the user hasn't made any changes + ui.button + .findByTitle('Save Configuration') + .should('be.visible') + .should('be.disabled'); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .clear() + .type('new-label'); + + cy.findByLabelText('Port').should('be.visible').clear().type('444'); + + ui.button + .findByTitle('Apply More Certificates') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer.findByTitle('Apply Certificates').within(() => { + cy.findByLabelText('Host Header').type('example-1.com'); + + cy.findByLabelText('Certificate').click(); + + ui.autocompletePopper.findByTitle(certificates[1].label).click(); + + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + ui.button + .findByTitle('Save Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateConfiguration'); + }); + + it('shows API errors when editing', () => { + const configuration = configurationFactory.build({ protocol: 'https' }); + const loadbalancer = loadbalancerFactory.build({ + configurations: [{ id: configuration.id, label: configuration.label }], + }); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, [configuration]).as( + 'getConfigurations' + ); + + const errors = [ + { field: 'label', reason: 'Bad Label.' }, + { field: 'port', reason: 'Port number is too high.' }, + { field: 'protocol', reason: 'This protocol is not supported.' }, + { + field: 'certificates[0].id', + reason: 'Certificate 0 does not exist.', + }, + { + field: 'certificates[0].hostname', + reason: 'That hostname is too long.', + }, + { field: 'route_ids', reason: 'Some of these routes do not exist.' }, + ]; + + mockUpdateLoadBalancerConfigurationError( + loadbalancer.id, + configuration.id, + errors + ).as('updateConfigurationError'); + + cy.visitWithLogin( + `/loadbalancers/${loadbalancer.id}/configurations/${configuration.id}` + ); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + // In edit mode, we will disable the "save" button if the user hasn't made any changes + ui.button + .findByTitle('Save Configuration') + .should('be.visible') + .should('be.disabled'); + + cy.findByLabelText('Configuration Label') + .should('be.visible') + .clear() + .type('new-label'); + + ui.button + .findByTitle('Save Configuration') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateConfigurationError'); + + for (const error of errors) { + cy.findByText(error.reason).should('be.visible'); + } + }); + }); + + describe('delete', () => { + it('deletes a configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const configurations = configurationFactory.buildList(1); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( + 'getConfigurations' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + const configuration = configurations[0]; + + cy.findByText(configuration.label).as('accordionHeader'); + // Click the accordion header to open the accordion + cy.get('@accordionHeader').click(); + // Get the Configuration's entire accordion area + cy.get('@accordionHeader') + .closest('[data-qa-panel]') + .within(() => { + // Click the Delete button to open the delete dialog + ui.button.findByTitle('Delete').click(); + }); + + mockDeleteLoadBalancerConfiguration(loadbalancer.id, configuration.id).as( + 'deleteConfiguration' + ); + mockGetLoadBalancerConfigurations(loadbalancer.id, []).as( + 'getConfigurations' + ); + + ui.dialog + .findByTitle(`Delete Configuration ${configuration.label}?`) + .within(() => { + cy.findByText( + 'Are you sure you want to delete this configuration?' + ).should('be.visible'); + + ui.button.findByTitle('Delete').click(); + }); + + cy.wait(['@deleteConfiguration', '@getConfigurations']); + + cy.findByText(configuration.label).should('not.exist'); + }); + it('shows API errors when deleting a configuration', () => { + const loadbalancer = loadbalancerFactory.build(); + const configurations = configurationFactory.buildList(1); + + mockGetLoadBalancer(loadbalancer).as('getLoadBalancer'); + mockGetLoadBalancerConfigurations(loadbalancer.id, configurations).as( + 'getConfigurations' + ); + + cy.visitWithLogin(`/loadbalancers/${loadbalancer.id}/configurations`); + cy.wait([ + '@getFeatureFlags', + '@getClientStream', + '@getLoadBalancer', + '@getConfigurations', + ]); + + const configuration = configurations[0]; + + cy.findByText(configuration.label).as('accordionHeader'); + // Click the accordion header to open the accordion + cy.get('@accordionHeader').click(); + // Get the Configuration's entire accordion area + cy.get('@accordionHeader') + .closest('[data-qa-panel]') + .within(() => { + // Click the Delete button to open the delete dialog + ui.button.findByTitle('Delete').click(); + }); + + mockDeleteLoadBalancerConfigurationError( + loadbalancer.id, + configuration.id, + 'Control Plane Error' + ).as('deleteConfiguration'); + + ui.dialog + .findByTitle(`Delete Configuration ${configuration.label}?`) + .within(() => { + cy.findByText( + 'Are you sure you want to delete this configuration?' + ).should('be.visible'); + + ui.button.findByTitle('Delete').click(); + + cy.wait(['@deleteConfiguration']); + + cy.findByText('Control Plane Error').should('be.visible'); + }); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts index 9c68f200cb1..b7be9ae8955 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-landing-page.spec.ts @@ -10,7 +10,10 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { loadbalancerFactory, configurationFactory } from '@src/factories'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; -import { mockGetLoadBalancers } from 'support/intercepts/load-balancers'; +import { + mockGetLoadBalancer, + mockGetLoadBalancers, +} from 'support/intercepts/load-balancers'; import type { Loadbalancer } from '@linode/api-v4'; import { chooseRegion } from 'support/util/regions'; import { getRegionById } from 'support/util/regions'; @@ -58,6 +61,7 @@ describe('Akamai Global Load Balancer landing page', () => { }).as('getFeatureFlags'); mockGetFeatureFlagClientstream().as('getClientStream'); mockGetLoadBalancers(loadbalancerMocks).as('getLoadBalancers'); + mockGetLoadBalancer(loadbalancerMocks[0]); cy.visitWithLogin('/loadbalancers'); cy.wait(['@getFeatureFlags', '@getClientStream', '@getLoadBalancers']); @@ -68,14 +72,16 @@ describe('Akamai Global Load Balancer landing page', () => { .should('be.visible') .closest('tr') .within(() => { - // Confirm that regions are listed for load balancer. - loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { - const regionLabel = getRegionById(loadbalancerRegion).label; - cy.findByText(regionLabel, { exact: false }).should('be.visible'); - cy.findByText(loadbalancerRegion, { exact: false }).should( - 'be.visible' - ); - }); + // TODO: AGLB - Confirm that regions from the API are listed for load balancer + // loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { + // const regionLabel = getRegionById(loadbalancerRegion).label; + // cy.findByText(regionLabel, { exact: false }).should('be.visible'); + // cy.findByText(loadbalancerRegion, { exact: false }).should( + // 'be.visible' + // ); + // }); + + cy.findByText(loadbalancerMock.hostname).should('be.visible'); // Confirm that clicking label navigates to details page. cy.findByText(loadbalancerMock.label).should('be.visible').click(); @@ -88,14 +94,14 @@ describe('Akamai Global Load Balancer landing page', () => { .should('be.visible') .closest('tr') .within(() => { - // Confirm that regions are listed for load balancer. - loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { - const regionLabel = getRegionById(loadbalancerRegion).label; - cy.findByText(regionLabel, { exact: false }).should('be.visible'); - cy.findByText(loadbalancerRegion, { exact: false }).should( - 'be.visible' - ); - }); + // TODO: AGLB - Confirm that regions from the API are listed for load balancer + // loadbalancerMock.regions.forEach((loadbalancerRegion: string) => { + // const regionLabel = getRegionById(loadbalancerRegion).label; + // cy.findByText(regionLabel, { exact: false }).should('be.visible'); + // cy.findByText(loadbalancerRegion, { exact: false }).should( + // 'be.visible' + // ); + // }); ui.actionMenu .findByTitle( diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts index 3c7419b5552..890c89f7066 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-routes.spec.ts @@ -263,7 +263,7 @@ describe('Akamai Global Load Balancer routes page', () => { .should('be.visible') .click(); - cy.findAllByLabelText('Cookie') + cy.findByLabelText('Cookie Key') .should('be.visible') .click() .clear() @@ -539,7 +539,7 @@ describe('Akamai Global Load Balancer routes page', () => { .findByTitle('Edit Rule') .should('be.visible') .within(() => { - cy.findByLabelText('Hostname') + cy.findByLabelText('Hostname (optional)') .should('have.value', routes[0].rules[0].match_condition.hostname) .clear() .type('example.com'); diff --git a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts index 98523450a55..75a33020556 100644 --- a/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts +++ b/packages/manager/cypress/e2e/core/loadBalancers/load-balancer-service-targets.spec.ts @@ -251,10 +251,11 @@ describe('Akamai Global Load Balancer service targets', () => { regions: [loadBalancerRegion.id], }); const mockServiceTarget = serviceTargetFactory.build({ - ca_certificate: 'my-certificate', + certificate_id: 0, load_balancing_policy: 'random', }); const mockCertificate = certificateFactory.build({ + id: 0, label: 'my-certificate', }); const mockNewCertificate = certificateFactory.build({ @@ -351,7 +352,7 @@ describe('Akamai Global Load Balancer service targets', () => { // Select the certificate mocked for this load balancer. cy.findByLabelText('Certificate') - .should('have.value', mockServiceTarget.ca_certificate) + .should('have.value', mockCertificate.label) .clear() .type('my-new-certificate'); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index c6a4ad4be46..14641f24ed9 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,62 +1,179 @@ -import { LongviewClient } from '@linode/api-v4'; -import { randomLabel, randomString } from 'support/util/random'; -import { createLinode } from 'support/api/linodes'; -import { createClient } from 'support/api/longview'; -import { containsVisible, fbtVisible, getVisible } from 'support/helpers'; -import { waitForAppLoad } from 'support/ui/common'; -import { cleanUp } from 'support/util/cleanup'; +import type { Linode, LongviewClient } from '@linode/api-v4'; +import { createLongviewClient } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; +import { + longviewInstallTimeout, + longviewStatusTimeout, +} from 'support/constants/longview'; +import { + interceptFetchLongviewStatus, + interceptGetLongviewClients, +} from 'support/intercepts/longview'; +import { cleanUp } from 'support/util/cleanup'; +import { createAndBootLinode } from 'support/util/linodes'; +import { randomLabel, randomString } from 'support/util/random'; + +// Timeout if Linode creation and boot takes longer than 1 and a half minutes. +const linodeCreateTimeout = 90000; + +/** + * Returns the command used to install Longview which is shown in Cloud's UI. + * + * @param installCode - Longview client install code. + * + * @returns Install command string. + */ +const getInstallCommand = (installCode: string): string => { + return `curl -s https://lv.linode.com/${installCode} | sudo bash`; +}; + +/** + * Installs Longview on a Linode. + * + * @param linodeIp - IP of Linode on which to install Longview. + * @param linodePass - Root password of Linode on which to install Longview. + * @param installCommand - Longview installation command. + * + * @returns Cypress chainable. + */ +const installLongview = ( + linodeIp: string, + linodePass: string, + installCommand: string +) => { + return cy.exec('./cypress/support/scripts/longview/install-longview.sh', { + failOnNonZeroExit: true, + timeout: longviewInstallTimeout, + env: { + LINODEIP: linodeIp, + LINODEPASSWORD: linodePass, + CURLCOMMAND: installCommand, + }, + }); +}; + +/** + * Waits for Cloud Manager to fetch Longview data and receive updates. + * + * Cloud Manager makes repeated requests to the `/fetch` endpoint, and this + * function waits until one of these requests receives a response for the + * desired Longview client indicating that its data has been updated. + * + * @param alias - Alias assigned to the initial HTTP intercept. + * @param apiKey - API key for Longview client. + */ +const waitForLongviewData = ( + alias: string, + apiKey: string, + attempt: number = 0 +) => { + const maxAttempts = 50; + // Escape route in case expected response is never received. + if (attempt > maxAttempts) { + throw new Error( + `Timed out waiting for Longview client update after ${maxAttempts} attempts` + ); + } + cy.wait(`@${alias}`, { timeout: longviewStatusTimeout }).then( + (interceptedRequest) => { + const responseBody = interceptedRequest.response?.body?.[0]; + const apiKeyMatches = (interceptedRequest?.request?.body ?? '').includes( + apiKey + ); + const containsUpdate = + responseBody?.ACTION === 'lastUpdated' && + responseBody?.DATA?.updated !== 0; + + if (!(apiKeyMatches && containsUpdate)) { + interceptFetchLongviewStatus().as(alias); + waitForLongviewData(alias, apiKey, attempt + 1); + } + } + ); +}; -/* eslint-disable sonarjs/no-duplicate-string */ authenticate(); describe('longview', () => { before(() => { cleanUp(['linodes', 'longview-clients']); }); - it('tests longview', () => { - const linodePassword = randomString(32); - const clientLabel = randomLabel(); - cy.visitWithLogin('/dashboard'); - createLinode({ root_pass: linodePassword }).then((linode) => { - createClient(undefined, clientLabel).then((client: LongviewClient) => { - const linodeIp = linode['ipv4'][0]; - const clientLabel = client.label; - cy.visit('/longview'); - containsVisible(clientLabel); - cy.contains('Waiting for data...').first().should('be.visible'); - cy.get('code') - .first() - .then(($code) => { - const curlCommand = $code.text(); - cy.exec('./cypress/support/longview.sh', { - failOnNonZeroExit: false, - timeout: 480000, - env: { - LINODEIP: `${linodeIp}`, - LINODEPASSWORD: `${linodePassword}`, - CURLCOMMAND: `${curlCommand}`, - }, - }).then((out) => { - console.log(out.stdout); - console.log(out.stderr); - }); - waitForAppLoad('/longview', false); - getVisible(`[data-testid="${client.id}"]`).within(() => { - if ( - cy - .contains('Waiting for data...', { - timeout: 300000, - }) - .should('not.exist') - ) { - fbtVisible(clientLabel); - getVisible(`[href="/longview/clients/${client.id}"]`); - containsVisible('Swap'); - } - }); - }); - }); + /* + * - Tests Longview installation end-to-end using real API data. + * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. + * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. + */ + it('can install Longview client on a Linode', () => { + const linodePassword = randomString(32, { + symbols: false, + lowercase: true, + uppercase: true, + numbers: true, + spaces: false, + }); + + const createLinodeAndClient = async () => { + return Promise.all([ + createAndBootLinode({ + root_pass: linodePassword, + type: 'g6-standard-1', + }), + createLongviewClient(randomLabel()), + ]); + }; + + // Create Linode and Longview Client before loading Longview landing page. + cy.defer(createLinodeAndClient(), { + label: 'Creating Linode and Longview Client...', + timeout: linodeCreateTimeout, + }).then(([linode, client]: [Linode, LongviewClient]) => { + const linodeIp = linode.ipv4[0]; + const installCommand = getInstallCommand(client.install_code); + + interceptGetLongviewClients().as('getLongviewClients'); + interceptFetchLongviewStatus().as('fetchLongviewStatus'); + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Find the table row for the new Longview client, assert expected information + // is displayed inside of it. + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText(client.label).should('be.visible'); + cy.findByText(client.api_key).should('be.visible'); + cy.contains(installCommand).should('be.visible'); + cy.findByText('Waiting for data...'); + }); + + // Install Longview on Linode by SSHing into machine and executing cURL command. + installLongview(linodeIp, linodePassword, installCommand).then( + (output) => { + // TODO Output this to a log file. + console.log(output.stdout); + console.log(output.stderr); + } + ); + + // Wait for Longview to begin serving data and confirm that Cloud Manager + // UI updates accordingly. + waitForLongviewData('fetchLongviewStatus', client.api_key); + + // Sometimes Cloud Manager UI does not updated automatically upon receiving + // Longivew status data. Performing a page reload mitigates this issue. + // TODO Remove call to `cy.reload()`. + cy.reload(); + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText('Waiting for data...').should('not.exist'); + cy.findByText('CPU').should('be.visible'); + cy.findByText('RAM').should('be.visible'); + cy.findByText('Swap').should('be.visible'); + cy.findByText('Load').should('be.visible'); + cy.findByText('Network').should('be.visible'); + cy.findByText('Storage').should('be.visible'); + }); }); }); }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index b7fa804dc69..afb04304c98 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -1,6 +1,5 @@ import { entityTag } from 'support/constants/cypress'; import { createLinode } from 'support/api/linodes'; -import { selectRegionString } from 'support/ui/constants'; import { containsClick, fbtClick, @@ -11,12 +10,10 @@ import { import { apiMatcher } from 'support/util/intercepts'; import { randomLabel } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; -import { dcPricingRegionNotice } from 'support/constants/dc-specific-pricing'; import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; @@ -27,6 +24,7 @@ const deployNodeBalancer = () => { const createNodeBalancerWithUI = (nodeBal, isDcPricingTest = false) => { const regionName = getRegionById(nodeBal.region).label; + cy.visitWithLogin('/nodebalancers/create'); getVisible('[id="nodebalancer-label"]').click().clear().type(nodeBal.label); containsClick('create a tag').type(entityTag); @@ -34,39 +32,30 @@ const createNodeBalancerWithUI = (nodeBal, isDcPricingTest = false) => { if (isDcPricingTest) { const newRegion = getRegionById('br-gru'); - cy.wait(['@getClientStream', '@getFeatureFlags']); - // Confirms that the price will not display when the region is not selected cy.get('[data-qa-summary="true"]').within(() => { cy.findByText('/month').should('not.exist'); }); // Confirms that the price will show up when the region is selected - containsClick(selectRegionString).type(`${regionName}{enter}`); + ui.regionSelect.find().click().type(`${regionName}{enter}`); cy.get('[data-qa-summary="true"]').within(() => { cy.findByText(`$10/month`).should('be.visible'); }); - // TODO: DC Pricing - M3-7086: Uncomment docs link assertion when docs links are added. - // cy.findByText(dcPricingDocsLabel) - // .should('be.visible') - // .should('have.attr', 'href', dcPricingDocsUrl); + // Confirm there is a docs link to the pricing page + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); // Confirms that the summary updates to reflect price changes if the user changes their region. - cy.get(`[value="${regionName}"]`).click().type(`${newRegion.label}{enter}`); + ui.regionSelect.find().click().clear().type(`${newRegion.label}{enter}`); cy.get('[data-qa-summary="true"]').within(() => { cy.findByText(`$14/month`).should('be.visible'); }); - - // Confirms that a notice is shown in the "Region" section of the NodeBalancer Create form informing the user of DC-specific pricing - cy.findByText(dcPricingRegionNotice, { exact: false }).should('be.visible'); - - // Change back to the initial region to create the Node Balancer - cy.get(`[value="${newRegion.label}"]`).click().type(`${regionName}{enter}`); - } else { - // this will create the NB in newark, where the default Linode was created - containsClick(selectRegionString).type(`${regionName}{enter}`); } + // this will create the NB in newark, where the default Linode was created + ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); // node backend config fbtClick('Label').type(randomLabel()); @@ -149,8 +138,7 @@ describe('create NodeBalancer', () => { /* * - Confirms DC-specific pricing UI flow works as expected during NodeBalancer creation. - * - Confirms that pricing notice is shown in "Region" section. - * - Confirms that notice is shown when selecting a region with a different price structure. + * - Confirms that pricing docs link is shown in "Region" section. */ it('shows DC-specific pricing information when creating a NodeBalancer', () => { const initialRegion = getRegionById('us-west'); @@ -166,11 +154,6 @@ describe('create NodeBalancer', () => { 'createNodeBalancer' ); - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - createNodeBalancerWithUI(nodeBal, true); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 289887d06d8..8787747900f 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -57,12 +57,10 @@ describe('Object Storage enrollment', () => { * - Confirms that Object Storage can be enabled using mock API data. * - Confirms that pricing information link is present in enrollment dialog. * - Confirms that cancellation explanation is present in enrollment dialog. - * - Confirms that free beta pricing is explained for regions with special price structures. - * - Confirms that regular pricing information is shown for regions with regular price structures. - * - Confirms that generic pricing information is shown when no region is selected. + * - Confirms that DC-specific overage pricing is explained for regions in the Create Bucket drawer. + * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ - // TODO: DC Pricing - M3-7073: Delete test when cleaning up feature flag. - it('can enroll in Object Storage with free beta DC-specific pricing', () => { + it('can enroll in Object Storage', () => { const mockAccountSettings = accountSettingsFactory.build({ managed: false, object_storage: 'disabled', @@ -74,252 +72,23 @@ describe('Object Storage enrollment', () => { }; const mockRegions: Region[] = [ - regionFactory.build({ label: 'Newark, NJ', id: 'us-east' }), - regionFactory.build({ label: 'Sao Paulo, BR', id: 'br-gru' }), - regionFactory.build({ label: 'Jakarta, ID', id: 'id-cgk' }), - ]; - - // Clusters with special pricing are currently hardcoded rather than - // retrieved via API, so we have to mock the cluster API request to correspond - // with that hardcoded data. - const mockClusters = [ - // Regions with special pricing. - objectStorageClusterFactory.build({ - id: 'br-gru-0', - region: 'br-gru', + regionFactory.build({ + capabilities: ['Object Storage'], + label: 'Newark, NJ', + id: 'us-east', }), - objectStorageClusterFactory.build({ - id: 'id-cgk-1', - region: 'id-cgk', + regionFactory.build({ + capabilities: ['Object Storage'], + label: 'Sao Paulo, BR', + id: 'br-gru', }), - // A region that does not have special pricing. - objectStorageClusterFactory.build({ - id: 'us-east-1', - region: 'us-east', + regionFactory.build({ + capabilities: ['Object Storage'], + label: 'Jakarta, ID', + id: 'id-cgk', }), ]; - const mockAccessKey = objectStorageKeyFactory.build({ - label: randomLabel(), - }); - mockAppendFeatureFlags({ - objDcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); - mockGetClusters(mockClusters).as('getClusters'); - mockGetBuckets([]).as('getBuckets'); - mockGetRegions(mockRegions).as('getRegions'); - mockGetAccessKeys([]); - - cy.visitWithLogin('/object-storage/buckets'); - cy.wait([ - '@getFeatureFlags', - '@getClientStream', - '@getAccountSettings', - '@getClusters', - '@getBuckets', - '@getRegions', - ]); - - // Confirm that empty-state message is shown before proceeding. - cy.findByText('S3-compatible storage solution').should('be.visible'); - - // Click create button, select a region with special pricing, and submit. - ui.button - .findByTitle('Create Bucket') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.drawer - .findByTitle('Create Bucket') - .should('be.visible') - .within(() => { - // Select a region with special pricing structure. - cy.findByText('Region').click().type('Jakarta, ID{enter}'); - - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm dialog contents shows the expected information for regions - // with special pricing during beta period, then cancel. - ui.dialog - .findByTitle('Enable Object Storage') - .should('be.visible') - .within(() => { - // Confirm that DC-specific beta pricing notes are shown, as well as - // additional pricing explanation link and cancellation information. - cy.contains(objNotes.dcSpecificBetaPricing).should('be.visible'); - cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); - cy.contains(objNotes.cancellationExplanation).should('be.visible'); - - // Confirm that regular pricing information is not shown. - cy.contains(objNotes.regularPricing).should('not.exist'); - - ui.button - .findByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Initiate bucket create flow again, and this time select a region with - // regular pricing structure. - ui.drawer.findByTitle('Create Bucket').within(() => { - // Select a region with special pricing structure. - cy.findByText('Region').click().type('Newark, NJ{enter}'); - - ui.buttonGroup - .findButtonByTitle('Create Bucket') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.dialog - .findByTitle('Enable Object Storage') - .should('be.visible') - .within(() => { - // Confirm that regular pricing information is shown, as well as - // additional pricing explanation link and cancellation information. - cy.contains(objNotes.regularPricing).should('be.visible'); - cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); - cy.contains(objNotes.cancellationExplanation).should('be.visible'); - - // Confirm that DC-specific beta pricing information is not shown. - cy.contains(objNotes.dcSpecificBetaPricing).should('not.exist'); - - ui.button - .findByTitle('Cancel') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Close the "Create Bucket" drawer, and navigate to the "Access Keys" tab. - ui.drawer.findByTitle('Create Bucket').within(() => { - ui.drawerCloseButton.find().should('be.visible').click(); - }); - - ui.tabList.findTabByTitle('Access Keys').should('be.visible').click(); - - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Fill out "Create Access Key" form, then submit. - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .type(mockAccessKey.label); - - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm dialog contents shows the expected information. - mockCreateAccessKey(mockAccessKey).as('createAccessKey'); - mockGetAccessKeys([mockAccessKey]).as('getAccessKey'); - mockGetAccountSettings(mockAccountSettingsEnabled).as('getAccountSettings'); - ui.dialog - .findByTitle('Enable Object Storage') - .should('be.visible') - .within(() => { - // Confirm that DC-specific generic pricing notes are shown, as well as - // additional pricing explanation link and cancellation information. - cy.contains(objNotes.dcPricingGenericExplanation).should('be.visible'); - cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); - cy.contains(objNotes.cancellationExplanation).should('be.visible'); - - // Confirm that regular pricing information and DC-specific beta pricing - // information is not shown. - cy.contains(objNotes.regularPricing).should('not.exist'); - cy.contains(objNotes.dcSpecificBetaPricing).should('not.exist'); - - // Click "Enable Object Storage". - ui.button - .findByAttribute('data-qa-enable-obj', 'true') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@createAccessKey', '@getAccessKey', '@getAccountSettings']); - cy.findByText(mockAccessKey.label).should('be.visible'); - - // Click through the "Access Keys" dialog which displays the new access key. - ui.dialog - .findByTitle('Access Keys') - .should('be.visible') - .within(() => { - ui.button - .findByTitle('I Have Saved My Secret Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.button - .findByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - - // Fill out "Create Access Key" form, then submit. - ui.drawer - .findByTitle('Create Access Key') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label').should('be.visible').type(randomLabel()); - - ui.buttonGroup - .findButtonByTitle('Create Access Key') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.findByText('Enable Object Storage').should('not.exist'); - }); - - /* - * - Confirms that Object Storage can be enabled using mock API data. - * - Confirms that pricing information link is present in enrollment dialog. - * - Confirms that cancellation explanation is present in enrollment dialog. - * - Confirms that DC-specific overage pricing is explained for regions in the Create Bucket drawer. - * - Confirms that consistent pricing information is shown for all regions in the enable modal. - */ - // TODO: DC Pricing - M3-7073: Remove feature flag mocks once feature flags are cleaned up. - it('can enroll in Object Storage with OBJ DC-specific pricing', () => { - const mockAccountSettings = accountSettingsFactory.build({ - managed: false, - object_storage: 'disabled', - }); - - const mockAccountSettingsEnabled = { - ...mockAccountSettings, - object_storage: 'active', - }; - - const mockRegions: Region[] = [ - regionFactory.build({ label: 'Newark, NJ', id: 'us-east' }), - regionFactory.build({ label: 'Sao Paulo, BR', id: 'br-gru' }), - regionFactory.build({ label: 'Jakarta, ID', id: 'id-cgk' }), - ]; - // Clusters with special pricing are currently hardcoded rather than // retrieved via API, so we have to mock the cluster API request to correspond // with that hardcoded data. @@ -344,10 +113,6 @@ describe('Object Storage enrollment', () => { label: randomLabel(), }); - mockAppendFeatureFlags({ - objDcSpecificPricing: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); mockGetClusters(mockClusters).as('getClusters'); mockGetBuckets([]).as('getBuckets'); @@ -356,8 +121,6 @@ describe('Object Storage enrollment', () => { cy.visitWithLogin('/object-storage/buckets'); cy.wait([ - '@getFeatureFlags', - '@getClientStream', '@getAccountSettings', '@getClusters', '@getBuckets', @@ -379,7 +142,7 @@ describe('Object Storage enrollment', () => { .should('be.visible') .within(() => { // Select a region with special pricing structure. - cy.findByText('Region').click().type('Jakarta, ID{enter}'); + ui.regionSelect.find().click().type('Jakarta, ID{enter}'); // Confirm DC-specific overage prices are shown in the drawer. cy.contains( @@ -410,9 +173,6 @@ describe('Object Storage enrollment', () => { cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); cy.contains(objNotes.cancellationExplanation).should('be.visible'); - // Confirm that DC-specific beta pricing information is not shown. - cy.contains(objNotes.dcSpecificBetaPricing).should('not.exist'); - ui.button .findByTitle('Cancel') .should('be.visible') @@ -424,7 +184,7 @@ describe('Object Storage enrollment', () => { // regular pricing structure. ui.drawer.findByTitle('Create Bucket').within(() => { // Select a region with regular pricing structure. - cy.findByText('Region').click().type('Newark, NJ{enter}'); + ui.regionSelect.find().click().type('Newark, NJ{enter}'); // Confirm regular overage prices are shown in the drawer. cy.contains( @@ -451,10 +211,6 @@ describe('Object Storage enrollment', () => { cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); cy.contains(objNotes.cancellationExplanation).should('be.visible'); - // Confirm that DC-specific beta pricing information is not shown. - // TODO: DC Pricing - M3-7073: Delete next line once feature flags are cleaned up. - cy.contains(objNotes.dcSpecificBetaPricing).should('not.exist'); - ui.button .findByTitle('Cancel') .should('be.visible') @@ -508,10 +264,6 @@ describe('Object Storage enrollment', () => { cy.contains(objNotes.dcPricingLearnMoreNote).should('be.visible'); cy.contains(objNotes.cancellationExplanation).should('be.visible'); - // Confirm that DC-specific beta pricing information is not shown. - // TODO: DC Pricing - M3-7073: Delete next line once feature flags are cleaned up. - cy.contains(objNotes.dcSpecificBetaPricing).should('not.exist'); - // Click "Enable Object Storage". ui.button .findByAttribute('data-qa-enable-obj', 'true') 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 f4e45dbd357..99e656b06f2 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 @@ -126,7 +126,7 @@ describe('object storage end-to-end tests', () => { .should('be.visible') .within(() => { cy.findByText('Label').click().type(bucketLabel); - cy.findByText('Region').click().type(`${bucketRegion}{enter}`); + ui.regionSelect.find().click().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 95847214dc5..e08a6fb50e1 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -46,7 +46,7 @@ describe('object storage smoke tests', () => { .should('be.visible') .within(() => { cy.findByText('Label').click().type(bucketLabel); - cy.findByText('Region').click().type(`${bucketRegion}{enter}`); + ui.regionSelect.find().click().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') 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 0a229e26bff..9e4c2132d13 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 @@ -214,9 +214,7 @@ describe('OneClick Apps (OCA)', () => { }); // Choose a region - cy.get(`[data-qa-enhanced-select="Select a Region"]`).within(() => { - containsClick('Select a Region').type(`${region.id}{enter}`); - }); + ui.regionSelect.find().click().type(`${region.id}{enter}`); // Choose a Linode plan cy.get('[data-qa-plan-row="Dedicated 8 GB"]') diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 0f116323d09..58421d0ec17 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -87,8 +87,7 @@ const fillOutStackscriptForm = ( const fillOutLinodeForm = (label: string, regionName: string) => { const password = randomString(32); - cy.findByText('Select a Region').should('be.visible').click(); - + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionLabel(regionName).click(); cy.findByText('Linode Label') @@ -308,11 +307,11 @@ describe('Create stackscripts', () => { interceptGetStackScripts().as('getStackScripts'); interceptCreateLinode().as('createLinode'); - cy.visitWithLogin('/stackscripts/create'); cy.defer(createLinodeAndImage(), { label: 'creating Linode and Image', timeout: 360000, }).then((privateImage) => { + cy.visitWithLogin('/stackscripts/create'); cy.fixture(stackscriptBasicPath).then((stackscriptBasic) => { fillOutStackscriptForm( stackscriptLabel, diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 3e27404e02f..332c92ad1ca 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -332,9 +332,7 @@ describe('Community Stackscripts integration tests', () => { .click(); // An error message shows up when no region is selected cy.contains('Region is required.').should('be.visible'); - cy.get('[data-qa-enhanced-select="Select a Region"]').within(() => { - containsClick('Select a Region').type(`${region.id}{enter}`); - }); + ui.regionSelect.find().click().type(`${region.id}{enter}`); // Choose a plan ui.button diff --git a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts index 92b9e8d329c..dd296339864 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -11,7 +11,7 @@ import { import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; -import { interceptGetLinodeConfigs } from 'support/intercepts/linodes'; +import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { cleanUp } from 'support/util/cleanup'; // Local storage override to force volume table to list up to 100 items. 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 a435965517e..7e181043ad1 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 @@ -95,7 +95,7 @@ describe('volumes', () => { cy.findByText('Must provide a region or a Linode ID.').should('be.visible'); - cy.findByText('Region').should('be.visible').click().type('new {enter}'); + ui.regionSelect.find().click().type('newark{enter}'); mockGetVolumes([mockVolume]).as('getVolumes'); ui.button.findByTitle('Create Volume').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 4fd3de68d54..90ce0a39603 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -8,11 +8,6 @@ import { interceptCreateVolume } from 'support/intercepts/volumes'; import { randomNumber, randomString, randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -24,7 +19,7 @@ const pageSizeOverride = { authenticate(); describe('volume create flow', () => { before(() => { - cleanUp('volumes'); + cleanUp(['volumes', 'linodes']); }); /* @@ -40,28 +35,16 @@ describe('volume create flow', () => { regionLabel: region.label, }; - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - interceptCreateVolume().as('createVolume'); cy.visitWithLogin('/volumes/create', { localStorageOverrides: pageSizeOverride, }); - cy.wait(['@getFeatureFlags', '@getClientStream']); - // Fill out and submit volume create form. containsClick('Label').type(volume.label); containsClick('Size').type(`{selectall}{backspace}${volume.size}`); - containsClick('Select a Region').type(`${volume.region}{enter}`); - - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${volume.regionLabel}{enter}`); + ui.regionSelect.find().click().type(`${volume.region}{enter}`); fbtClick('Create Volume'); cy.wait('@createVolume'); @@ -103,24 +86,16 @@ describe('volume create flow', () => { }; cy.defer(createLinode(linodeRequest), 'creating Linode').then((linode) => { - mockAppendFeatureFlags({ - dcSpecificPricing: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - - mockGetFeatureFlagClientstream().as('getClientStream'); - interceptCreateVolume().as('createVolume'); cy.visitWithLogin('/volumes/create', { localStorageOverrides: pageSizeOverride, }); - cy.wait(['@getFeatureFlags', '@getClientStream']); - // Fill out and submit volume create form. containsClick('Label').type(volume.label); containsClick('Size').type(`{selectall}{backspace}${volume.size}`); - containsClick('Select a Region').type(`${volume.regionLabel}{enter}`); + ui.regionSelect.find().click().type(`${volume.region}{enter}`); cy.findByLabelText('Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index 59c4b3b977b..43bb53d93d3 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -88,10 +88,7 @@ describe('VPC create flow', () => { cy.visitWithLogin('/vpcs/create'); cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${mockVPCRegion.label}{enter}`); + ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); cy.findByText('VPC Label').should('be.visible').click().type(mockVpc.label); @@ -306,10 +303,7 @@ describe('VPC create flow', () => { cy.visitWithLogin('/vpcs/create'); cy.wait(['@getFeatureFlags', '@getClientstream', '@getRegions']); - cy.findByText('Region') - .should('be.visible') - .click() - .type(`${mockVPCRegion.label}{enter}`); + ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); cy.findByText('VPC Label').should('be.visible').click().type(mockVpc.label); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index 855eb7ff1b5..0bb4b43981c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -6,9 +6,11 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetVPCs, mockDeleteVPC, + mockDeleteVPCError, mockUpdateVPC, + MOCK_DELETE_VPC_ERROR, } from 'support/intercepts/vpc'; -import { vpcFactory } from '@src/factories'; +import { subnetFactory, vpcFactory } from '@src/factories'; import { ui } from 'support/ui'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; @@ -69,7 +71,6 @@ describe('VPC landing page', () => { cy.findByText(VPC_LABEL).should('be.visible'); cy.findByText('Create a private and isolated network').should('be.visible'); cy.findByText('Getting Started Guides').should('be.visible'); - cy.findByText('Video Playlist').should('be.visible'); // Create button exists and navigates user to create page. ui.button @@ -270,6 +271,92 @@ describe('VPC landing page', () => { cy.findByText('Create a private and isolated network').should('be.visible'); }); + /** + * Confirms UI handles errors gracefully when attempting to delete a VPC + */ + it('cannot delete a VPC with linodes assigned to it', () => { + const subnet = subnetFactory.build(); + const mockVPCs = [ + vpcFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + subnets: [subnet], + }), + vpcFactory.build({ + label: randomLabel(), + region: chooseRegion().id, + }), + ]; + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetVPCs(mockVPCs).as('getVPCs'); + mockDeleteVPCError(mockVPCs[0].id).as('deleteVPCError'); + + cy.visitWithLogin('/vpcs'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPCs']); + + // Try to delete VPC + cy.findByText(mockVPCs[0].label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Complete type-to-confirm dialog. + ui.dialog + .findByTitle(`Delete VPC ${mockVPCs[0].label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('VPC Label') + .should('be.visible') + .click() + .type(mockVPCs[0].label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm that VPC doesn't get deleted and that an error appears + cy.wait(['@deleteVPCError']); + cy.findByText(MOCK_DELETE_VPC_ERROR).should('be.visible'); + + // close Delete dialog for this VPC and open it up for the second VPC to confirm that error message does not persist + ui.dialog + .findByTitle(`Delete VPC ${mockVPCs[0].label}`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(mockVPCs[1].label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(MOCK_DELETE_VPC_ERROR).should('not.exist'); + }); + /* * - Confirms that users cannot navigate to VPC landing page when feature is disabled. */ 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 new file mode 100644 index 00000000000..400d699edf2 --- /dev/null +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -0,0 +1,342 @@ +/** + * @file Integration tests for VPC assign/unassign Linodes flows. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + mockGetSubnets, + mockCreateSubnet, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { + subnetFactory, + vpcFactory, + linodeFactory, + linodeConfigFactory, + LinodeConfigInterfaceFactoryWithVPC, +} from '@src/factories'; +import { ui } from 'support/ui'; +import { randomNumber, randomLabel } from 'support/util/random'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + mockCreateLinodeConfigInterfaces, + mockGetLinodeConfigs, + mockDeleteLinodeConfigInterface, +} from 'support/intercepts/configs'; +import { + vpcAssignLinodeRebootNotice, + vpcUnassignLinodeRebootNotice, +} from 'support/constants/vpc'; +import { VPC, Linode, Config } from '@linode/api-v4/types'; + +describe('VPC assign/unassign flows', () => { + let mockVPCs: VPC[]; + let mockLinode: Linode; + let mockConfig: Config; + + before(() => { + mockVPCs = vpcFactory.buildList(5); + + mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + mockConfig = linodeConfigFactory.build({ + id: randomNumber(), + }); + }); + + /* + * - Confirms that can assign a Linode to the VPC when feature is enabled. + */ + it('can assign Linode(s) to a VPC', () => { + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockVPCAfterSubnetCreation = vpcFactory.build({ + ...mockVPC, + subnets: [mockSubnet], + }); + + const mockSubnetAfterLinodeAssignment = subnetFactory.build({ + ...mockSubnet, + linodes: [mockLinode], + }); + + const mockVPCAfterLinodeAssignment = vpcFactory.build({ + ...mockVPCAfterSubnetCreation, + subnets: [mockSubnetAfterLinodeAssignment], + }); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetVPCs(mockVPCs).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, []).as('getSubnets'); + mockCreateSubnet(mockVPC.id).as('createSubnet'); + + cy.visitWithLogin(`/vpcs/${mockVPC.id}`); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + + // confirm that vpc and subnet details get displayed + cy.findByText(mockVPC.label).should('be.visible'); + cy.findByText('Subnets (0)'); + cy.findByText('No Subnets are assigned.'); + + ui.button.findByTitle('Create Subnet').should('be.visible').click(); + + mockGetVPC(mockVPCAfterSubnetCreation).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + + ui.drawer + .findByTitle('Create Subnet') + .should('be.visible') + .within(() => { + cy.findByText('Subnet Label') + .should('be.visible') + .click() + .type(mockSubnet.label); + + cy.findByTestId('create-subnet-drawer-button') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createSubnet', '@getVPC', '@getSubnets']); + + // confirm that newly created subnet should now appear on VPC's detail page + cy.findByText(mockVPC.label).should('be.visible'); + cy.findByText('Subnets (1)').should('be.visible'); + cy.findByText(mockSubnet.label).should('be.visible'); + + // assign a linode to the subnet + ui.actionMenu + .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) + .should('be.visible') + .click(); + + mockGetLinodes([mockLinode]).as('getLinodes'); + ui.actionMenuItem + .findByTitle('Assign Linodes') + .should('be.visible') + .click(); + cy.wait('@getLinodes'); + + ui.drawer + .findByTitle(`Assign Linodes to subnet: ${mockSubnet.label} (0.0.0.0/0)`) + .should('be.visible') + .within(() => { + // confirm that the user is warned that a reboot is required + cy.findByText(vpcAssignLinodeRebootNotice).should('be.visible'); + + ui.button + .findByTitle('Assign Linode') + .should('be.visible') + .should('be.disabled'); + + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as( + 'getLinodeConfigs' + ); + cy.findByLabelText('Linodes') + .should('be.visible') + .click() + .type(mockLinode.label); + + ui.autocompletePopper + .findByTitle(mockLinode.label) + .should('be.visible') + .click(); + + cy.wait('@getLinodeConfigs'); + + mockCreateLinodeConfigInterfaces(mockLinode.id, mockConfig).as( + 'createLinodeConfigInterfaces' + ); + mockGetVPC(mockVPCAfterLinodeAssignment).as('getVPCLinodeAssignment'); + mockGetSubnets(mockVPC.id, [mockSubnetAfterLinodeAssignment]).as( + 'getSubnetsLinodeAssignment' + ); + ui.button + .findByTitle('Assign Linode') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait([ + '@createLinodeConfigInterfaces', + '@getVPCLinodeAssignment', + '@getSubnetsLinodeAssignment', + ]); + + ui.button + .findByTitle('Done') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[aria-label="View Details"]') + .closest('tbody') + .within(() => { + // after assigning Linode(s) to a VPC, VPC page increases number in 'Linodes' column + cy.findByText('1').should('be.visible'); + }); + }); + + /* + * - Confirms that can unassign a Linode from the VPC when feature is enabled. + */ + it('can unassign Linode(s) from a VPC', () => { + const mockSecondLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + linodes: [mockLinode, mockSecondLinode], + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + subnets: [mockSubnet], + }); + + const mockLinodeConfig = linodeConfigFactory.build({ + interfaces: [ + LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + subnet_id: mockSubnet.id, + }), + ], + }); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + mockGetVPCs(mockVPCs).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); + + cy.visitWithLogin(`/vpcs/${mockVPC.id}`); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); + + // confirm that subnet should get displayed on VPC's detail page + cy.findByText(mockVPC.label).should('be.visible'); + cy.findByText('Subnets (1)').should('be.visible'); + cy.findByText(mockSubnet.label).should('be.visible'); + + // unassign a linode to the subnet + ui.actionMenu + .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) + .should('be.visible') + .click(); + + mockGetLinodes([mockLinode, mockSecondLinode]).as('getLinodes'); + ui.actionMenuItem + .findByTitle('Unassign Linodes') + .should('be.visible') + .click(); + cy.wait('@getLinodes'); + + ui.drawer + .findByTitle( + `Unassign Linodes from subnet: ${mockSubnet.label} (0.0.0.0/0)` + ) + .should('be.visible') + .within(() => { + // confirm that the user is warned that a reboot is required + cy.findByText(vpcUnassignLinodeRebootNotice).should('be.visible'); + + ui.button + .findByTitle('Unassign Linodes') + .should('be.visible') + .should('be.disabled'); + + // confirm that unassign a single Linode from the VPC correctly + mockGetLinodeConfigs(mockLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + + cy.findByLabelText('Linodes') + .should('be.visible') + .click() + .type(mockLinode.label); + + ui.autocompletePopper + .findByTitle(mockLinode.label) + .should('be.visible') + .click(); + + cy.wait('@getLinodeConfigs'); + + // the select option won't disappear unless click on somewhere else + cy.findByText(vpcUnassignLinodeRebootNotice).click(); + // confirm that unassigned Linode(s) are displayed on the details page + cy.findByText('Linodes to be Unassigned from Subnet (1)').should( + 'be.visible' + ); + cy.findByText(mockLinode.label).should('be.visible'); + + // confirm that unassign multiple Linodes from the VPC correctly + mockGetLinodeConfigs(mockSecondLinode.id, [mockLinodeConfig]).as( + 'getLinodeConfigs' + ); + cy.findByText('Linodes') + .should('be.visible') + .click() + .type(mockSecondLinode.label); + cy.findByText(mockSecondLinode.label).should('be.visible').click(); + cy.wait('@getLinodeConfigs'); + + // confirm that unassigned Linode(s) are displayed on the details page + cy.findByText(vpcUnassignLinodeRebootNotice).click(); + cy.findByText('Linodes to be Unassigned from Subnet (2)').should( + 'be.visible' + ); + cy.findByText(mockSecondLinode.label).should('be.visible'); + + mockDeleteLinodeConfigInterface( + mockLinode.id, + mockLinodeConfig.id, + mockLinodeConfig.interfaces[0].id + ).as('deleteLinodeConfigInterface1'); + mockDeleteLinodeConfigInterface( + mockSecondLinode.id, + mockLinodeConfig.id, + mockLinodeConfig.interfaces[0].id + ).as('deleteLinodeConfigInterface2'); + ui.button + .findByTitle('Unassign Linodes') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that click on 'Unassign Linodes' button will send request to update the subnet details on the VPC page. + cy.wait('@deleteLinodeConfigInterface1') + .its('response.statusCode') + .should('eq', 200); + cy.wait('@deleteLinodeConfigInterface2') + .its('response.statusCode') + .should('eq', 200); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts b/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts index 1a8a8a509c2..c15162405c1 100644 --- a/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts +++ b/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts @@ -34,8 +34,7 @@ describe('Upload Machine Images', () => { .click() .type(imageDescription); - cy.contains('Select a Region').should('be.visible').click(); - + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).should('be.visible').click(); // Pass `null` to `cy.fixture()` to encode file as a Cypress buffer object. diff --git a/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts index fa859bbd594..263edc6f9b8 100644 --- a/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts @@ -2,6 +2,7 @@ import { testRegions } from 'support/util/regions'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { interceptCreateLinode } from 'support/intercepts/linodes'; + import type { Region } from '@linode/api-v4'; describe('Create Linodes', () => { @@ -17,7 +18,7 @@ describe('Create Linodes', () => { cy.visitWithLogin('linodes/create'); // Select region and plan. - cy.contains('Select a Region').should('be.visible').click(); + ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).should('be.visible').click(); cy.get('[data-qa-plan-row="Dedicated 4 GB"]') diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index 62c236de134..9d9fc2bcf57 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -5,9 +5,6 @@ import { linodeTypeFactory } from '@src/factories'; import { LkePlanDescription } from 'support/api/lke'; -/** Notice shown to users when selecting a region. */ -export const dcPricingRegionNotice = /Prices for plans, products, and services in .* may vary from other regions\./; - /** Notice shown to users when selecting a region with a different price structure. */ export const dcPricingRegionDifferenceNotice = 'The selected region has a different price structure.'; diff --git a/packages/manager/cypress/support/constants/longview.ts b/packages/manager/cypress/support/constants/longview.ts new file mode 100644 index 00000000000..29b14802ce7 --- /dev/null +++ b/packages/manager/cypress/support/constants/longview.ts @@ -0,0 +1,13 @@ +/** + * Timeout when installing Longview client on a Linode. + * + * Equates to 4 minutes and 15 seconds. + */ +export const longviewInstallTimeout = 255000; + +/** + * Timeout when waiting for a Longview client's status to be updated. + * + * Equates to 1 minute. + */ +export const longviewStatusTimeout = 60000; diff --git a/packages/manager/cypress/support/constants/vpc.ts b/packages/manager/cypress/support/constants/vpc.ts new file mode 100644 index 00000000000..46b34b0dea7 --- /dev/null +++ b/packages/manager/cypress/support/constants/vpc.ts @@ -0,0 +1,7 @@ +/** Notice shown to users trying to assign a linode to a VPC. */ +export const vpcAssignLinodeRebootNotice = + 'Assigning a Linode to a subnet requires you to reboot the Linode to update its configuration.'; + +/** Notice shown to users trying to unassign a linode from a VPC. */ +export const vpcUnassignLinodeRebootNotice = + 'Unassigning Linodes from a subnet requires you to reboot the Linodes to update its configuration.'; diff --git a/packages/manager/cypress/support/helpers.ts b/packages/manager/cypress/support/helpers.ts index c052de1d658..3355d97d4b4 100644 --- a/packages/manager/cypress/support/helpers.ts +++ b/packages/manager/cypress/support/helpers.ts @@ -10,6 +10,10 @@ export const containsClick = (text) => { return cy.contains(text).click(); }; +export const containsPlaceholderClick = (text) => { + return cy.get(`[placeholder="${text}"]`).click(); +}; + export const getVisible = (element) => { return cy.get(element).should(visible); }; diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 18906c0f844..552a7bcd9f4 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -11,11 +11,13 @@ import { makeResponse } from 'support/util/response'; import type { Account, AccountSettings, + CancelAccount, EntityTransfer, Invoice, InvoiceItem, Payment, PaymentMethod, + User, } from '@linode/api-v4'; /** @@ -26,7 +28,7 @@ import type { * @returns Cypress chainable. */ export const mockGetAccount = (account: Account): Cypress.Chainable => { - return cy.intercept('GET', apiMatcher('account'), account); + return cy.intercept('GET', apiMatcher('account'), makeResponse(account)); }; /** @@ -39,7 +41,11 @@ export const mockGetAccount = (account: Account): Cypress.Chainable => { export const mockUpdateAccount = ( updatedAccount: Account ): Cypress.Chainable => { - return cy.intercept('PUT', apiMatcher('account'), updatedAccount); + return cy.intercept( + 'PUT', + apiMatcher('account'), + makeResponse(updatedAccount) + ); }; /** @@ -322,3 +328,39 @@ export const mockGetPayments = ( paginateResponse(payments) ); }; + +/** + * Intercepts POST request to cancel account and mocks cancellation response. + * + * @param cancellation - Account cancellation object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCancelAccount = ( + cancellation: CancelAccount +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('account/cancel'), + makeResponse(cancellation) + ); +}; + +/** + * Intercepts POST request to cancel account and mocks an API error response. + * + * @param errorMessage - Error message to include in mock error response. + * @param status - HTTP status for mock error response. + * + * @returns Cypress chainable. + */ +export const mockCancelAccountError = ( + errorMessage: string, + status: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('account/cancel'), + makeErrorResponse(errorMessage, status) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/configs.ts b/packages/manager/cypress/support/intercepts/configs.ts index fe4aa03d710..220a0ff5ba6 100644 --- a/packages/manager/cypress/support/intercepts/configs.ts +++ b/packages/manager/cypress/support/intercepts/configs.ts @@ -3,12 +3,14 @@ */ import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; +import { Config } from '@linode/api-v4/types'; +import { makeResponse } from 'support/util/response'; /** * Intercepts GET request to fetch all configs for a given linode. * * @param linodeId - ID of Linode for intercepted request. - * @param configId - ID of Linode config for intercepted request. * * @returns Cypress chainable. */ @@ -17,7 +19,7 @@ export const interceptGetLinodeConfigs = ( ): Cypress.Chainable => { return cy.intercept( 'GET', - apiMatcher(`linode/instances/${linodeId}/configs`) + apiMatcher(`linode/instances/${linodeId}/configs*`) ); }; @@ -72,3 +74,102 @@ export const interceptDeleteLinodeConfig = ( apiMatcher(`linode/instances/${linodeId}/configs/${configId}`) ); }; + +/** + * Mocks DELETE request to delete an interface of linode config. + * + * @param linodeId - ID of Linode for intercepted request. + * @param configId - ID of Linode config for intercepted request. + * @param interfaceId - ID of Interface in the Linode config. + * + * @returns Cypress chainable. + */ +export const mockDeleteLinodeConfigInterface = ( + linodeId: number, + configId: number, + interfaceId: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher( + `linode/instances/${linodeId}/configs/${configId}/interfaces/${interfaceId}` + ), + makeResponse() + ); +}; + +/** + * Mocks GET request to retrieve Linode configs. + * + * @param linodeId - ID of Linode for mocked request. + * @param configs - a list of Linode configswith which to mocked response. + * + * @returns Cypress chainable. + */ +export const mockGetLinodeConfigs = ( + linodeId: number, + configs: Config[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`linode/instances/${linodeId}/configs*`), + paginateResponse(configs) + ); +}; + +/** + * Mocks PUT request to update a linode config. + * + * @param linodeId - ID of Linode for mock request. + * @param config - config data with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateLinodeConfigs = ( + linodeId: number, + config: Config +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`linode/instances/${linodeId}/configs/${config.id}`), + config + ); +}; + +/** + * Mocks POST request to create a Linode config. + * + * @param linodeId - ID of Linode for mocked request. + * @param config - config data with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateLinodeConfigs = ( + linodeId: number, + config: Config +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/configs`), + config + ); +}; + +/** + * Mocks POST request to retrieve interfaces from a given Linode config. + * + * @param linodeId - ID of Linode for mocked request. + * @param config - config data with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateLinodeConfigInterfaces = ( + linodeId: number, + config: Config +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/configs/${config.id}/interfaces`), + config.interfaces + ); +}; diff --git a/packages/manager/cypress/support/intercepts/general.ts b/packages/manager/cypress/support/intercepts/general.ts new file mode 100644 index 00000000000..3cd13083bc5 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/general.ts @@ -0,0 +1,40 @@ +import { makeErrorResponse } from 'support/util/errors'; +import { apiMatcher } from 'support/util/intercepts'; +import { makeResponse } from 'support/util/response'; + +/** + * Intercepts GET request to given URL and mocks an HTTP 200 response with the given content. + * + * This can be used to mock visits to arbitrary webpages. + * + * @param url - Webpage URL for which to intercept GET request. + * @param content - Webpage content with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockWebpageUrl = ( + url: string, + content: string +): Cypress.Chainable => { + return cy.intercept(url, makeResponse(content, 200)); +}; + +/** + * Intercepts all Linode APIv4 requests and mocks maintenance mode response. + * + * Maintenance mode mock is achieved by inserting the `x-maintenace-mode` header + * into the intercepted response. + * + * @returns Cypress chainable. + */ +export const mockApiMaintenanceMode = (): Cypress.Chainable => { + const errorResponse = makeErrorResponse( + 'Currently in maintenance mode.', + 503 + ); + errorResponse.headers = { + 'x-maintenance-mode': 'all,All endpoints are temporarily unavailable.', + }; + + return cy.intercept(apiMatcher('**'), errorResponse); +}; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index bdbc6440f35..1c7ff256c7c 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -82,22 +82,6 @@ export const interceptGetLinodeDetails = ( return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}*`)); }; -/** - * Intercepts GET request to retrieve Linode configs. - * - * @param linodeId - ID of Linode for intercepted request. - * - * @returns Cypress chainable. - */ -export const interceptGetLinodeConfigs = ( - linodeId: number -): Cypress.Chainable => { - return cy.intercept( - 'GET', - apiMatcher(`linode/instances/${linodeId}/configs*`) - ); -}; - /** * Intercepts GET request to retrieve Linode details and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/load-balancers.ts b/packages/manager/cypress/support/intercepts/load-balancers.ts index c79b257c2db..ff7ed60fbe2 100644 --- a/packages/manager/cypress/support/intercepts/load-balancers.ts +++ b/packages/manager/cypress/support/intercepts/load-balancers.ts @@ -4,6 +4,7 @@ import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import type { + APIError, Certificate, Configuration, Loadbalancer, @@ -60,6 +61,122 @@ export const mockGetLoadBalancerConfigurations = ( ); }; +/** + * Intercepts DELETE requests to delete an AGLB load balancer configuration. + * + * @param loadBalancerId - ID of load balancer for which to delete the configuration. + * @param configId - ID of the configuration being deleted. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancerConfiguration = ( + loadBalancerId: number, + configId: number +) => { + return cy.intercept( + 'DELETE', + apiMatcher(`/aglb/${loadBalancerId}/configurations/${configId}`), + {} + ); +}; + +/** + * Intercepts DELETE requests to delete an AGLB load balancer configuration and returns an error. + * + * @param loadBalancerId - ID of load balancer for which to delete the configuration. + * @param configId - ID of the configuration being deleted. + * + * @returns Cypress chainable. + */ +export const mockDeleteLoadBalancerConfigurationError = ( + loadBalancerId: number, + configId: number, + error: string +) => { + return cy.intercept( + 'DELETE', + apiMatcher(`/aglb/${loadBalancerId}/configurations/${configId}`), + makeResponse({ errors: [{ reason: error }] }, 500) + ); +}; + +/** + * Intercepts POST request to create an AGLB configuration. + * + * @param loadBalancerId - ID of load balancer for which to create the configuration. + * @param configuration - The AGLB configuration being created. + * + * @returns Cypress chainable. + */ +export const mockCreateLoadBalancerConfiguration = ( + loadBalancerId: number, + configuration: Configuration +) => { + return cy.intercept( + 'POST', + apiMatcher(`/aglb/${loadBalancerId}/configurations`), + makeResponse(configuration) + ); +}; + +/** + * Intercepts PUT request to update an AGLB configuration. + * + * @param loadBalancerId - ID of load balancer for which to update the configuration. + * @param configuration - The AGLB configuration being updated. + * + * @returns Cypress chainable. + */ +export const mockUpdateLoadBalancerConfiguration = ( + loadBalancerId: number, + configuration: Configuration +) => { + return cy.intercept( + 'PUT', + apiMatcher(`/aglb/${loadBalancerId}/configurations/${configuration.id}`), + makeResponse(configuration) + ); +}; + +/** + * Intercepts PUT request to update an AGLB configuration. + * + * @param loadBalancerId - ID of load balancer for which to update the configuration. + * @param configuration - The AGLB configuration being updated. + * + * @returns Cypress chainable. + */ +export const mockUpdateLoadBalancerConfigurationError = ( + loadBalancerId: number, + configurationId: number, + errors: APIError[] +) => { + return cy.intercept( + 'PUT', + apiMatcher(`/aglb/${loadBalancerId}/configurations/${configurationId}`), + makeResponse({ errors }, 400) + ); +}; + +/** + * Intercepts POST request to create an AGLB configuration and returns an error. + * + * @param loadBalancerId - ID of load balancer for which to create the configuration. + * @param errors - Array of API errors to mock. + * + * @returns Cypress chainable. + */ +export const mockCreateLoadBalancerConfigurationError = ( + loadBalancerId: number, + errors: APIError[] +) => { + return cy.intercept( + 'POST', + apiMatcher(`/aglb/${loadBalancerId}/configurations`), + makeResponse({ errors }, 500) + ); +}; + /** * Intercepts GET requests to retrieve AGLB load balancer certificates and mocks response. * diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts new file mode 100644 index 00000000000..62909f39b82 --- /dev/null +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -0,0 +1,19 @@ +import { apiMatcher } from 'support/util/intercepts'; + +/** + * Intercepts request to retrieve Longview status for a Longview client. + * + * @returns Cypress chainable. + */ +export const interceptFetchLongviewStatus = (): Cypress.Chainable => { + return cy.intercept('POST', 'https://longview.linode.com/fetch'); +}; + +/** + * Intercepts GET request to fetch Longview clients. + * + * @returns Cypress chainable. + */ +export const interceptGetLongviewClients = (): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('longview/clients*')); +}; diff --git a/packages/manager/cypress/support/intercepts/vlans.ts b/packages/manager/cypress/support/intercepts/vlans.ts new file mode 100644 index 00000000000..77f6cbbfdfc --- /dev/null +++ b/packages/manager/cypress/support/intercepts/vlans.ts @@ -0,0 +1,22 @@ +/** + * @files Cypress intercepts and mocks for VLAN API requests. + */ + +import { apiMatcher } from 'support/util/intercepts'; +import { paginateResponse } from 'support/util/paginate'; + +import type { VLAN } from '@linode/api-v4'; +/** + * Intercepts GET request to fetch VLANs and mocks response. + * + * @param vlans - Array of VLANs with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetVLANs = (vlans: VLAN[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('networking/vlans*'), + paginateResponse(vlans) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/vpc.ts b/packages/manager/cypress/support/intercepts/vpc.ts index 844194960cf..362b263076c 100644 --- a/packages/manager/cypress/support/intercepts/vpc.ts +++ b/packages/manager/cypress/support/intercepts/vpc.ts @@ -2,12 +2,15 @@ * @files Cypress intercepts and mocks for VPC API requests. */ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; import type { Subnet, VPC } from '@linode/api-v4'; -import { makeResponse } from 'support/util/response'; -import { makeErrorResponse } from 'support/util/errors'; + +export const MOCK_DELETE_VPC_ERROR = + 'Before deleting this VPC, you must remove all of its Linodes'; /** * Intercepts GET request to fetch a VPC and mocks response. @@ -89,6 +92,27 @@ export const mockDeleteVPC = (vpcId: number): Cypress.Chainable => { return cy.intercept('DELETE', apiMatcher(`vpcs/${vpcId}`), {}); }; +/** + * Intercepts DELETE request to delete a VPC and mocks an HTTP error response. + * + * @param vpcId - ID of deleted VPC for which to mock response. + * @param errorMessage - Optional error message with which to mock response. + * @param errorCode - Optional error code with which to mock response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockDeleteVPCError = ( + vpcId: number, + errorMessage: string = MOCK_DELETE_VPC_ERROR, + errorCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`/vpcs/${vpcId}`), + makeErrorResponse(errorMessage, errorCode) + ); +}; + /** * Intercepts GET request to get a VPC's subnets and mocks response. * diff --git a/packages/manager/cypress/support/longview.sh b/packages/manager/cypress/support/longview.sh deleted file mode 100755 index 6e5ddb109cb..00000000000 --- a/packages/manager/cypress/support/longview.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/expect -f -# this script is used in the longview test -spawn ssh root@$env(LINODEIP) -sleep 65 -expect "*yes/no*" -send "yes\r" -expect "*assword" -send "$env(LINODEPASSWORD)\r" -expect "*root@localhost:~#" -send "$env(CURLCOMMAND)\r" -expect "*root@localhost:~#" -send "sudo systemctl start longview\r" -expect "*root@localhost:~#" -send "exit\r" -expect "%closed." -sleep 60 -exit -interact diff --git a/packages/manager/cypress/support/scripts/longview/install-longview.sh b/packages/manager/cypress/support/scripts/longview/install-longview.sh new file mode 100755 index 00000000000..87f3cea582d --- /dev/null +++ b/packages/manager/cypress/support/scripts/longview/install-longview.sh @@ -0,0 +1,38 @@ +#!/usr/bin/expect -f +# Connects to a Linode via SSH and installs Longview using a given command. + +set timeout 120 + +# Wait 15 seconds before attempting to connect to the Linode. +# This mitigates a rare issue where connecting too quickly after provisioning and +# booting yields a connection refused error. +sleep 15 + +# SSH into the Linode. +# Disable strict host key checking and pass a null path for the host file so that +# developers do not have their `known_hosts` file modified when running this test, +# preventing rare cases where recycled IPs can trigger a warning that causes the +# test to fail. +spawn ssh root@$env(LINODEIP) -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no + +# Answer SSH password prompt. +expect "*?assword" { + send "$env(LINODEPASSWORD)\r" +} + +# Execute the Longview installation command shown in the Cloud Manager UI. +expect "*root@localhost:~#" { + send "$env(CURLCOMMAND)\r" +} + +# Start Longview, confirm that it is running, and exit. +expect "*root@localhost:~#" { + send "sudo systemctl start longview\r" + send "sudo systemctl status longview\r" +} + +expect "*active (running)*" +send "exit\r" +expect "%closed." +exit +interact diff --git a/packages/manager/cypress/support/ui/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 1beadfd7d23..523de4ed3fa 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -19,6 +19,6 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`); + return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); }, }; diff --git a/packages/manager/cypress/support/ui/autocomplete-popper.ts b/packages/manager/cypress/support/ui/autocomplete-popper.ts deleted file mode 100644 index 65c1a801744..00000000000 --- a/packages/manager/cypress/support/ui/autocomplete-popper.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Autocomplete Popper UI element. - * - * Useful for validating content, filling out forms, etc. that appear within - * a autocomplete popper. - */ -export const autocompletePopper = { - /** - * Finds a autocomplete popper that has the given title. - */ - findByTitle: (title: string): Cypress.Chainable => { - return cy - .document() - .its('body') - .find('[data-qa-autocomplete-popper]') - .findByText(title); - }, -}; diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts new file mode 100644 index 00000000000..f96bb9cc7e3 --- /dev/null +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -0,0 +1,73 @@ +import { getRegionById, getRegionByLabel } from 'support/util/regions'; + +export const autocomplete = { + /** + * Finds a autocomplete popper that has the given title. + */ + find: (): Cypress.Chainable => { + return cy.get('[data-qa-autocomplete] input'); + }, +}; + +/** + * Autocomplete Popper UI element. + * + * Useful for validating content, filling out forms, etc. that appear within + * a autocomplete popper. + */ +export const autocompletePopper = { + /** + * Finds a autocomplete popper that has the given title. + */ + findByTitle: (title: string): Cypress.Chainable => { + return cy + .document() + .its('body') + .find('[data-qa-autocomplete-popper]') + .findByText(title); + }, +}; + +/** + * UI helpers for region selection Autocomplete. + */ +export const regionSelect = { + /** + * Finds and open the region select input. + */ + find: (): Cypress.Chainable => { + return cy.get('[data-testid="region-select"] input'); + }, + + findBySelectedItem: (selectedRegion: string) => { + return cy.get(`[value="${selectedRegion}"]`); + }, + + /** + * Finds a Region Select menu item using the ID of a region. + * + * This assumes that the Region Select menu is already open. + * + * @param regionId - ID of region for which to find Region Select menu item. + * + * @returns Cypress chainable. + */ + findItemByRegionId: (regionId: string) => { + const region = getRegionById(regionId); + return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + }, + + /** + * Finds a Region Select menu item using a region's label. + * + * This assumes that the Region Select menu is already open. + * + * @param regionLabel - Region label. + * + * @returns Cypress chainable. + */ + findItemByRegionLabel: (regionLabel: string) => { + const region = getRegionByLabel(regionLabel); + return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + }, +}; diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index fc7e2abb416..2f299321eda 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -24,7 +24,6 @@ const waitDoubleRerender = () => { cy.wait(500); }; -export const selectRegionString = 'Select a Region'; // List of Routes and validator of the route export const pages = [ { diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 043251017e6..22044a1c082 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -1,6 +1,6 @@ import * as accordion from './accordion'; import * as actionMenu from './action-menu'; -import * as autocompletePopper from './autocomplete-popper'; +import * as autocomplete from './autocomplete'; import * as breadcrumb from './breadcrumb'; import * as buttons from './buttons'; import * as dialog from './dialog'; @@ -20,7 +20,7 @@ import * as userMenu from './user-menu'; export const ui = { ...accordion, ...actionMenu, - ...autocompletePopper, + ...autocomplete, ...breadcrumb, ...buttons, ...dialog, diff --git a/packages/manager/cypress/support/ui/select.ts b/packages/manager/cypress/support/ui/select.ts index a65be2d618c..6d117789272 100644 --- a/packages/manager/cypress/support/ui/select.ts +++ b/packages/manager/cypress/support/ui/select.ts @@ -1,5 +1,3 @@ -import { getRegionById, getRegionByLabel } from 'support/util/regions'; - /** * UI helpers for Enhanced Select component. */ @@ -67,36 +65,3 @@ export const select = { .should('be.visible'); }, }; - -/** - * UI helpers for region selection Enhanced Select. - */ -export const regionSelect = { - /** - * Finds a Region Select menu item using the ID of a region. - * - * This assumes that the Region Select menu is already open. - * - * @param regionId - ID of region for which to find Region Select menu item. - * - * @returns Cypress chainable. - */ - findItemByRegionId: (regionId: string) => { - const region = getRegionById(regionId); - return select.findItemByText(`${region.label} (${region.id})`); - }, - - /** - * Finds a Region Select menu item using a region's label. - * - * This assumes that the Region Select menu is already open. - * - * @param regionLabel - Region label. - * - * @returns Cypress chainable. - */ - findItemByRegionLabel: (regionLabel: string) => { - const region = getRegionByLabel(regionLabel); - return select.findItemByText(`${region.label} (${region.id})`); - }, -}; diff --git a/packages/manager/cypress/support/ui/tab-list.ts b/packages/manager/cypress/support/ui/tab-list.ts index ae3ac1bbd1a..dd4816696dd 100644 --- a/packages/manager/cypress/support/ui/tab-list.ts +++ b/packages/manager/cypress/support/ui/tab-list.ts @@ -1,3 +1,5 @@ +import type { SelectorMatcherOptions } from '@testing-library/cypress'; + /** * Tab list UI element. */ @@ -15,10 +17,14 @@ export const tabList = { * Finds a tab within a tab list by its title. * * @param tabTitle - Title of tab to find. + * @param options - Selector matcher options. * * @returns Cypress chainable. */ - findTabByTitle: (tabTitle: string): Cypress.Chainable => { - return cy.get('[data-reach-tab-list]').findByText(tabTitle); + findTabByTitle: ( + tabTitle: string, + options?: SelectorMatcherOptions + ): Cypress.Chainable => { + return cy.get('[data-reach-tab-list]').findByText(tabTitle, options); }, }; diff --git a/packages/manager/cypress/support/util/api.ts b/packages/manager/cypress/support/util/api.ts index 1225f66a2fd..fd55777be19 100644 --- a/packages/manager/cypress/support/util/api.ts +++ b/packages/manager/cypress/support/util/api.ts @@ -3,6 +3,7 @@ */ import { baseRequest } from '@linode/api-v4'; +import { AxiosHeaders } from 'axios'; // Note: This file is imported by Cypress plugins, and indirectly by Cypress // tests. Because Cypress has not been initiated when plugins are executed, we @@ -26,23 +27,15 @@ export const defaultApiRoot = 'https://api.linode.com/v4'; */ export const configureLinodeApi = (accessToken: string, baseUrl?: string) => { baseRequest.interceptors.request.use((config) => { + const headers = new AxiosHeaders(config.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + // If a base URL is provided, override the request URL - // using the given base URL. Otherwise, evaluate to an empty object so - // we can still use the spread operator later on. - const url = config.url; - const urlOverride = - !baseUrl || !url ? {} : { url: url.replace(defaultApiRoot, baseUrl) }; + // using the given base URL. + if (baseUrl && config.url) { + config.url = config.url.replace(defaultApiRoot, baseUrl); + } - return { - ...config, - headers: { - ...config.headers, - common: { - ...config.headers.common, - authorization: `Bearer ${accessToken}`, - }, - }, - ...urlOverride, - }; + return { ...config, headers }; }); }; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index e94a8263402..d54ca5b2545 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -1,4 +1,5 @@ import { createLinode, Devices, getLinodeConfigs } from '@linode/api-v4'; +import type { CreateLinodeRequest } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeStatus, pollLinodeDiskStatuses } from 'support/util/polling'; @@ -8,14 +9,21 @@ import { chooseRegion } from 'support/util/regions'; import type { Config, Linode, LinodeConfigCreationData } from '@linode/api-v4'; /** - * Creates a Linode and waits for it to be in "running" state. + * 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. */ -export const createAndBootLinode = async (): Promise => { - const createPayload = createLinodeRequestFactory.build({ +export const createAndBootLinode = async ( + createPayload?: Partial +): Promise => { + const payload = createLinodeRequestFactory.build({ label: randomLabel(), region: chooseRegion().id, + ...(createPayload ?? {}), }); - const linode = await createLinode(createPayload); + const linode = await createLinode(payload); await pollLinodeStatus( linode.id, diff --git a/packages/manager/cypress/support/util/paginate.ts b/packages/manager/cypress/support/util/paginate.ts index 2135fcd63c6..a5f7a43e794 100644 --- a/packages/manager/cypress/support/util/paginate.ts +++ b/packages/manager/cypress/support/util/paginate.ts @@ -1,6 +1,5 @@ -import { Response } from 'support/util/response'; - import type { ResourcePage } from '@linode/api-v4/types'; +import type { StaticResponse } from 'cypress/types/net-stubbing'; /** * Paginated data. @@ -87,7 +86,7 @@ export const paginateResponse = ( statusCode: number = 200, page: number = 1, totalPages: number = 1 -): Response => { +): StaticResponse => { const dataArray = Array.isArray(data) ? data : [data]; return { body: { diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index a89c057ef4c..a16841f2f0f 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -138,7 +138,6 @@ export const chooseRegions = (count: number): Region[] => { ); } const overrideRegion = getOverrideRegion(); - return new Array(count).fill(null).reduce((acc: Region[], _cur, index) => { const chosenRegion: Region = ((): Region => { if (index === 0 && overrideRegion) { @@ -146,8 +145,7 @@ export const chooseRegions = (count: number): Region[] => { } // Get an array of regions that have not already been selected. const unusedRegions = regions.filter( - (regionA: Region) => - !!regions.find((regionB: Region) => regionA.id !== regionB.id) + (region: Region) => !acc.includes(region) ); return randomItem(unusedRegions); })(); diff --git a/packages/manager/cypress/support/util/response.ts b/packages/manager/cypress/support/util/response.ts index 963c3365c4d..ddbbcfd2149 100644 --- a/packages/manager/cypress/support/util/response.ts +++ b/packages/manager/cypress/support/util/response.ts @@ -2,13 +2,7 @@ * @file Utility functions to easily create HTTP response objects for Cypress tests. */ -/** - * Object describing an HTTP response. - */ -export interface Response { - body: any; - statusCode: number; -} +import type { StaticResponse } from 'cypress/types/net-stubbing'; /** * Creates an HTTP response object with the given body data. @@ -21,7 +15,7 @@ export interface Response { export const makeResponse = ( body: any = {}, statusCode: number = 200 -): Response => { +): Partial => { return { body, statusCode, diff --git a/packages/manager/package.json b/packages/manager/package.json index 001a6b6019d..92694758152 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.107.0", + "version": "1.108.0", "private": true, "bugs": { "url": "https://github.com/Linode/manager/issues" @@ -24,8 +24,7 @@ "@reach/tabs": "^0.10.5", "@sentry/react": "^7.57.0", "algoliasearch": "^4.14.3", - "axios": "~0.21.4", - "axios-mock-adapter": "^1.15.0", + "axios": "~1.6.1", "braintree-web": "^3.92.2", "chart.js": "~2.9.4", "chartjs-adapter-luxon": "^0.2.1", @@ -66,6 +65,7 @@ "react-select": "~3.1.0", "react-vnc": "^0.5.3", "react-waypoint": "~9.0.2", + "recharts": "^2.9.3", "recompose": "^0.30.0", "redux": "^4.0.4", "redux-thunk": "^2.3.0", @@ -90,7 +90,7 @@ "lint": "yarn run eslint . --ext .js,.ts,.tsx --quiet", "build": "node scripts/prebuild.mjs && vite build", "precommit": "lint-staged && yarn typecheck", - "test": "jest --color", + "test": "vitest run", "test:debug": "node --inspect-brk scripts/test.js --runInBand", "storybook": "storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", @@ -99,7 +99,9 @@ "cy:e2e": "cypress run --headless -b chrome", "cy:debug": "cypress open --e2e", "cy:rec-snap": "cypress run --headless -b chrome --env visualRegMode=record --spec ./cypress/integration/**/*visual*.spec.ts", - "typecheck": "tsc" + "typecheck": "tsc", + "coverage": "vitest run --coverage && open coverage/index.html", + "coverage:summary": "vitest run --coverage.enabled --reporter=junit --coverage.reporter=json-summary" }, "lint-staged": { "*.{ts,tsx,js}": [ @@ -120,7 +122,6 @@ "@storybook/react-vite": "^7.5.2", "@storybook/theming": "~7.5.2", "@swc/core": "^1.3.1", - "@swc/jest": "^0.2.22", "@testing-library/cypress": "^10.0.0", "@testing-library/jest-dom": "~5.11.3", "@testing-library/react": "~10.4.9", @@ -133,8 +134,8 @@ "@types/enzyme": "^3.9.3", "@types/he": "^1.1.0", "@types/highlight.js": "~10.1.0", - "@types/jest": "^26.0.13", - "@types/jest-axe": "^3.2.1", + "@types/jest-axe": "^3.5.7", + "@types/jsdom": "^21.1.4", "@types/jspdf": "^1.3.3", "@types/luxon": "^3.2.0", "@types/markdown-it": "^10.0.2", @@ -163,10 +164,12 @@ "@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.1.1", "@vitejs/plugin-react-swc": "^3.4.0", + "@vitest/coverage-v8": "^0.34.6", + "@vitest/ui": "^0.34.6", "chai-string": "^1.5.0", "chalk": "^5.2.0", "css-mediaquery": "^0.1.2", - "cypress": "^13.4.0", + "cypress": "^13.5.0", "cypress-axe": "^1.0.0", "cypress-file-upload": "^5.0.7", "cypress-real-events": "^1.11.0", @@ -177,7 +180,6 @@ "eslint": "^6.8.0", "eslint-config-prettier": "~8.1.0", "eslint-plugin-cypress": "^2.11.3", - "eslint-plugin-jest": "^23.8.2", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-node": "^11.0.0", "eslint-plugin-perfectionist": "^1.4.0", @@ -192,11 +194,8 @@ "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", "glob": "^10.3.1", - "jest": "~26.4.2", - "jest-axe": "^3.3.0", - "jest-junit": "^10.0.0", - "jest-sonar-reporter": "^2.0.0", - "jest_workaround": "^0.1.10", + "jest-axe": "^8.0.0", + "jsdom": "^22.1.0", "lint-staged": "^13.2.2", "msw": "~1.3.2", "prettier": "~2.2.1", @@ -205,97 +204,9 @@ "serve": "^14.0.1", "storybook": "~7.5.2", "storybook-dark-mode": "^3.0.1", - "vite": "^4.4.11", - "vite-plugin-svgr": "^3.2.0" - }, - "jest": { - "testResultsProcessor": "jest-sonar-reporter", - "roots": [ - "/src" - ], - "collectCoverageFrom": [ - "src/**/*.{js,jsx,ts,tsx}", - "!src/**/*.stories.{js,jsx,ts,tsx}" - ], - "coverageReporters": [ - "clover", - "json", - "text", - "lcov", - "cobertura" - ], - "setupFilesAfterEnv": [ - "/src/testSetup.ts" - ], - "testMatch": [ - "/src/**/__tests__/**/*.ts?(x)", - "/src/**/?(*.)(spec|test).ts?(x)" - ], - "testEnvironment": "jsdom", - "testURL": "https://api.linode.com", - "transform": { - "^.+\\.tsx?$": [ - "@swc/jest", - { - "$schema": "http://json.schemastore.org/swcrc", - "jsc": { - "transform": { - "optimizer": { - "globals": { - "vars": { - "import.meta.env": "{}" - } - } - } - }, - "experimental": { - "plugins": [ - [ - "jest_workaround", - {} - ] - ] - } - }, - "module": { - "type": "commonjs" - } - } - ], - "^.+\\.css$": "/config/jest/cssTransform.js", - "^(?!.*\\.(js|jsx|css|json)$)": "/config/jest/fileTransform.js" - }, - "transformIgnorePatterns": [ - "[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$" - ], - "moduleNameMapper": { - "\\.svg$": "/src/components/NullComponent", - "^react-native$": "react-native-web", - "ramda": "ramda/src/index.js", - "^src/(.*)": "/src/$1", - "(.*)\\.css\\?raw$": "$1.css", - "@linode/api-v4/lib(.*)$": "/../api-v4/src/$1", - "@linode/validation/lib(.*)$": "/../validation/src/$1", - "@linode/api-v4": "/../api-v4/src/index.ts", - "@linode/validation": "/../validation/src/index.ts" - }, - "moduleFileExtensions": [ - "mjs", - "web.ts", - "ts", - "web.tsx", - "tsx", - "web.js", - "js", - "web.jsx", - "jsx", - "json", - "node" - ], - "reporters": [ - "default", - "jest-junit" - ] + "vite": "^4.5.0", + "vite-plugin-svgr": "^3.2.0", + "vitest": "^0.34.6" }, "browserslist": [ ">1%", diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 9d71807abe9..6a2d738828e 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -1,8 +1,6 @@ import '@reach/tabs/styles.css'; import { ErrorBoundary } from '@sentry/react'; -import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment, @@ -10,34 +8,18 @@ import { } from 'src/components/DocumentTitle'; import withFeatureFlagConsumer from 'src/containers/withFeatureFlagConsumer.container'; import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.container'; -import { EventWithStore, events$ } from 'src/events'; import TheApplicationIsOnFire from 'src/features/TheApplicationIsOnFire'; import { GoTo } from './GoTo'; import { MainContent } from './MainContent'; import { SplashScreen } from './components/SplashScreen'; -import { ADOBE_ANALYTICS_URL, NUM_ADOBE_SCRIPTS } from './constants'; -import { reportException } from './exceptionReporting'; +import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; +import { useEventHandlers } from './hooks/useEventHandlers'; +import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import { useInitialRequests } from './hooks/useInitialRequests'; -import { loadScript } from './hooks/useScript'; -import { oauthClientsEventHandler } from './queries/accountOAuth'; -import { databaseEventsHandler } from './queries/databases'; -import { domainEventsHandler } from './queries/domains'; -import { firewallEventsHandler } from './queries/firewalls'; -import { imageEventsHandler } from './queries/images'; -import { - diskEventHandler, - linodeEventsHandler, -} from './queries/linodes/events'; -import { nodebalanacerEventHandler } from './queries/nodebalancers'; -import { useMutatePreferences, usePreferences } from './queries/preferences'; -import { sshKeyEventHandler } from './queries/profile'; -import { supportTicketEventHandler } from './queries/support'; -import { tokenEventHandler } from './queries/tokens'; -import { volumeEventsHandler } from './queries/volumes'; +import { useNewRelic } from './hooks/useNewRelic'; +import { useToastNotifications } from './hooks/useToastNotifications'; import { useSetupFeatureFlags } from './useSetupFeatureFlags'; -import { getNextThemeValue } from './utilities/theme'; -import { isOSMac } from './utilities/userAgent'; // Ensure component's display name is 'App' export const App = () => ; @@ -45,219 +27,17 @@ export const App = () => ; const BaseApp = withDocumentTitleProvider( withFeatureFlagProvider( withFeatureFlagConsumer(() => { - const history = useHistory(); - - const { data: preferences } = usePreferences(); - const { mutateAsync: updateUserPreferences } = useMutatePreferences(); - const { isLoading } = useInitialRequests(); const { areFeatureFlagsLoading } = useSetupFeatureFlags(); - const { enqueueSnackbar } = useSnackbar(); - - const [goToOpen, setGoToOpen] = React.useState(false); - - const theme = preferences?.theme; - const keyboardListener = React.useCallback( - (event: KeyboardEvent) => { - const letterForThemeShortcut = 'D'; - const letterForGoToOpen = 'K'; - const modifierKey = isOSMac ? 'ctrlKey' : 'altKey'; - if (event[modifierKey] && event.shiftKey) { - switch (event.key) { - case letterForThemeShortcut: - const currentTheme = theme; - const newTheme = getNextThemeValue(currentTheme); - - updateUserPreferences({ theme: newTheme }); - break; - case letterForGoToOpen: - setGoToOpen(!goToOpen); - break; - } - } - }, - [goToOpen, theme, updateUserPreferences] - ); - - React.useEffect(() => { - if ( - import.meta.env.PROD && - !import.meta.env.REACT_APP_DISABLE_NEW_RELIC - ) { - loadScript('/new-relic.js'); - } - - // Load Adobe Analytics Launch Script - if (!!ADOBE_ANALYTICS_URL) { - loadScript(ADOBE_ANALYTICS_URL, { location: 'head' }) - .then((data) => { - const adobeScriptTags = document.querySelectorAll( - 'script[src^="https://assets.adobedtm.com/"]' - ); - // Log an error; if the promise resolved, the _satellite object and 3 Adobe scripts should be present in the DOM. - if ( - data.status !== 'ready' || - !(window as any)._satellite || - adobeScriptTags.length !== NUM_ADOBE_SCRIPTS - ) { - reportException( - 'Adobe Analytics error: Not all Adobe Launch scripts and extensions were loaded correctly; analytics cannot be sent.' - ); - } - }) - .catch(() => { - // Do nothing; a user may have analytics script requests blocked. - }); - } - }, []); - - React.useEffect(() => { - /** - * Send pageviews - */ - return history.listen(({ pathname }) => { - // Send Adobe Analytics page view events - if ((window as any)._satellite) { - (window as any)._satellite.track('page view', { - url: pathname, - }); - } - }); - }, [history]); - - React.useEffect(() => { - /** - * Allow an Easter egg for toggling the theme with - * a key combination - */ - // eslint-disable-next-line - document.addEventListener('keydown', keyboardListener); - return () => { - document.removeEventListener('keydown', keyboardListener); - }; - }, [keyboardListener]); - - /* - * We want to listen for migration events side-wide - * It's unpredictable when a migration is going to happen. It could take - * hours and it could take days. We want to notify to the user when it happens - * and then update the Linodes in LinodesDetail and LinodesLanding - */ - const handleMigrationEvent = React.useCallback( - ({ event }: EventWithStore) => { - const { entity: migratedLinode } = event; - if ( - event.action === 'linode_migrate' && - event.status === 'finished' - ) { - enqueueSnackbar( - `Linode ${migratedLinode!.label} migrated successfully.`, - { - variant: 'success', - } - ); - } - - if (event.action === 'linode_migrate' && event.status === 'failed') { - enqueueSnackbar( - `Linode ${migratedLinode!.label} migration failed.`, - { - variant: 'error', - } - ); - } - }, - [enqueueSnackbar] - ); - - React.useEffect(() => { - const eventHandlers: { - filter: (event: EventWithStore) => boolean; - handler: (event: EventWithStore) => void; - }[] = [ - { - filter: ({ event }) => - event.action.startsWith('database') && !event._initial, - handler: databaseEventsHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('domain') && - !event._initial && - event.entity !== null, - handler: domainEventsHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('volume') && !event._initial, - handler: volumeEventsHandler, - }, - { - filter: ({ event }) => - (event.action.startsWith('image') || - event.action === 'disk_imagize') && - !event._initial, - handler: imageEventsHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('token') && !event._initial, - handler: tokenEventHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('user_ssh_key') && !event._initial, - handler: sshKeyEventHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('firewall') && !event._initial, - handler: firewallEventsHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('nodebalancer') && !event._initial, - handler: nodebalanacerEventHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('oauth_client') && !event._initial, - handler: oauthClientsEventHandler, - }, - { - filter: ({ event }) => - (event.action.startsWith('linode') || - event.action.startsWith('backups')) && - !event._initial, - handler: linodeEventsHandler, - }, - { - filter: ({ event }) => - event.action.startsWith('ticket') && !event._initial, - handler: supportTicketEventHandler, - }, - { - filter: ({ event }) => - !event._initial && ['linode_migrate'].includes(event.action), - handler: handleMigrationEvent, - }, - { - filter: ({ event }) => - event.action.startsWith('disk') && !event._initial, - handler: diskEventHandler, - }, - ]; + const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); - const subscriptions = eventHandlers.map(({ filter, handler }) => - events$.filter(filter).subscribe(handler) - ); + useEventHandlers(); + useToastNotifications(); - return () => { - subscriptions.forEach((sub) => sub.unsubscribe()); - }; - }, [handleMigrationEvent]); + useAdobeAnalytics(); + useNewRelic(); if (isLoading || areFeatureFlagsLoading) { return ; @@ -277,7 +57,6 @@ const BaseApp = withDocumentTitleProvider( setGoToOpen(false)} open={goToOpen} /> - {/** Update the LD client with the user's id as soon as we know it */} diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index dab1dd692da..c0594e97b78 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -10,7 +10,7 @@ import { Box } from 'src/components/Box'; import { MainContentBanner } from 'src/components/MainContentBanner'; import { MaintenanceScreen } from 'src/components/MaintenanceScreen'; import { NotFound } from 'src/components/NotFound'; -import { SideMenu } from 'src/components/SideMenu'; +import { SideMenu } from 'src/components/PrimaryNav/SideMenu'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useDialogContext } from 'src/context/useDialogContext'; import { Footer } from 'src/features/Footer'; @@ -19,7 +19,6 @@ import { notificationContext, useNotificationContext, } from 'src/features/NotificationCenter/NotificationContext'; -import { ToastNotifications } from 'src/features/ToastNotifications/ToastNotifications'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; @@ -98,11 +97,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, }, switchWrapper: { - '& .mlSidebar': { - [theme.breakpoints.up('lg')]: { - paddingRight: `0 !important`, - }, - }, '& > .MuiGrid-container': { maxWidth: theme.breakpoints.values.lg, width: '100%', @@ -379,7 +373,6 @@ export const MainContent = () => {