diff --git a/.github/workflows/prefer_typescript.yml b/.github/workflows/prefer_typescript.yml index db1c926cbb..2572ef3b15 100644 --- a/.github/workflows/prefer_typescript.yml +++ b/.github/workflows/prefer_typescript.yml @@ -21,10 +21,7 @@ jobs: js_files_added() { jq -r ' map( - select( - (contains("cypress-base/") | not) and - (endswith(".js") or endswith(".jsx")) - ) + select((endswith(".js") or endswith(".jsx")) ) | join("\n") ' ${HOME}/files_added.json } diff --git a/superset-frontend/cypress-base/.eslintrc b/superset-frontend/cypress-base/.eslintrc new file mode 100644 index 0000000000..91d33bab45 --- /dev/null +++ b/superset-frontend/cypress-base/.eslintrc @@ -0,0 +1,25 @@ +{ + "parser": "@typescript-eslint/parser", + "plugins": ["cypress", "@typescript-eslint"], + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:cypress/recommended" + ], + "rules": { + "import/no-unresolved": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/camelcase": 0 + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + }, + "env": { + "cypress/globals": true + } +} diff --git a/superset-frontend/cypress-base/cypress.json b/superset-frontend/cypress-base/cypress.json index 76e4778f9c..8856588dfa 100644 --- a/superset-frontend/cypress-base/cypress.json +++ b/superset-frontend/cypress-base/cypress.json @@ -2,9 +2,10 @@ "baseUrl": "http://localhost:8081", "chromeWebSecurity": false, "defaultCommandTimeout": 5000, + "experimentalFetchPolyfill": true, "requestTimeout": 10000, "ignoreTestFiles": [ - "**/!(*.test.js)" + "**/!(*.test.js|*.test.ts)" ], "video": false, "videoUploadOnPasses": false, diff --git a/superset-frontend/cypress-base/cypress/.eslintrc b/superset-frontend/cypress-base/cypress/.eslintrc deleted file mode 100644 index 5b98856272..0000000000 --- a/superset-frontend/cypress-base/cypress/.eslintrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "plugins": [ - "cypress" - ], - "env": { - "cypress/globals": true - } -} diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.ts similarity index 79% rename from superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.js rename to superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.ts index d734a57156..6f386000de 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/filter.test.ts @@ -18,11 +18,23 @@ */ import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper'; -describe('Dashboard filter', () => { - let filterId; - let aliases; +interface Slice { + slice_id: number; + form_data: { + viz_type: string; + [key: string]: JSONValue; + }; +} - const getAlias = id => { +interface DashboardData { + slices: Slice[]; +} + +describe('Dashboard filter', () => { + let filterId: number; + let aliases: string[]; + + const getAlias = (id: number) => { return `@slice_${id}`; }; @@ -32,13 +44,14 @@ describe('Dashboard filter', () => { cy.visit(WORLD_HEALTH_DASHBOARD); - cy.get('#app').then(data => { - const bootstrapData = JSON.parse(data[0].dataset.bootstrap); - const dashboard = bootstrapData.dashboard_data; + cy.get('#app').then(app => { + const bootstrapData = app.data('bootstrap'); + const dashboard = bootstrapData.dashboard_data as DashboardData; const sliceIds = dashboard.slices.map(slice => slice.slice_id); - filterId = dashboard.slices.find( - slice => slice.form_data.viz_type === 'filter_box', - ).slice_id; + filterId = + dashboard.slices.find( + slice => slice.form_data.viz_type === 'filter_box', + )?.slice_id || 0; aliases = sliceIds.map(id => { const alias = getAlias(id); const url = `/superset/explore_json/?*{"slice_id":${id}}*`; @@ -72,7 +85,7 @@ describe('Dashboard filter', () => { cy.get('.Select__control input[type=text]') .first() - .focus({ force: true }) + .focus() .type('So', { force: true }); cy.get('.Select__menu').first().contains('Create "So"'); @@ -81,7 +94,7 @@ describe('Dashboard filter', () => { // we refocus the input again here. The is not happening in real life. cy.get('.Select__control input[type=text]') .first() - .focus({ force: true }) + .focus() .type('uth Asia{enter}', { force: true }); // by default, need to click Apply button to apply filter @@ -90,8 +103,10 @@ describe('Dashboard filter', () => { // wait again after applied filters cy.wait(aliases.filter(x => x !== getAlias(filterId))).then(requests => { requests.forEach(xhr => { - const requestFormData = xhr.request.body; - const requestParams = JSON.parse(requestFormData.get('form_data')); + const requestFormData = xhr.request.body as FormData; + const requestParams = JSON.parse( + requestFormData.get('form_data') as string, + ); expect(requestParams.extra_filters[0]).deep.eq({ col: 'region', op: 'in', diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js index 8f7069ccb2..650d372232 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/save.test.js @@ -21,7 +21,6 @@ import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper'; describe('Dashboard save action', () => { let dashboardId; - let boxplotChartId; beforeEach(() => { cy.server(); @@ -32,10 +31,6 @@ describe('Dashboard save action', () => { const bootstrapData = JSON.parse(data[0].dataset.bootstrap); const dashboard = bootstrapData.dashboard_data; dashboardId = dashboard.id; - boxplotChartId = dashboard.slices.find( - slice => slice.form_data.viz_type === 'box_plot', - ).slice_id; - cy.route('POST', `/superset/copy_dash/${dashboardId}/`).as('copyRequest'); }); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/link.test.js b/superset-frontend/cypress-base/cypress/integration/explore/link.test.js index e8bc56cedb..1d0ce87913 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/link.test.js +++ b/superset-frontend/cypress-base/cypress/integration/explore/link.test.js @@ -55,8 +55,6 @@ describe('Test explore links', () => { // explicitly wait for the url response cy.wait('@getShortUrl'); - cy.wait(100); - cy.get('#shorturl-popover [data-test="short-url"]') .invoke('text') .then(text => { diff --git a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js index 43e9706c25..d876ef637f 100644 --- a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js +++ b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js @@ -24,30 +24,28 @@ describe('SqlLab query tabs', () => { }); it('allows you to create a tab', () => { - cy.get('#a11y-query-editor-tabs > ul > li').then(tabList => { + cy.get('.SqlEditorTabs > ul > li').then(tabList => { const initialTabCount = tabList.length; - // add tab - cy.get('#a11y-query-editor-tabs > ul > li').last().click(); - - cy.get('#a11y-query-editor-tabs > ul > li').should( - 'have.length', - initialTabCount + 1, + cy.get('.SqlEditorTabs > ul > li').last().click(); + // wait until we find the new tab + cy.get(`.SqlEditorTabs > ul > li:eq(${initialTabCount - 1})`).contains( + 'Untitled Query', ); }); }); it('allows you to close a tab', () => { - cy.get('#a11y-query-editor-tabs > ul > li').then(tabListA => { + cy.get('.SqlEditorTabs > ul > li').then(tabListA => { const initialTabCount = tabListA.length; // open the tab dropdown to remove - cy.get('#a11y-query-editor-tabs > ul > li .dropdown-toggle').click(); + cy.get('.SqlEditorTabs > ul > li .dropdown-toggle').click(); // first item is close - cy.get('#a11y-query-editor-tabs .close-btn a').click(); + cy.get('.SqlEditorTabs .close-btn a').click(); - cy.get('#a11y-query-editor-tabs > ul > li').should( + cy.get('.SqlEditorTabs > ul > li').should( 'have.length', initialTabCount - 1, ); diff --git a/superset-frontend/cypress-base/cypress/plugins/index.js b/superset-frontend/cypress-base/cypress/plugins/index.js index adfeabedbc..4df323f823 100644 --- a/superset-frontend/cypress-base/cypress/plugins/index.js +++ b/superset-frontend/cypress-base/cypress/plugins/index.js @@ -16,16 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) diff --git a/superset-frontend/cypress-base/cypress/support/index.d.ts b/superset-frontend/cypress-base/cypress/support/index.d.ts new file mode 100644 index 0000000000..80a936ef40 --- /dev/null +++ b/superset-frontend/cypress-base/cypress/support/index.d.ts @@ -0,0 +1,61 @@ +/** + * 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. + */ +// eslint-disable-next-line spaced-comment +/// +type JSONPrimitive = string | number | boolean | null; +type JSONValue = JSONPrimitive | JSONObject | JSONArray; +type JSONObject = { [member: string]: JSONValue }; +type JSONArray = JSONValue[]; + +declare namespace Cypress { + interface Chainable { + /** + * Login test user. + */ + login(): void; + + /** + * Verify a waitXHR response and parse response JSON. + */ + verifyResponseCodes( + xhr: WaitXHR, + callback?: (result: JSONValue) => void, + ): cy; + + /** + * Verify slice container renders. + */ + verifySliceContainer(chartSelector: JQuery.Selector): cy; + + /** + * Verify slice successfully loaded. + */ + verifySliceSuccess({ + waitAlias, + querySubString, + chartSelector, + }: { + waitAlias: string; + querySubString: string; + chartSelector: JQuery.Selector; + }): cy; + } +} + +declare module '@cypress/code-coverage/task'; diff --git a/superset-frontend/cypress-base/cypress/support/index.js b/superset-frontend/cypress-base/cypress/support/index.js deleted file mode 100644 index 52bd671616..0000000000 --- a/superset-frontend/cypress-base/cypress/support/index.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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. - */ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -import '@cypress/code-coverage/support'; -import './commands'; - -// The following is a workaround for Cypress not supporting fetch. -// By setting window.fetch = null, we force the fetch polyfill to fall back -// to xhr as described here https://github.com/cypress-io/cypress/issues/95 -Cypress.on('window:before:load', win => { - win.fetch = null; // eslint-disable-line no-param-reassign -}); diff --git a/superset-frontend/cypress-base/cypress/support/commands.js b/superset-frontend/cypress-base/cypress/support/index.ts similarity index 52% rename from superset-frontend/cypress-base/cypress/support/commands.js rename to superset-frontend/cypress-base/cypress/support/index.ts index fafff6cb94..ad70b732e5 100644 --- a/superset-frontend/cypress-base/cypress/support/commands.js +++ b/superset-frontend/cypress-base/cypress/support/index.ts @@ -16,32 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This is will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) - +import '@cypress/code-coverage/support'; import readResponseBlob from '../utils/readResponseBlob'; const BASE_EXPLORE_URL = '/superset/explore/?form_data='; @@ -63,47 +38,60 @@ Cypress.Commands.add('visitChartByName', name => { }); Cypress.Commands.add('visitChartById', chartId => { - cy.visit(`${BASE_EXPLORE_URL}{"slice_id": ${chartId}}`); + return cy.visit(`${BASE_EXPLORE_URL}{"slice_id": ${chartId}}`); }); Cypress.Commands.add('visitChartByParams', params => { - cy.visit(`${BASE_EXPLORE_URL}${params}`); + return cy.visit(`${BASE_EXPLORE_URL}${params}`); }); -Cypress.Commands.add('verifyResponseCodes', async xhr => { +Cypress.Commands.add('verifyResponseCodes', (xhr: XMLHttpRequest, callback) => { // After a wait response check for valid response expect(xhr.status).to.eq(200); - - const responseBody = await readResponseBlob(xhr.response.body); - - if (responseBody.error) { - expect(responseBody.error).to.eq(null); - } + readResponseBlob(xhr.response.body).then(res => { + expect(res).to.not.be.instanceOf(Error); + if (callback) { + callback(res); + } + }); + return cy; }); Cypress.Commands.add('verifySliceContainer', chartSelector => { // After a wait response check for valid slice container - cy.get('.slice_container').within(async () => { + cy.get('.slice_container').within(() => { if (chartSelector) { - const chart = await cy.get(chartSelector); - expect(chart[0].clientWidth).greaterThan(0); - expect(chart[0].clientHeight).greaterThan(0); + cy.get(chartSelector).then(chart => { + expect(chart[0].clientWidth).greaterThan(0); + expect(chart[0].clientHeight).greaterThan(0); + }); } }); + return cy; }); Cypress.Commands.add( 'verifySliceSuccess', - ({ waitAlias, querySubstring, chartSelector }) => { - cy.wait(waitAlias).then(async xhr => { - cy.verifyResponseCodes(xhr); - - const responseBody = await readResponseBlob(xhr.response.body); - if (querySubstring) { - expect(responseBody.query).contains(querySubstring); - } - + ({ + waitAlias, + querySubstring, + chartSelector, + }: { + waitAlias: string; + querySubstring: string; + chartSelector: JQuery.Selector; + }) => { + cy.wait(waitAlias).then(xhr => { cy.verifySliceContainer(chartSelector); + cy.verifyResponseCodes(xhr, responseBody => { + if (querySubstring) { + type QueryResponse = { query: string }; + expect( + responseBody && (responseBody as QueryResponse).query, + ).contains(querySubstring); + } + }); }); + return cy; }, ); diff --git a/superset-frontend/cypress-base/cypress/utils/readResponseBlob.js b/superset-frontend/cypress-base/cypress/utils/readResponseBlob.ts similarity index 63% rename from superset-frontend/cypress-base/cypress/utils/readResponseBlob.js rename to superset-frontend/cypress-base/cypress/utils/readResponseBlob.ts index 6cf077b37a..560a5c55d5 100644 --- a/superset-frontend/cypress-base/cypress/utils/readResponseBlob.js +++ b/superset-frontend/cypress-base/cypress/utils/readResponseBlob.ts @@ -16,14 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -// This function returns a promise that resolves to the value -// of the passed response blob. It assumes the blob should be read as text, -// and that the response can be parsed as JSON. This is needed to read -// the value of any fetch-based response. -export default function readResponseBlob(blob) { - return new Promise(resolve => { - const reader = new FileReader(); - reader.onload = () => resolve(JSON.parse(reader.result)); - reader.readAsText(blob); +/** + * Read XHR response and parse it as JSON. + */ +export default function readResponseBlob(blob: Blob | JSONValue) { + return new Promise>(resolve => { + if (blob instanceof Blob) { + const reader = new FileReader(); + reader.onload = () => resolve(JSON.parse(String(reader.result || ''))); + reader.readAsText(blob); + } else { + resolve(blob); + } }); } diff --git a/superset-frontend/cypress-base/tsconfig.json b/superset-frontend/cypress-base/tsconfig.json new file mode 100644 index 0000000000..eec99e45f9 --- /dev/null +++ b/superset-frontend/cypress-base/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES5", + "lib": ["ES5", "ES2015", "DOM"], + "types": ["cypress"], + "allowJs": true, + "noEmit": true + }, + "files": ["cypress/support/index.d.ts"], + "include": ["node_modules/cypress", "cypress/**/*.ts"] +}