From 49d8fa90b1f80bacf468b820b2caca8406edb894 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 17 Dec 2024 00:27:34 -0800 Subject: [PATCH] allow .storybook (12k) --- .dockerignore | 1 - .github/workflows/superset-frontend.yml | 103 +++- Dockerfile | 4 + .../databases/DatabaseModal/index.1.test.tsx | 549 ++++++++++++++++++ .../{index.test.tsx => index.2.test.tsx} | 209 ------- .../databases/DatabaseModal/index.3.test.tsx | 549 ++++++++++++++++++ 6 files changed, 1181 insertions(+), 234 deletions(-) create mode 100644 superset-frontend/src/features/databases/DatabaseModal/index.1.test.tsx rename superset-frontend/src/features/databases/DatabaseModal/{index.test.tsx => index.2.test.tsx} (87%) create mode 100644 superset-frontend/src/features/databases/DatabaseModal/index.3.test.tsx diff --git a/.dockerignore b/.dockerignore index 31c873f0073f9..b650f22c10806 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,7 +34,6 @@ **/*.sqllite **/*.swp **/.terser-plugin-cache/ -**/.storybook/ **/node_modules/ tests/ diff --git a/.github/workflows/superset-frontend.yml b/.github/workflows/superset-frontend.yml index 6c4bad6c1dc15..94030a35e5e43 100644 --- a/.github/workflows/superset-frontend.yml +++ b/.github/workflows/superset-frontend.yml @@ -1,4 +1,4 @@ -name: Frontend +name: "Frontend Build CI (unit tests, linting & sanity checks)" on: push: @@ -21,7 +21,6 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false - submodules: recursive - name: Check for file changes id: check @@ -35,6 +34,7 @@ jobs: with: dockerhub-user: ${{ secrets.DOCKERHUB_USER }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + build: "true" - name: Setup supersetbot if: steps.check.outputs.frontend @@ -47,21 +47,32 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="superset-node-${{ github.sha }}" - docker buildx build --target superset-node-ci -t $TAG . + + supersetbot docker \ + --load \ + --preset superset-node-ci \ + --platform "linux/amd64" \ + --extra-flags "--tag $TAG" + docker save $TAG | gzip > superset-node.tar.gz - name: Upload Docker Image Artifact - uses: actions/upload-artifact@v3 + if: steps.check.outputs.frontend + uses: actions/upload-artifact@v4 with: name: docker-image path: superset-node.tar.gz - unit-tests: - needs: frontend-docker-build - runs-on: ubuntu-latest + sharded-jest-tests: + needs: frontend-build + if: needs.frontend-build.result == 'success' + strategy: + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8] + runs-on: ubuntu-22.04 steps: - name: Download Docker Image Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: docker-image @@ -70,15 +81,39 @@ jobs: docker load -i superset-node.tar.gz - name: npm run test with coverage - working-directory: ./superset-frontend - run: | - docker run --rm superset-node-${{ github.sha }} bash -c \ - "npm run test -- --coverage --silent" - - name: superset-ui/core coverage - working-directory: ./superset-frontend run: | - docker run --rm superset-node-${{ github.sha }} bash -c \ - "npm run core:cover" + mkdir -p ${{ github.workspace }}/coverage + docker run \ + --rm superset-node-${{ github.sha }} \ + -v ${{ github.workspace }}/coverage:/app/superset-frontend/coverage \ + bash -c \ + 'npm run test -- --coverage --silent --shard=${{ matrix.shard }}/8 --coverageReporters="json-summary" && find ./coverage' + find ${{ github.workspace }}/coverage + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-artifacts-${{ matrix.shard }} + path: coverage/ + + report-coverage: + needs: [sharded-jest-tests] + if: needs.frontend-build.result == 'success' + runs-on: ubuntu-22.04 + steps: + - name: Download Docker Image Artifact + uses: actions/download-artifact@v4 + with: + name: coverage-artifacts + merge-multiple: true + pattern: coverage/* + + - name: Show files + run: find . + + - name: Merge Code Coverage + run: npx nyc merge coverage/ merged-output/merged-coverage.json + - name: Upload code coverage uses: codecov/codecov-action@v5 with: @@ -86,12 +121,32 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} verbose: true + core-cover: + needs: frontend-build + if: needs.frontend-build.result == 'success' + runs-on: ubuntu-22.04 + steps: + - name: Download Docker Image Artifact + uses: actions/download-artifact@v4 + with: + name: docker-image + + - name: Load Docker Image + run: | + docker load -i superset-node.tar.gz + + - name: superset-ui/core coverage + run: | + docker run --rm superset-node-${{ github.sha }} bash -c \ + "npm run core:cover" + lint-frontend: - needs: frontend-docker-build - runs-on: ubuntu-latest + needs: frontend-build + if: needs.frontend-build.result == 'success' + runs-on: ubuntu-22.04 steps: - name: Download Docker Image Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: docker-image @@ -102,18 +157,19 @@ jobs: - name: eslint run: | docker run --rm superset-node-${{ github.sha }} bash -c \ - "npm run eslint -- . --quiet" + "npm i && npm run eslint -- . --quiet" - name: tsc run: | docker run --rm superset-node-${{ github.sha }} bash -c \ "npm run type" validate-frontend: - needs: frontend-docker-build - runs-on: ubuntu-latest + needs: frontend-build + if: needs.frontend-build.result == 'success' + runs-on: ubuntu-22.04 steps: - name: Download Docker Image Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: docker-image @@ -126,7 +182,6 @@ jobs: docker run --rm superset-node-${{ github.sha }} bash -c \ "npm run plugins:build" - name: Build plugins Storybook - working-directory: ./superset-frontend run: | docker run --rm superset-node-${{ github.sha }} bash -c \ "npm run plugins:build-storybook" diff --git a/Dockerfile b/Dockerfile index 91af4312c60a8..7297ad139337b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,10 @@ RUN mkdir -p /app/superset/static/assets \ /app/superset/translations # Mount package files and install dependencies if not in dev mode +# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces +# ideally we'd COPY only their package.json. Here npm ci will be cached as long +# as the full content of these folders don't change, yielding a decent cache reuse rate. +# Note that's it's not possible selectively COPY of mount using blobs. RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \ --mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \ --mount=type=cache,target=/root/.cache \ diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.1.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.1.test.tsx new file mode 100644 index 0000000000000..025bbf0227b25 --- /dev/null +++ b/superset-frontend/src/features/databases/DatabaseModal/index.1.test.tsx @@ -0,0 +1,549 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: These tests should be made atomic in separate files + +import fetchMock from 'fetch-mock'; +import userEvent from '@testing-library/user-event'; +import { render, screen, cleanup, act } from 'spec/helpers/testing-library'; +import DatabaseModal from './index'; + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: () => true, +})); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +const dbProps = { + show: true, + database_name: 'my database', + sqlalchemy_uri: 'postgres://superset:superset@something:1234/superset', + onHide: () => {}, +}; + +const DATABASE_FETCH_ENDPOINT = 'glob:*/api/v1/database/10'; +const AVAILABLE_DB_ENDPOINT = 'glob:*/api/v1/database/available*'; +const VALIDATE_PARAMS_ENDPOINT = 'glob:*/api/v1/database/validate_parameters*'; +const DATABASE_CONNECT_ENDPOINT = 'glob:*/api/v1/database/'; + +fetchMock.post(DATABASE_CONNECT_ENDPOINT, { + id: 10, + result: { + configuration_method: 'sqlalchemy_form', + database_name: 'Other2', + driver: 'apsw', + expose_in_sqllab: true, + extra: '{"allows_virtual_table_explore":true}', + sqlalchemy_uri: 'gsheets://', + }, + json: 'foo', +}); + +fetchMock.config.overwriteRoutes = true; +fetchMock.get(DATABASE_FETCH_ENDPOINT, { + result: { + id: 10, + database_name: 'my database', + expose_in_sqllab: false, + allow_ctas: false, + allow_cvas: false, + configuration_method: 'sqlalchemy_form', + }, +}); +fetchMock.mock(AVAILABLE_DB_ENDPOINT, { + databases: [ + { + available_drivers: ['psycopg2'], + default_driver: 'psycopg2', + engine: 'postgresql', + name: 'PostgreSQL', + parameters: { + properties: { + database: { + description: 'Database name', + type: 'string', + }, + encryption: { + description: 'Use an encrypted connection to the database', + type: 'boolean', + }, + host: { + description: 'Hostname or IP address', + type: 'string', + }, + password: { + description: 'Password', + nullable: true, + type: 'string', + }, + port: { + description: 'Database port', + format: 'int32', + maximum: 65536, + minimum: 0, + type: 'integer', + }, + query: { + additionalProperties: {}, + description: 'Additional parameters', + type: 'object', + }, + ssh: { + description: 'Create SSH Tunnel', + type: 'boolean', + }, + username: { + description: 'Username', + nullable: true, + type: 'string', + }, + }, + required: ['database', 'host', 'port', 'username'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'postgresql://user:password@host:port/dbname[?key=value&key=value...]', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['rest'], + engine: 'presto', + name: 'Presto', + preferred: true, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['mysqldb'], + default_driver: 'mysqldb', + engine: 'mysql', + name: 'MySQL', + parameters: { + properties: { + database: { + description: 'Database name', + type: 'string', + }, + encryption: { + description: 'Use an encrypted connection to the database', + type: 'boolean', + }, + host: { + description: 'Hostname or IP address', + type: 'string', + }, + password: { + description: 'Password', + nullable: true, + type: 'string', + }, + port: { + description: 'Database port', + format: 'int32', + maximum: 65536, + minimum: 0, + type: 'integer', + }, + query: { + additionalProperties: {}, + description: 'Additional parameters', + type: 'object', + }, + username: { + description: 'Username', + nullable: true, + type: 'string', + }, + }, + required: ['database', 'host', 'port', 'username'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'mysql://user:password@host:port/dbname[?key=value&key=value...]', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['pysqlite'], + engine: 'sqlite', + name: 'SQLite', + preferred: true, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['rest'], + engine: 'druid', + name: 'Apache Druid', + preferred: false, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['bigquery'], + default_driver: 'bigquery', + engine: 'bigquery', + name: 'Google BigQuery', + parameters: { + properties: { + credentials_info: { + description: 'Contents of BigQuery JSON credentials.', + type: 'string', + 'x-encrypted-extra': true, + }, + query: { + type: 'object', + }, + }, + type: 'object', + }, + preferred: false, + sqlalchemy_uri_placeholder: 'bigquery://{project_id}', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: true, + }, + }, + { + available_drivers: ['rest'], + default_driver: 'apsw', + engine: 'gsheets', + name: 'Google Sheets', + preferred: false, + engine_information: { + supports_file_upload: false, + disable_ssh_tunneling: true, + }, + }, + { + available_drivers: ['connector'], + default_driver: 'connector', + engine: 'databricks', + name: 'Databricks', + parameters: { + properties: { + access_token: { + type: 'string', + }, + database: { + type: 'string', + }, + host: { + type: 'string', + }, + http_path: { + type: 'string', + }, + port: { + format: 'int32', + type: 'integer', + }, + }, + required: ['access_token', 'database', 'host', 'http_path', 'port'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'databricks+connector://token:{access_token}@{host}:{port}/{database_name}', + }, + ], +}); +fetchMock.post(VALIDATE_PARAMS_ENDPOINT, { + message: 'OK', +}); + +describe('DatabaseModal', () => { + const renderAndWait = async () => { + const mounted = act(async () => { + render(, { + useRedux: true, + }); + }); + + return mounted; + }; + + beforeEach(async () => { + await renderAndWait(); + }); + + afterEach(cleanup); + + describe('Functional: Create new database', () => { + test('directs databases to the appropriate form (dynamic vs. SQL Alchemy)', async () => { + // ---------- Dynamic example (3-step form) + // Click the PostgreSQL button to enter the dynamic form + const postgreSQLButton = screen.getByRole('button', { + name: /postgresql/i, + }); + userEvent.click(postgreSQLButton); + + // Dynamic form has 3 steps, seeing this text means the dynamic form is present + const dynamicFormStepText = screen.getByText(/step 2 of 3/i); + + expect(dynamicFormStepText).toBeVisible(); + + // ---------- SQL Alchemy example (2-step form) + // Click the back button to go back to step 1, + // then click the SQLite button to enter the SQL Alchemy form + const backButton = screen.getByRole('button', { name: /back/i }); + userEvent.click(backButton); + + const sqliteButton = screen.getByRole('button', { + name: /sqlite/i, + }); + userEvent.click(sqliteButton); + + // SQL Alchemy form has 2 steps, seeing this text means the SQL Alchemy form is present + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const sqlAlchemyFormStepText = screen.getByText(/step 2 of 2/i); + + expect(sqlAlchemyFormStepText).toBeVisible(); + }); + + describe('SQL Alchemy form flow', () => { + test('enters step 2 of 2 when proper database is selected', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + }); + + test('runs fetchResource when "Connect" is clicked', () => { + /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- + + // Mock useSingleViewResource + const mockUseSingleViewResource = jest.fn(); + mockUseSingleViewResource.mockImplementation(useSingleViewResource); + + const { fetchResource } = mockUseSingleViewResource('database'); + + // Invalid hook call? + userEvent.click(screen.getByRole('button', { name: 'Connect' })); + expect(fetchResource).toHaveBeenCalled(); + + The line below makes the linter happy */ + expect.anything(); + }); + + describe('step 2 component interaction', () => { + test('properly interacts with textboxes', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const dbNametextBox = screen.getByTestId('database-name-input'); + expect(dbNametextBox).toHaveValue('SQLite'); + + userEvent.type(dbNametextBox, 'Different text'); + expect(dbNametextBox).toHaveValue('SQLiteDifferent text'); + + const sqlAlchemyURItextBox = screen.getByTestId( + 'sqlalchemy-uri-input', + ); + expect(sqlAlchemyURItextBox).toHaveValue(''); + + userEvent.type(sqlAlchemyURItextBox, 'Different text'); + expect(sqlAlchemyURItextBox).toHaveValue('Different text'); + }); + + test('runs testDatabaseConnection when "TEST CONNECTION" is clicked', () => { + /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- + + // Mock testDatabaseConnection + const mockTestDatabaseConnection = jest.fn(); + mockTestDatabaseConnection.mockImplementation(testDatabaseConnection); + + userEvent.click( + screen.getByRole('button', { + name: /test connection/i, + }), + ); + + expect(mockTestDatabaseConnection).toHaveBeenCalled(); + + The line below makes the linter happy */ + expect.anything(); + }); + }); + + describe('SSH Tunnel Form interaction', () => { + test('properly interacts with SSH Tunnel form textboxes for dynamic form', async () => { + userEvent.click( + screen.getByRole('button', { + name: /postgresql/i, + }), + ); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelServerAddressInput = screen.getByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).toHaveValue(''); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + const SSHTunnelServerPortInput = screen.getByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).toHaveValue(null); + userEvent.type(SSHTunnelServerPortInput, '22'); + expect(SSHTunnelServerPortInput).toHaveValue(22); + const SSHTunnelUsernameInput = screen.getByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).toHaveValue(''); + userEvent.type(SSHTunnelUsernameInput, 'test'); + expect(SSHTunnelUsernameInput).toHaveValue('test'); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).toHaveValue(''); + userEvent.type(SSHTunnelPasswordInput, 'pass'); + expect(SSHTunnelPasswordInput).toHaveValue('pass'); + }); + + test('properly interacts with SSH Tunnel form textboxes', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelServerAddressInput = screen.getByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).toHaveValue(''); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + const SSHTunnelServerPortInput = screen.getByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).toHaveValue(null); + userEvent.type(SSHTunnelServerPortInput, '22'); + expect(SSHTunnelServerPortInput).toHaveValue(22); + const SSHTunnelUsernameInput = screen.getByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).toHaveValue(''); + userEvent.type(SSHTunnelUsernameInput, 'test'); + expect(SSHTunnelUsernameInput).toHaveValue('test'); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).toHaveValue(''); + userEvent.type(SSHTunnelPasswordInput, 'pass'); + expect(SSHTunnelPasswordInput).toHaveValue('pass'); + }); + + test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + expect(SSHTunnelingToggle).toBeVisible(); + const SSHTunnelServerAddressInput = screen.queryByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).not.toBeInTheDocument(); + const SSHTunnelServerPortInput = screen.queryByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).not.toBeInTheDocument(); + const SSHTunnelUsernameInput = screen.queryByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).not.toBeInTheDocument(); + const SSHTunnelPasswordInput = screen.queryByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).not.toBeInTheDocument(); + }); + + test('If user changes the login method, the inputs change', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelUsePasswordInput = screen.getByTestId( + 'ssh-tunnel-use_password-radio', + ); + expect(SSHTunnelUsePasswordInput).toBeVisible(); + const SSHTunnelUsePrivateKeyInput = screen.getByTestId( + 'ssh-tunnel-use_private_key-radio', + ); + expect(SSHTunnelUsePrivateKeyInput).toBeVisible(); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + // By default, we use Password as login method + expect(SSHTunnelPasswordInput).toBeVisible(); + // Change the login method to use private key + userEvent.click(SSHTunnelUsePrivateKeyInput); + const SSHTunnelPrivateKeyInput = screen.getByTestId( + 'ssh-tunnel-private_key-input', + ); + expect(SSHTunnelPrivateKeyInput).toBeVisible(); + const SSHTunnelPrivateKeyPasswordInput = screen.getByTestId( + 'ssh-tunnel-private_key_password-input', + ); + expect(SSHTunnelPrivateKeyPasswordInput).toBeVisible(); + }); + }); + }); + }); +}); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.2.test.tsx similarity index 87% rename from superset-frontend/src/features/databases/DatabaseModal/index.test.tsx rename to superset-frontend/src/features/databases/DatabaseModal/index.2.test.tsx index bd3eb3bec9796..7fee254d6e2cc 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.2.test.tsx @@ -1140,215 +1140,6 @@ describe('DatabaseModal', () => { expect(sqlAlchemyFormStepText).toBeVisible(); }); - describe('SQL Alchemy form flow', () => { - test('enters step 2 of 2 when proper database is selected', async () => { - userEvent.click( - screen.getByRole('button', { - name: /sqlite/i, - }), - ); - - expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); - }); - - test('runs fetchResource when "Connect" is clicked', () => { - /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- - - // Mock useSingleViewResource - const mockUseSingleViewResource = jest.fn(); - mockUseSingleViewResource.mockImplementation(useSingleViewResource); - - const { fetchResource } = mockUseSingleViewResource('database'); - - // Invalid hook call? - userEvent.click(screen.getByRole('button', { name: 'Connect' })); - expect(fetchResource).toHaveBeenCalled(); - - The line below makes the linter happy */ - expect.anything(); - }); - - describe('step 2 component interaction', () => { - test('properly interacts with textboxes', async () => { - userEvent.click( - screen.getByRole('button', { - name: /sqlite/i, - }), - ); - - expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); - const dbNametextBox = screen.getByTestId('database-name-input'); - expect(dbNametextBox).toHaveValue('SQLite'); - - userEvent.type(dbNametextBox, 'Different text'); - expect(dbNametextBox).toHaveValue('SQLiteDifferent text'); - - const sqlAlchemyURItextBox = screen.getByTestId( - 'sqlalchemy-uri-input', - ); - expect(sqlAlchemyURItextBox).toHaveValue(''); - - userEvent.type(sqlAlchemyURItextBox, 'Different text'); - expect(sqlAlchemyURItextBox).toHaveValue('Different text'); - }); - - test('runs testDatabaseConnection when "TEST CONNECTION" is clicked', () => { - /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- - - // Mock testDatabaseConnection - const mockTestDatabaseConnection = jest.fn(); - mockTestDatabaseConnection.mockImplementation(testDatabaseConnection); - - userEvent.click( - screen.getByRole('button', { - name: /test connection/i, - }), - ); - - expect(mockTestDatabaseConnection).toHaveBeenCalled(); - - The line below makes the linter happy */ - expect.anything(); - }); - }); - - describe('SSH Tunnel Form interaction', () => { - test('properly interacts with SSH Tunnel form textboxes for dynamic form', async () => { - userEvent.click( - screen.getByRole('button', { - name: /postgresql/i, - }), - ); - expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); - const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); - userEvent.click(SSHTunnelingToggle); - const SSHTunnelServerAddressInput = screen.getByTestId( - 'ssh-tunnel-server_address-input', - ); - expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); - const SSHTunnelServerPortInput = screen.getByTestId( - 'ssh-tunnel-server_port-input', - ); - expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); - const SSHTunnelUsernameInput = screen.getByTestId( - 'ssh-tunnel-username-input', - ); - expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); - const SSHTunnelPasswordInput = screen.getByTestId( - 'ssh-tunnel-password-input', - ); - expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); - }); - - test('properly interacts with SSH Tunnel form textboxes', async () => { - userEvent.click( - screen.getByRole('button', { - name: /sqlite/i, - }), - ); - - expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); - const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); - userEvent.click(SSHTunnelingToggle); - const SSHTunnelServerAddressInput = screen.getByTestId( - 'ssh-tunnel-server_address-input', - ); - expect(SSHTunnelServerAddressInput).toHaveValue(''); - userEvent.type(SSHTunnelServerAddressInput, 'localhost'); - expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); - const SSHTunnelServerPortInput = screen.getByTestId( - 'ssh-tunnel-server_port-input', - ); - expect(SSHTunnelServerPortInput).toHaveValue(null); - userEvent.type(SSHTunnelServerPortInput, '22'); - expect(SSHTunnelServerPortInput).toHaveValue(22); - const SSHTunnelUsernameInput = screen.getByTestId( - 'ssh-tunnel-username-input', - ); - expect(SSHTunnelUsernameInput).toHaveValue(''); - userEvent.type(SSHTunnelUsernameInput, 'test'); - expect(SSHTunnelUsernameInput).toHaveValue('test'); - const SSHTunnelPasswordInput = screen.getByTestId( - 'ssh-tunnel-password-input', - ); - expect(SSHTunnelPasswordInput).toHaveValue(''); - userEvent.type(SSHTunnelPasswordInput, 'pass'); - expect(SSHTunnelPasswordInput).toHaveValue('pass'); - }); - - test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { - userEvent.click( - screen.getByRole('button', { - name: /sqlite/i, - }), - ); - - expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); - const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); - expect(SSHTunnelingToggle).toBeVisible(); - const SSHTunnelServerAddressInput = screen.queryByTestId( - 'ssh-tunnel-server_address-input', - ); - expect(SSHTunnelServerAddressInput).not.toBeInTheDocument(); - const SSHTunnelServerPortInput = screen.queryByTestId( - 'ssh-tunnel-server_port-input', - ); - expect(SSHTunnelServerPortInput).not.toBeInTheDocument(); - const SSHTunnelUsernameInput = screen.queryByTestId( - 'ssh-tunnel-username-input', - ); - expect(SSHTunnelUsernameInput).not.toBeInTheDocument(); - const SSHTunnelPasswordInput = screen.queryByTestId( - 'ssh-tunnel-password-input', - ); - expect(SSHTunnelPasswordInput).not.toBeInTheDocument(); - }); - - test('If user changes the login method, the inputs change', async () => { - userEvent.click( - screen.getByRole('button', { - name: /sqlite/i, - }), - ); - - expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); - const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); - userEvent.click(SSHTunnelingToggle); - const SSHTunnelUsePasswordInput = screen.getByTestId( - 'ssh-tunnel-use_password-radio', - ); - expect(SSHTunnelUsePasswordInput).toBeVisible(); - const SSHTunnelUsePrivateKeyInput = screen.getByTestId( - 'ssh-tunnel-use_private_key-radio', - ); - expect(SSHTunnelUsePrivateKeyInput).toBeVisible(); - const SSHTunnelPasswordInput = screen.getByTestId( - 'ssh-tunnel-password-input', - ); - // By default, we use Password as login method - expect(SSHTunnelPasswordInput).toBeVisible(); - // Change the login method to use private key - userEvent.click(SSHTunnelUsePrivateKeyInput); - const SSHTunnelPrivateKeyInput = screen.getByTestId( - 'ssh-tunnel-private_key-input', - ); - expect(SSHTunnelPrivateKeyInput).toBeVisible(); - const SSHTunnelPrivateKeyPasswordInput = screen.getByTestId( - 'ssh-tunnel-private_key_password-input', - ); - expect(SSHTunnelPrivateKeyPasswordInput).toBeVisible(); - }); - }); - }); - describe('Dynamic form flow', () => { test('enters step 2 of 3 when proper database is selected', async () => { expect(await screen.findByText(/step 1 of 3/i)).toBeInTheDocument(); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.3.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.3.test.tsx new file mode 100644 index 0000000000000..abb711238d686 --- /dev/null +++ b/superset-frontend/src/features/databases/DatabaseModal/index.3.test.tsx @@ -0,0 +1,549 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// TODO: These tests should be made atomic in separate files + +import fetchMock from 'fetch-mock'; +import userEvent from '@testing-library/user-event'; +import { act, cleanup, render, screen } from 'spec/helpers/testing-library'; +import DatabaseModal from './index'; + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: () => true, +})); + +const mockHistoryPush = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); + +const dbProps = { + show: true, + database_name: 'my database', + sqlalchemy_uri: 'postgres://superset:superset@something:1234/superset', + onHide: () => {}, +}; + +const DATABASE_FETCH_ENDPOINT = 'glob:*/api/v1/database/10'; +const AVAILABLE_DB_ENDPOINT = 'glob:*/api/v1/database/available*'; +const VALIDATE_PARAMS_ENDPOINT = 'glob:*/api/v1/database/validate_parameters*'; +const DATABASE_CONNECT_ENDPOINT = 'glob:*/api/v1/database/'; + +fetchMock.post(DATABASE_CONNECT_ENDPOINT, { + id: 10, + result: { + configuration_method: 'sqlalchemy_form', + database_name: 'Other2', + driver: 'apsw', + expose_in_sqllab: true, + extra: '{"allows_virtual_table_explore":true}', + sqlalchemy_uri: 'gsheets://', + }, + json: 'foo', +}); + +fetchMock.config.overwriteRoutes = true; +fetchMock.get(DATABASE_FETCH_ENDPOINT, { + result: { + id: 10, + database_name: 'my database', + expose_in_sqllab: false, + allow_ctas: false, + allow_cvas: false, + configuration_method: 'sqlalchemy_form', + }, +}); +fetchMock.mock(AVAILABLE_DB_ENDPOINT, { + databases: [ + { + available_drivers: ['psycopg2'], + default_driver: 'psycopg2', + engine: 'postgresql', + name: 'PostgreSQL', + parameters: { + properties: { + database: { + description: 'Database name', + type: 'string', + }, + encryption: { + description: 'Use an encrypted connection to the database', + type: 'boolean', + }, + host: { + description: 'Hostname or IP address', + type: 'string', + }, + password: { + description: 'Password', + nullable: true, + type: 'string', + }, + port: { + description: 'Database port', + format: 'int32', + maximum: 65536, + minimum: 0, + type: 'integer', + }, + query: { + additionalProperties: {}, + description: 'Additional parameters', + type: 'object', + }, + ssh: { + description: 'Create SSH Tunnel', + type: 'boolean', + }, + username: { + description: 'Username', + nullable: true, + type: 'string', + }, + }, + required: ['database', 'host', 'port', 'username'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'postgresql://user:password@host:port/dbname[?key=value&key=value...]', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['rest'], + engine: 'presto', + name: 'Presto', + preferred: true, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['mysqldb'], + default_driver: 'mysqldb', + engine: 'mysql', + name: 'MySQL', + parameters: { + properties: { + database: { + description: 'Database name', + type: 'string', + }, + encryption: { + description: 'Use an encrypted connection to the database', + type: 'boolean', + }, + host: { + description: 'Hostname or IP address', + type: 'string', + }, + password: { + description: 'Password', + nullable: true, + type: 'string', + }, + port: { + description: 'Database port', + format: 'int32', + maximum: 65536, + minimum: 0, + type: 'integer', + }, + query: { + additionalProperties: {}, + description: 'Additional parameters', + type: 'object', + }, + username: { + description: 'Username', + nullable: true, + type: 'string', + }, + }, + required: ['database', 'host', 'port', 'username'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'mysql://user:password@host:port/dbname[?key=value&key=value...]', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['pysqlite'], + engine: 'sqlite', + name: 'SQLite', + preferred: true, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['rest'], + engine: 'druid', + name: 'Apache Druid', + preferred: false, + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: false, + }, + }, + { + available_drivers: ['bigquery'], + default_driver: 'bigquery', + engine: 'bigquery', + name: 'Google BigQuery', + parameters: { + properties: { + credentials_info: { + description: 'Contents of BigQuery JSON credentials.', + type: 'string', + 'x-encrypted-extra': true, + }, + query: { + type: 'object', + }, + }, + type: 'object', + }, + preferred: false, + sqlalchemy_uri_placeholder: 'bigquery://{project_id}', + engine_information: { + supports_file_upload: true, + disable_ssh_tunneling: true, + }, + }, + { + available_drivers: ['rest'], + default_driver: 'apsw', + engine: 'gsheets', + name: 'Google Sheets', + preferred: false, + engine_information: { + supports_file_upload: false, + disable_ssh_tunneling: true, + }, + }, + { + available_drivers: ['connector'], + default_driver: 'connector', + engine: 'databricks', + name: 'Databricks', + parameters: { + properties: { + access_token: { + type: 'string', + }, + database: { + type: 'string', + }, + host: { + type: 'string', + }, + http_path: { + type: 'string', + }, + port: { + format: 'int32', + type: 'integer', + }, + }, + required: ['access_token', 'database', 'host', 'http_path', 'port'], + type: 'object', + }, + preferred: true, + sqlalchemy_uri_placeholder: + 'databricks+connector://token:{access_token}@{host}:{port}/{database_name}', + }, + ], +}); +fetchMock.post(VALIDATE_PARAMS_ENDPOINT, { + message: 'OK', +}); + +describe('DatabaseModal', () => { + const renderAndWait = async () => { + const mounted = act(async () => { + render(, { + useRedux: true, + }); + }); + + return mounted; + }; + + beforeEach(async () => { + await renderAndWait(); + }); + + afterEach(cleanup); + + describe('Functional: Create new database', () => { + test('directs databases to the appropriate form (dynamic vs. SQL Alchemy)', async () => { + // ---------- Dynamic example (3-step form) + // Click the PostgreSQL button to enter the dynamic form + const postgreSQLButton = screen.getByRole('button', { + name: /postgresql/i, + }); + userEvent.click(postgreSQLButton); + + // Dynamic form has 3 steps, seeing this text means the dynamic form is present + const dynamicFormStepText = screen.getByText(/step 2 of 3/i); + + expect(dynamicFormStepText).toBeVisible(); + + // ---------- SQL Alchemy example (2-step form) + // Click the back button to go back to step 1, + // then click the SQLite button to enter the SQL Alchemy form + const backButton = screen.getByRole('button', { name: /back/i }); + userEvent.click(backButton); + + const sqliteButton = screen.getByRole('button', { + name: /sqlite/i, + }); + userEvent.click(sqliteButton); + + // SQL Alchemy form has 2 steps, seeing this text means the SQL Alchemy form is present + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const sqlAlchemyFormStepText = screen.getByText(/step 2 of 2/i); + + expect(sqlAlchemyFormStepText).toBeVisible(); + }); + + describe('SQL Alchemy form flow', () => { + test('enters step 2 of 2 when proper database is selected', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + }); + + test('runs fetchResource when "Connect" is clicked', () => { + /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- + + // Mock useSingleViewResource + const mockUseSingleViewResource = jest.fn(); + mockUseSingleViewResource.mockImplementation(useSingleViewResource); + + const { fetchResource } = mockUseSingleViewResource('database'); + + // Invalid hook call? + userEvent.click(screen.getByRole('button', { name: 'Connect' })); + expect(fetchResource).toHaveBeenCalled(); + + The line below makes the linter happy */ + expect.anything(); + }); + + describe('step 2 component interaction', () => { + test('properly interacts with textboxes', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const dbNametextBox = screen.getByTestId('database-name-input'); + expect(dbNametextBox).toHaveValue('SQLite'); + + userEvent.type(dbNametextBox, 'Different text'); + expect(dbNametextBox).toHaveValue('SQLiteDifferent text'); + + const sqlAlchemyURItextBox = screen.getByTestId( + 'sqlalchemy-uri-input', + ); + expect(sqlAlchemyURItextBox).toHaveValue(''); + + userEvent.type(sqlAlchemyURItextBox, 'Different text'); + expect(sqlAlchemyURItextBox).toHaveValue('Different text'); + }); + + test('runs testDatabaseConnection when "TEST CONNECTION" is clicked', () => { + /* ---------- 🐞 TODO (lyndsiWilliams): function mock is not currently working 🐞 ---------- + + // Mock testDatabaseConnection + const mockTestDatabaseConnection = jest.fn(); + mockTestDatabaseConnection.mockImplementation(testDatabaseConnection); + + userEvent.click( + screen.getByRole('button', { + name: /test connection/i, + }), + ); + + expect(mockTestDatabaseConnection).toHaveBeenCalled(); + + The line below makes the linter happy */ + expect.anything(); + }); + }); + + describe('SSH Tunnel Form interaction', () => { + test('properly interacts with SSH Tunnel form textboxes for dynamic form', async () => { + userEvent.click( + screen.getByRole('button', { + name: /postgresql/i, + }), + ); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelServerAddressInput = screen.getByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).toHaveValue(''); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + const SSHTunnelServerPortInput = screen.getByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).toHaveValue(null); + userEvent.type(SSHTunnelServerPortInput, '22'); + expect(SSHTunnelServerPortInput).toHaveValue(22); + const SSHTunnelUsernameInput = screen.getByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).toHaveValue(''); + userEvent.type(SSHTunnelUsernameInput, 'test'); + expect(SSHTunnelUsernameInput).toHaveValue('test'); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).toHaveValue(''); + userEvent.type(SSHTunnelPasswordInput, 'pass'); + expect(SSHTunnelPasswordInput).toHaveValue('pass'); + }); + + test('properly interacts with SSH Tunnel form textboxes', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelServerAddressInput = screen.getByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).toHaveValue(''); + userEvent.type(SSHTunnelServerAddressInput, 'localhost'); + expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); + const SSHTunnelServerPortInput = screen.getByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).toHaveValue(null); + userEvent.type(SSHTunnelServerPortInput, '22'); + expect(SSHTunnelServerPortInput).toHaveValue(22); + const SSHTunnelUsernameInput = screen.getByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).toHaveValue(''); + userEvent.type(SSHTunnelUsernameInput, 'test'); + expect(SSHTunnelUsernameInput).toHaveValue('test'); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).toHaveValue(''); + userEvent.type(SSHTunnelPasswordInput, 'pass'); + expect(SSHTunnelPasswordInput).toHaveValue('pass'); + }); + + test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + expect(SSHTunnelingToggle).toBeVisible(); + const SSHTunnelServerAddressInput = screen.queryByTestId( + 'ssh-tunnel-server_address-input', + ); + expect(SSHTunnelServerAddressInput).not.toBeInTheDocument(); + const SSHTunnelServerPortInput = screen.queryByTestId( + 'ssh-tunnel-server_port-input', + ); + expect(SSHTunnelServerPortInput).not.toBeInTheDocument(); + const SSHTunnelUsernameInput = screen.queryByTestId( + 'ssh-tunnel-username-input', + ); + expect(SSHTunnelUsernameInput).not.toBeInTheDocument(); + const SSHTunnelPasswordInput = screen.queryByTestId( + 'ssh-tunnel-password-input', + ); + expect(SSHTunnelPasswordInput).not.toBeInTheDocument(); + }); + + test('If user changes the login method, the inputs change', async () => { + userEvent.click( + screen.getByRole('button', { + name: /sqlite/i, + }), + ); + + expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument(); + const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch'); + userEvent.click(SSHTunnelingToggle); + const SSHTunnelUsePasswordInput = screen.getByTestId( + 'ssh-tunnel-use_password-radio', + ); + expect(SSHTunnelUsePasswordInput).toBeVisible(); + const SSHTunnelUsePrivateKeyInput = screen.getByTestId( + 'ssh-tunnel-use_private_key-radio', + ); + expect(SSHTunnelUsePrivateKeyInput).toBeVisible(); + const SSHTunnelPasswordInput = screen.getByTestId( + 'ssh-tunnel-password-input', + ); + // By default, we use Password as login method + expect(SSHTunnelPasswordInput).toBeVisible(); + // Change the login method to use private key + userEvent.click(SSHTunnelUsePrivateKeyInput); + const SSHTunnelPrivateKeyInput = screen.getByTestId( + 'ssh-tunnel-private_key-input', + ); + expect(SSHTunnelPrivateKeyInput).toBeVisible(); + const SSHTunnelPrivateKeyPasswordInput = screen.getByTestId( + 'ssh-tunnel-private_key_password-input', + ); + expect(SSHTunnelPrivateKeyPasswordInput).toBeVisible(); + }); + }); + }); + }); +});