From a4fe7f39ea17afe399610a0949184f7199d1b8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Bedi?= Date: Tue, 19 Mar 2024 23:07:48 +0100 Subject: [PATCH] E2E: Rewrite mysql tests to playwright (#83424) * E2E: Rewrite mysql tests to playwright * Fix lint * Add more selectors and address comments * Scope locators when locating text * Don't run it 20 times * Update new-datasource-variable to assert mysql --- .../new-datasource-variable.spec.ts | 21 ++- e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts | 72 ++++++++++ e2e/plugin-e2e/mysql/mysql.spec.ts | 90 +++++++++++++ .../fixtures/datasets-response.json | 21 --- .../fixtures/fields-response.json | 27 ---- .../fixtures/tables-response.json | 19 --- e2e/various-suite/mysql.spec.ts | 125 ------------------ .../src/selectors/components.ts | 13 ++ .../src/components/QueryHeader.tsx | 5 + .../src/components/TableSelector.tsx | 2 + .../AwesomeQueryBuilder.tsx | 4 + .../visual-query-builder/SelectRow.tsx | 4 + playwright.config.ts | 15 ++- 13 files changed, 212 insertions(+), 206 deletions(-) create mode 100644 e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts create mode 100644 e2e/plugin-e2e/mysql/mysql.spec.ts delete mode 100644 e2e/various-suite/fixtures/datasets-response.json delete mode 100644 e2e/various-suite/fixtures/fields-response.json delete mode 100644 e2e/various-suite/fixtures/tables-response.json delete mode 100644 e2e/various-suite/mysql.spec.ts diff --git a/e2e/dashboards-suite/new-datasource-variable.spec.ts b/e2e/dashboards-suite/new-datasource-variable.spec.ts index 17a46749c23..40260a76520 100644 --- a/e2e/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e/dashboards-suite/new-datasource-variable.spec.ts @@ -3,6 +3,9 @@ import { e2e } from '../utils'; const PAGE_UNDER_TEST = 'kVi2Gex7z/test-variable-output'; const DASHBOARD_NAME = 'Test variable output'; +const gdev_mysql = 'gdev-mysql'; +const gdev_mysql_ds_tests = 'gdev-mysql-ds-tests'; + describe('Variables - Datasource', () => { beforeEach(() => { e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); @@ -20,18 +23,15 @@ describe('Variables - Datasource', () => { e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type('VariableUnderTest').blur(); e2e.pages.Dashboard.Settings.Variables.Edit.General.generalLabelInputV2().type('Variable under test').blur(); - // If this is failing, but sure to check there are Prometheus datasources named "gdev-prometheus" and "gdev-slow-prometheus" + // If this is failing, but sure to check there are MySQL datasources named "gdev-mysql" and "gdev-mysql-ds-tests" // Or, just update is to match some gdev datasources to test with :) e2e.pages.Dashboard.Settings.Variables.Edit.DatasourceVariable.datasourceSelect().within(() => { - cy.get('input').type('Prometheus{enter}'); + cy.get('input').type('MySQL{enter}'); }); + e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should('contain.text', gdev_mysql); e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( 'contain.text', - 'gdev-prometheus' - ); - e2e.pages.Dashboard.Settings.Variables.Edit.General.previewOfValuesOption().should( - 'contain.text', - 'gdev-slow-prometheus' + gdev_mysql_ds_tests ); // Navigate back to the homepage and change the selected variable value @@ -39,11 +39,10 @@ describe('Variables - Datasource', () => { e2e.pages.Dashboard.Settings.Actions.close().click(); e2e.components.RefreshPicker.runButtonV2().click(); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts('gdev-prometheus').click(); - e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('gdev-slow-prometheus').click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownValueLinkTexts(gdev_mysql).click(); + e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts(gdev_mysql_ds_tests).click(); // Assert it was rendered - cy.get('.markdown-html').should('include.text', 'VariableUnderTest: gdev-slow-prometheus-uid'); - cy.get('.markdown-html').should('include.text', 'VariableUnderTestText: gdev-slow-prometheus'); + cy.get('.markdown-html').should('include.text', `VariableUnderTestText: ${gdev_mysql_ds_tests}`); }); }); diff --git a/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts b/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts new file mode 100644 index 00000000000..eb213f0aafc --- /dev/null +++ b/e2e/plugin-e2e/mysql/mocks/mysql.mocks.ts @@ -0,0 +1,72 @@ +export const normalTableName = 'normalTable'; +export const tableNameWithSpecialCharacter = 'table-name'; +export const tablesResponse = { + results: { + tables: { + status: 200, + frames: [ + { + schema: { + refId: 'tables', + meta: { + executedQueryString: + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'DataMaker' ORDER BY table_name", + }, + fields: [{ name: 'TABLE_NAME', type: 'string', typeInfo: { frame: 'string', nullable: true } }], + }, + data: { values: [[normalTableName, tableNameWithSpecialCharacter]] }, + }, + ], + }, + }, +}; + +export const fieldsResponse = { + results: { + fields: { + status: 200, + frames: [ + { + schema: { + refId: 'fields', + meta: { + executedQueryString: + "SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'DataMaker' AND table_name = 'RandomIntsWithTimes' ORDER BY column_name", + }, + fields: [ + { name: 'COLUMN_NAME', type: 'string', typeInfo: { frame: 'string', nullable: true } }, + { name: 'DATA_TYPE', type: 'string', typeInfo: { frame: 'string', nullable: true } }, + ], + }, + data: { + values: [ + ['createdAt', 'id', 'time', 'updatedAt', 'bigint'], + ['datetime', 'int', 'datetime', 'datetime', 'int'], + ], + }, + }, + ], + }, + }, +}; + +export const datasetResponse = { + results: { + datasets: { + status: 200, + frames: [ + { + schema: { + refId: 'datasets', + meta: { + executedQueryString: + "SELECT DISTINCT TABLE_SCHEMA from information_schema.TABLES where TABLE_TYPE != 'SYSTEM VIEW' ORDER BY TABLE_SCHEMA", + }, + fields: [{ name: 'TABLE_SCHEMA', type: 'string', typeInfo: { frame: 'string', nullable: true } }], + }, + data: { values: [['DataMaker', 'mysql', 'performance_schema', 'sys']] }, + }, + ], + }, + }, +}; diff --git a/e2e/plugin-e2e/mysql/mysql.spec.ts b/e2e/plugin-e2e/mysql/mysql.spec.ts new file mode 100644 index 00000000000..ac6d4c20a6a --- /dev/null +++ b/e2e/plugin-e2e/mysql/mysql.spec.ts @@ -0,0 +1,90 @@ +import { selectors } from '@grafana/e2e-selectors'; +import { expect, test } from '@grafana/plugin-e2e'; + +import { + tablesResponse, + fieldsResponse, + datasetResponse, + normalTableName, + tableNameWithSpecialCharacter, +} from './mocks/mysql.mocks'; + +test.beforeEach(async ({ context, selectors, explorePage }) => { + await explorePage.datasource.set('gdev-mysql'); + await context.route(selectors.apis.DataSource.queryPattern, async (route, request) => { + switch (request.postDataJSON().queries[0].refId) { + case 'tables': + return route.fulfill({ json: tablesResponse, status: 200 }); + case 'fields': + return route.fulfill({ json: fieldsResponse, status: 200 }); + case 'datasets': + return route.fulfill({ json: datasetResponse, status: 200 }); + default: + return route.continue(); + } + }); +}); + +test('code editor autocomplete should handle table name escaping/quoting', async ({ explorePage, selectors, page }) => { + await page.getByLabel('Code').check(); + + const editor = explorePage.getByTestIdOrAriaLabel(selectors.components.CodeEditor.container).getByRole('textbox'); + await editor.fill('S'); + await page.getByLabel('SELECT FROM ').locator('a').click(); + await expect(page.getByLabel(tableNameWithSpecialCharacter)).toBeVisible(); + await page.keyboard.press('Enter'); + + await expect(editor).toHaveValue(`SELECT FROM grafana.\`${tableNameWithSpecialCharacter}\``); + + for (let i = 0; i < tableNameWithSpecialCharacter.length + 2; i++) { + await page.keyboard.press('Backspace'); + } + + await page.keyboard.press('Control+I'); + await expect(page.getByLabel(tableNameWithSpecialCharacter)).toBeVisible(); +}); + +test('visual query builder should handle time filter macro', async ({ explorePage, page }) => { + await explorePage.getByTestIdOrAriaLabel(selectors.components.SQLQueryEditor.headerTableSelector).click(); + await page.getByText(normalTableName, { exact: true }).click(); + + // Open column selector + await explorePage.getByTestIdOrAriaLabel(selectors.components.SQLQueryEditor.selectColumn).click(); + const select = page.getByLabel('Select options menu'); + await select.locator(page.getByText('createdAt')).click(); + + // Toggle where row + await page.getByLabel('Filter').click(); + + // Click add filter button + await page.getByRole('button', { name: 'Add filter' }).click(); + await page.getByRole('button', { name: 'Add filter' }).click(); // For some reason we need to click twice + + // Open field selector + await explorePage.getByTestIdOrAriaLabel(selectors.components.SQLQueryEditor.filterField).click(); + await select.locator(page.getByText('createdAt')).click(); + + // Open operator selector + await explorePage.getByTestIdOrAriaLabel(selectors.components.SQLQueryEditor.filterOperator).click(); + await select.locator(page.getByText('Macros')).click(); + + // Open macros value selector + await explorePage.getByTestIdOrAriaLabel('Macros value selector').click(); + await select.locator(page.getByText('timeFilter', { exact: true })).click(); + + // Validate that the timeFilter macro was added + await expect( + explorePage.getByTestIdOrAriaLabel(selectors.components.CodeEditor.container).getByRole('textbox') + ).toHaveValue(`SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50`); + + // Validate that the timeFilter macro was removed when changed to equals operator + await explorePage.getByTestIdOrAriaLabel(selectors.components.SQLQueryEditor.filterOperator).click(); + await select.locator(page.getByText('==')).click(); + + await explorePage.getByTestIdOrAriaLabel(selectors.components.DateTimePicker.input).click(); + await explorePage.getByTestIdOrAriaLabel(selectors.components.DateTimePicker.input).blur(); + + await expect( + explorePage.getByTestIdOrAriaLabel(selectors.components.CodeEditor.container).getByRole('textbox') + ).not.toHaveValue(`SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n createdAt = NULL\nLIMIT\n 50`); +}); diff --git a/e2e/various-suite/fixtures/datasets-response.json b/e2e/various-suite/fixtures/datasets-response.json deleted file mode 100644 index f9ab5f5f94b..00000000000 --- a/e2e/various-suite/fixtures/datasets-response.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "results": { - "datasets": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "datasets", - "meta": { - "executedQueryString": "SELECT DISTINCT TABLE_SCHEMA from information_schema.TABLES where TABLE_TYPE != 'SYSTEM VIEW' ORDER BY TABLE_SCHEMA" - }, - "fields": [ - { "name": "TABLE_SCHEMA", "type": "string", "typeInfo": { "frame": "string", "nullable": true } } - ] - }, - "data": { "values": [["DataMaker", "mysql", "performance_schema", "sys"]] } - } - ] - } - } -} diff --git a/e2e/various-suite/fixtures/fields-response.json b/e2e/various-suite/fixtures/fields-response.json deleted file mode 100644 index 3ccf5a0a5b8..00000000000 --- a/e2e/various-suite/fixtures/fields-response.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "results": { - "fields": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "fields", - "meta": { - "executedQueryString": "SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'DataMaker' AND table_name = 'RandomIntsWithTimes' ORDER BY column_name" - }, - "fields": [ - { "name": "COLUMN_NAME", "type": "string", "typeInfo": { "frame": "string", "nullable": true } }, - { "name": "DATA_TYPE", "type": "string", "typeInfo": { "frame": "string", "nullable": true } } - ] - }, - "data": { - "values": [ - ["createdAt", "id", "time", "updatedAt", "bigint"], - ["datetime", "int", "datetime", "datetime", "int"] - ] - } - } - ] - } - } -} diff --git a/e2e/various-suite/fixtures/tables-response.json b/e2e/various-suite/fixtures/tables-response.json deleted file mode 100644 index 6cdda151849..00000000000 --- a/e2e/various-suite/fixtures/tables-response.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "results": { - "tables": { - "status": 200, - "frames": [ - { - "schema": { - "refId": "tables", - "meta": { - "executedQueryString": "SELECT table_name FROM information_schema.tables WHERE table_schema = 'DataMaker' ORDER BY table_name" - }, - "fields": [{ "name": "TABLE_NAME", "type": "string", "typeInfo": { "frame": "string", "nullable": true } }] - }, - "data": { "values": [["normalTable", "table-name"]] } - } - ] - } - } -} diff --git a/e2e/various-suite/mysql.spec.ts b/e2e/various-suite/mysql.spec.ts deleted file mode 100644 index b78db4bfe18..00000000000 --- a/e2e/various-suite/mysql.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { e2e } from '../utils'; - -import datasetResponse from './fixtures/datasets-response.json'; -import fieldsResponse from './fixtures/fields-response.json'; -import tablesResponse from './fixtures/tables-response.json'; - -const tableNameWithSpecialCharacter = tablesResponse.results.tables.frames[0].data.values[0][1]; -const normalTableName = tablesResponse.results.tables.frames[0].data.values[0][0]; - -describe('MySQL datasource', () => { - beforeEach(() => { - cy.intercept('POST', '/api/ds/query', (req) => { - if (req.body.queries[0].refId === 'datasets') { - req.alias = 'datasets'; - req.reply({ - body: datasetResponse, - }); - } else if (req.body.queries[0].refId === 'tables') { - req.alias = 'tables'; - req.reply({ - body: tablesResponse, - }); - } else if (req.body.queries[0].refId === 'fields') { - req.alias = 'fields'; - req.reply({ - body: fieldsResponse, - }); - } - }); - e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD')); - e2e.pages.Explore.visit(); - - e2e.components.DataSourcePicker.container().should('be.visible').type('gdev-mysql{enter}'); - cy.wait('@datasets'); - }); - - it.skip('code editor autocomplete should handle table name escaping/quoting', () => { - e2e.components.RadioButton.container().filter(':contains("Code")').click(); - - e2e.components.CodeEditor.container().children('[data-testid="Spinner"]').should('not.exist'); - cy.window().its('monaco').should('exist'); - - cy.get('textarea').type('S{downArrow}{enter}'); - cy.wait('@tables'); - cy.get('.suggest-widget').contains(tableNameWithSpecialCharacter).should('be.visible'); - cy.get('textarea').type('{enter}'); - cy.get('textarea').should('have.value', `SELECT FROM grafana.\`${tableNameWithSpecialCharacter}\``); - - const deleteTimes = new Array(tableNameWithSpecialCharacter.length + 2).fill( - '{backspace}', - 0, - tableNameWithSpecialCharacter.length + 2 - ); - cy.get('textarea').type(deleteTimes.join('')); - - const commandKey = Cypress.platform === 'darwin' ? '{command}' : '{ctrl}'; - - cy.get('textarea').type(`${commandKey}i`); - cy.get('.suggest-widget').contains(tableNameWithSpecialCharacter).should('be.visible'); - cy.get('textarea').type('S{downArrow}{enter}'); - cy.get('textarea').should('have.value', `SELECT FROM grafana.${normalTableName}`); - - cy.get('textarea').type('.'); - cy.get('.suggest-widget').contains('No suggestions.').should('be.visible'); - }); - - describe('visual query builder', () => { - it('should be able to add timeFilter macro', () => { - cy.get("[aria-label='Table selector']").should('be.visible').click(); - selectOption(normalTableName); - // Open column selector - cy.get("[id^='select-column-0']").should('be.visible').click(); - selectOption('createdAt'); - - // Toggle where row - cy.get("label[for^='sql-filter']").last().should('be.visible').click(); - - // Click add filter button - cy.get('button[title="Add filter"]').should('be.visible').click(); - cy.get('button[title="Add filter"]').should('be.visible').click(); // For some reason we need to click twice - - // Open field selector - cy.get("[aria-label='Field']").should('be.visible').click(); - selectOption('createdAt'); - - // Open operator selector - cy.get("[aria-label='Operator']").should('be.visible').click(); - selectOption('Macros'); - - // Open macros value selector - cy.get("[aria-label='Macros value selector']").should('be.visible').click(); - selectOption('timeFilter'); - - e2e.components.CodeEditor.container().children('[data-testid="Spinner"]').should('not.exist'); - cy.window().its('monaco').should('exist'); - - // Validate that the timeFilter macro was added - e2e.components.CodeEditor.container() - .get('textarea') - .should( - 'have.value', - `SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50` - ); - - // Validate that the timeFilter macro was removed when changed to equals operator - - // For some reason the input is not visible the second time so we need to force the click - cy.get("[aria-label='Operator']").click({ force: true }); - selectOption('=='); - - e2e.components.DateTimePicker.input().should('be.visible').click().blur(); - - e2e.components.CodeEditor.container() - .get('textarea') - .should( - 'not.have.value', - `SELECT\n createdAt\nFROM\n DataMaker.normalTable\nWHERE\n $__timeFilter(createdAt)\nLIMIT\n 50` - ); - }); - }); -}); - -function selectOption(option: string) { - cy.get("[aria-label='Select option']").contains(option).should('be.visible').click(); -} diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 357187506e7..a1576e2383b 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -537,4 +537,17 @@ export const Components = { Tooltip: { container: 'data-testid tooltip', }, + SQLQueryEditor: { + selectColumn: 'data-testid select-column', + selectAggregation: 'data-testid select-aggregation', + selectAlias: 'data-testid select-alias', + filterConjunction: 'data-testid filter-conjunction', + filterField: 'data-testid filter-field', + filterOperator: 'data-testid filter-operator', + headerTableSelector: 'data-testid header-table-selector', + headerFilterSwitch: 'data-testid header-filter-switch', + headerGroupSwitch: 'data-testid header-group-switch', + headerOrderSwitch: 'data-testid header-order-switch', + headerPreviewSwitch: 'data-testid header-preview-switch', + }, }; diff --git a/packages/grafana-sql/src/components/QueryHeader.tsx b/packages/grafana-sql/src/components/QueryHeader.tsx index c27c7a018c6..3501a5e9ea3 100644 --- a/packages/grafana-sql/src/components/QueryHeader.tsx +++ b/packages/grafana-sql/src/components/QueryHeader.tsx @@ -3,6 +3,7 @@ import { useCopyToClipboard } from 'react-use'; import { v4 as uuidv4 } from 'uuid'; import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { EditorField, EditorHeader, EditorMode, EditorRow, FlexItem, InlineSelect } from '@grafana/experimental'; import { reportInteraction } from '@grafana/runtime'; import { Button, InlineSwitch, RadioButtonGroup, Tooltip, Space } from '@grafana/ui'; @@ -137,6 +138,7 @@ export function QueryHeader({ { // @ts-ignore @@ -175,6 +178,7 @@ export const settings: Settings = { ({ fullyParallel: true, @@ -44,7 +44,7 @@ export default defineConfig({ // Run all tests in parallel using user with admin role { name: 'admin', - testDir: path.join(testDirRoot, '/as-admin-user'), + testDir: path.join(testDirRoot, '/plugin-e2e-api-tests/as-admin-user'), use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json', @@ -54,12 +54,21 @@ export default defineConfig({ // Run all tests in parallel using user with viewer role { name: 'viewer', - testDir: path.join(testDirRoot, '/as-viewer-user'), + testDir: path.join(testDirRoot, '/plugin-e2e-api-tests/as-viewer-user'), use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/viewer.json', }, dependencies: ['createUserAndAuthenticate'], }, + { + name: 'mysql', + testDir: path.join(testDirRoot, '/mysql'), + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/admin.json', + }, + dependencies: ['authenticate'], + }, ], });