fix(test): ingestion related playwright (#17674)

* fix(test): ingestion related playwright

* fix failing tests

* update status logic to avoid conflict

* fix double run issue for ingestion

* fixing ingestion specs

* fix postgres and ingestion

* running ingestion specs

* fix airflow and argo gaps

* revert config

* fix redshift dbt tests
This commit is contained in:
Chirag Madlani 2024-09-04 19:02:30 +05:30 committed by Shailesh Parmar
parent 123f222026
commit 1848be697b
17 changed files with 226 additions and 91 deletions

View File

@ -30,7 +30,7 @@ const uniqueID = uuid();
export const REDSHIFT = {
serviceType: 'Redshift',
serviceName: `redshift-ct-test-${uniqueID}`,
serviceName: `redshift-ct-test-with-%-${uniqueID}`,
tableName: 'raw_payments',
DBTTable: 'customers',
description: `This is Redshift-ct-test-${uniqueID} description`,
@ -38,7 +38,7 @@ export const REDSHIFT = {
export const POSTGRES = {
serviceType: 'Postgres',
serviceName: `pw-postgres-test-${uuid()}`,
serviceName: `pw-postgres-test-with-%-${uniqueID}`,
tableName: 'order_items',
};

View File

@ -11,7 +11,9 @@
* limitations under the License.
*/
import test from '@playwright/test';
import test, { expect } from '@playwright/test';
import { POSTGRES, REDSHIFT } from '../../constant/service';
import { GlobalSettingOptions } from '../../constant/settings';
import AirflowIngestionClass from '../../support/entity/ingestion/AirflowIngestionClass';
import BigQueryIngestionClass from '../../support/entity/ingestion/BigQueryIngestionClass';
import KafkaIngestionClass from '../../support/entity/ingestion/KafkaIngestionClass';
@ -23,8 +25,8 @@ import RedshiftWithDBTIngestionClass from '../../support/entity/ingestion/Redshi
import S3IngestionClass from '../../support/entity/ingestion/S3IngestionClass';
import SnowflakeIngestionClass from '../../support/entity/ingestion/SnowflakeIngestionClass';
import SupersetIngestionClass from '../../support/entity/ingestion/SupersetIngestionClass';
import { redirectToHomePage } from '../../utils/common';
import { settingClick } from '../../utils/sidebar';
import { INVALID_NAMES, redirectToHomePage } from '../../utils/common';
import { settingClick, SettingOptionsType } from '../../utils/sidebar';
const services = [
S3IngestionClass,
@ -44,19 +46,26 @@ if (process.env.PLAYWRIGHT_IS_OSS) {
}
// use the admin user to login
test.use({ storageState: 'playwright/.auth/admin.json', trace: 'off' });
test.use({
storageState: 'playwright/.auth/admin.json',
trace: process.env.PLAYWRIGHT_IS_OSS ? 'off' : 'on-first-retry',
});
services.forEach((ServiceClass) => {
const service = new ServiceClass();
test.describe.configure({
timeout: 300000,
// 11 minutes max for ingestion tests
timeout: 11 * 60 * 1000,
});
test.describe.serial(service.serviceType, { tag: '@ingestion' }, async () => {
test.beforeEach('Visit entity details page', async ({ page }) => {
await redirectToHomePage(page);
await settingClick(page, service.category);
await settingClick(
page,
service.category as unknown as SettingOptionsType
);
});
test(`Create & Ingest ${service.serviceType} service`, async ({ page }) => {
@ -73,12 +82,48 @@ services.forEach((ServiceClass) => {
await service.updateScheduleOptions(page);
});
test.fixme(`Service specific tests`, async () => {
await service.runAdditionalTests(test);
if (
[POSTGRES.serviceType, REDSHIFT.serviceType].includes(service.serviceType)
) {
test(`Service specific tests`, async ({ page }) => {
await service.runAdditionalTests(page, test);
});
}
test(`Delete ${service.serviceType} service`, async ({ page }) => {
await service.deleteService(page);
});
});
});
test.describe('Service form', () => {
test('name field should throw error for invalid name', async ({ page }) => {
await redirectToHomePage(page);
await settingClick(page, GlobalSettingOptions.DATABASES);
await page.click('[data-testid="add-service-button"]');
await page.click('[data-testid="Mysql"]');
await page.click('[data-testid="next-button"]');
await page.waitForSelector('[data-testid="service-name"]');
await page.click('[data-testid="next-button"]');
await expect(page.locator('#name_help')).toBeVisible();
await expect(page.locator('#name_help')).toHaveText('Name is required');
await page.fill(
'[data-testid="service-name"]',
INVALID_NAMES.WITH_SPECIAL_CHARS
);
await expect(page.locator('#name_help')).toBeVisible();
await expect(page.locator('#name_help')).toHaveText(
'Name must contain only letters, numbers, underscores, hyphens, periods, parenthesis, and ampersands.'
);
await page.fill('[data-testid="service-name"]', 'test-service');
await page.click('[data-testid="next-button"]');
await expect(page.getByTestId('step-icon-3')).toHaveClass(/active/);
});
});

View File

@ -13,7 +13,7 @@
import { test as setup } from '@playwright/test';
import { JWT_EXPIRY_TIME_MAP } from '../constant/login';
import { AdminClass } from '../support/user/AdminClass';
import { getApiContext, getToken } from '../utils/common';
import { getApiContext } from '../utils/common';
import { updateJWTTokenExpiryTime } from '../utils/login';
const adminFile = 'playwright/.auth/admin.json';
@ -31,10 +31,6 @@ setup('authenticate as admin', async ({ page }) => {
await admin.login(page);
await page.waitForURL('**/my-data');
const token = await getToken(page);
// eslint-disable-next-line no-console
console.log(token);
// End of authentication steps.
await page.context().storageState({ path: adminFile });
});

View File

@ -20,7 +20,7 @@ class MetabaseIngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.Pipeline,
`pw-airflow-${uuid()}`,
`pw-airflow-with-%-${uuid()}`,
'Airflow',
'sample_lineage'
);

View File

@ -25,7 +25,12 @@ class BigQueryIngestionClass extends ServiceBaseClass {
filterPattern: string;
constructor() {
super(Services.Database, `pw-bigquery-${uuid()}`, 'BigQuery', 'testtable');
super(
Services.Database,
`pw-bigquery-with-%-${uuid()}`,
'BigQuery',
'testtable'
);
this.filterPattern = 'testschema';
}

View File

@ -24,7 +24,7 @@ class KafkaIngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.Messaging,
`pw-kafka-${uuid()}`,
`pw-kafka-with-%-${uuid()}`,
'Kafka',
'__transaction_state'
);

View File

@ -26,7 +26,7 @@ class MetabaseIngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.Dashboard,
`pw-Metabase-${uuid()}`,
`pw-Metabase-with-%-${uuid()}`,
'Metabase',
'jaffle_shop dashboard'
);

View File

@ -23,7 +23,7 @@ class MlFlowIngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.MLModels,
`pw-Ml-Model-${uuid()}`,
`pw-Ml-Model-with-%-${uuid()}`,
'Mlflow',
'ElasticnetWineModel',
false,

View File

@ -23,7 +23,12 @@ class MysqlIngestionClass extends ServiceBaseClass {
name: string;
tableFilter: string[];
constructor() {
super(Services.Database, `pw-mysql-${uuid()}`, 'Mysql', 'bot_entity');
super(
Services.Database,
`pw-mysql-with-%-${uuid()}`,
'Mysql',
'bot_entity'
);
this.tableFilter = ['bot_entity', 'alert_entity', 'chart_entity'];
}

View File

@ -11,9 +11,18 @@
* limitations under the License.
*/
import { Page } from '@playwright/test';
import {
Page,
PlaywrightTestArgs,
PlaywrightWorkerArgs,
TestType,
} from '@playwright/test';
import { POSTGRES } from '../../../constant/service';
import { redirectToHomePage } from '../../../utils/common';
import {
getApiContext,
redirectToHomePage,
toastNotification,
} from '../../../utils/common';
import { visitEntityPage } from '../../../utils/entity';
import { visitServiceDetailsPage } from '../../../utils/service';
import {
@ -72,9 +81,13 @@ class PostgresIngestionClass extends ServiceBaseClass {
await page.locator('#root\\/schemaFilterPattern\\/includes').press('Enter');
}
async runAdditionalTests(test) {
async runAdditionalTests(
page: Page,
test: TestType<PlaywrightTestArgs, PlaywrightWorkerArgs>
) {
if (process.env.PLAYWRIGHT_IS_OSS) {
test('Add Usage ingestion', async ({ page }) => {
await test.step('Add Usage ingestion', async () => {
const { apiContext } = await getApiContext(page);
await redirectToHomePage(page);
await visitServiceDetailsPage(
page,
@ -95,25 +108,44 @@ class PostgresIngestionClass extends ServiceBaseClass {
await page.click('[data-menu-id*="usage"]');
await page.fill('#root\\/queryLogFilePath', this.queryLogFilePath);
const deployResponse = page.waitForResponse(
'/api/v1/services/ingestionPipelines/deploy/*'
);
await page.click('[data-testid="submit-btn"]');
await page.click('[data-testid="deploy-button"]');
await deployResponse;
// Make sure we create ingestion with None schedule to avoid conflict between Airflow and Argo behavior
await this.scheduleIngestion(page);
await page.click('[data-testid="view-service-button"]');
await page.waitForResponse(
'**/api/v1/services/ingestionPipelines/status'
// Header available once page loads
await page.waitForSelector('[data-testid="data-assets-header"]');
await page.getByTestId('loader').waitFor({ state: 'detached' });
await page.getByTestId('ingestions').click();
await page
.getByLabel('Ingestions')
.getByTestId('loader')
.waitFor({ state: 'detached' });
const response = await apiContext
.get(
`/api/v1/services/ingestionPipelines?service=${encodeURIComponent(
this.serviceName
)}&pipelineType=usage&serviceType=databaseService&limit=1`
)
.then((res) => res.json());
await page.click(
`[data-row-key*="${response.data[0].name}"] [data-testid="more-actions"]`
);
await page.getByTestId('run-button').click();
await toastNotification(page, `Pipeline triggered successfully!`);
await this.handleIngestionRetry('usage', page);
});
test('Verify if usage is ingested properly', async ({ page }) => {
await test.step('Verify if usage is ingested properly', async () => {
await page.waitForSelector('[data-testid="loader"]', {
state: 'hidden',
});
const entityResponse = page.waitForResponse(
`/api/v1/tables/name/*.order_items?**`
);
@ -128,9 +160,10 @@ class PostgresIngestionClass extends ServiceBaseClass {
await page.getByRole('tab', { name: 'Queries' }).click();
await page.waitForSelector(
'[data-testid="queries-container"] >> text=selectQuery'
);
// Need to connect to postgres db to get the query log
// await page.waitForSelector(
// '[data-testid="queries-container"] >> text=selectQuery'
// );
await page.click('[data-testid="schema"]');
await page.waitForSelector('[data-testid="related-tables-data"]');

View File

@ -11,10 +11,20 @@
* limitations under the License.
*/
import { expect, Page } from '@playwright/test';
import {
expect,
Page,
PlaywrightTestArgs,
PlaywrightWorkerArgs,
TestType,
} from '@playwright/test';
import { DBT, HTTP_CONFIG_SOURCE, REDSHIFT } from '../../../constant/service';
import { SidebarItem } from '../../../constant/sidebar';
import { redirectToHomePage } from '../../../utils/common';
import {
getApiContext,
redirectToHomePage,
toastNotification,
} from '../../../utils/common';
import { visitEntityPage } from '../../../utils/entity';
import { visitServiceDetailsPage } from '../../../utils/service';
import {
@ -75,12 +85,14 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
.fill(this.schemaFilterPattern);
await page.locator('#root\\/schemaFilterPattern\\/includes').press('Enter');
await page.click('#root\\/includeViews');
}
async runAdditionalTests(test) {
test('Add DBT ingestion', async ({ page }) => {
async runAdditionalTests(
page: Page,
test: TestType<PlaywrightTestArgs, PlaywrightWorkerArgs>
) {
await test.step('Add DBT ingestion', async () => {
const { apiContext } = await getApiContext(page);
await redirectToHomePage(page);
await visitServiceDetailsPage(
page,
@ -94,6 +106,7 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
await page.click('[data-testid="ingestions"]');
await page.waitForSelector('[data-testid="ingestion-details-container"]');
await page.waitForTimeout(1000);
await page.click('[data-testid="add-new-ingestion-button"]');
await page.waitForTimeout(1000);
await page.click('[data-menu-id*="dbt"]');
@ -115,23 +128,41 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
'#root\\/dbtConfigSource\\/dbtRunResultsHttpPath',
HTTP_CONFIG_SOURCE.DBT_RUN_RESULTS_FILE_PATH
);
const deployResponse = page.waitForResponse(
'/api/v1/services/ingestionPipelines/deploy/*'
);
await page.click('[data-testid="submit-btn"]');
await page.click('[data-testid="deploy-button"]');
await deployResponse;
// Make sure we create ingestion with None schedule to avoid conflict between Airflow and Argo behavior
await this.scheduleIngestion(page);
await page.click('[data-testid="view-service-button"]');
await page.waitForResponse(
'**/api/v1/services/ingestionPipelines/status'
// Header available once page loads
await page.waitForSelector('[data-testid="data-assets-header"]');
await page.getByTestId('loader').waitFor({ state: 'detached' });
await page.getByTestId('ingestions').click();
await page
.getByLabel('Ingestions')
.getByTestId('loader')
.waitFor({ state: 'detached' });
const response = await apiContext
.get(
`/api/v1/services/ingestionPipelines?service=${encodeURIComponent(
this.serviceName
)}&pipelineType=dbt&serviceType=databaseService&limit=1`
)
.then((res) => res.json());
await page.click(
`[data-row-key*="${response.data[0].name}"] [data-testid="more-actions"]`
);
await page.getByTestId('run-button').click();
await toastNotification(page, `Pipeline triggered successfully!`);
await this.handleIngestionRetry('dbt', page);
});
test('Validate DBT is ingested properly', async ({ page }) => {
await test.step('Validate DBT is ingested properly', async () => {
await sidebarClick(page, SidebarItem.TAGS);
await page.waitForSelector('[data-testid="data-summary-container"]');
@ -153,16 +184,18 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
await visitEntityPage({
page,
searchTerm: REDSHIFT.DBTTable,
dataTestId: `${REDSHIFT.serviceName}.${REDSHIFT.DBTTable}`,
dataTestId: `${REDSHIFT.serviceName}-${REDSHIFT.DBTTable}`,
});
// Verify tags
await page.waitForSelector('[data-testid="entity-tags"]');
const entityTagsText = await page.textContent(
'[data-testid="entity-tags"]'
);
expect(entityTagsText).toContain(DBT.tagName);
await expect(
page
.getByTestId('entity-right-panel')
.getByTestId('tags-container')
.getByTestId('entity-tags')
).toContainText(DBT.tagName);
// Verify DBT tab is present
await page.click('[data-testid="dbt"]');
@ -186,25 +219,15 @@ class RedshiftWithDBTIngestionClass extends ServiceBaseClass {
await page.click('[data-testid="profiler"]');
await page.waitForSelector('[data-testid="profiler-tab-left-panel"]');
const profilerTabLeftPanelText = await page.textContent(
'[data-testid="profiler-tab-left-panel"]'
await page.getByRole('menuitem', { name: 'Data Quality' }).click();
await expect(page.getByTestId(DBT.dataQualityTest1)).toHaveText(
DBT.dataQualityTest1
);
expect(profilerTabLeftPanelText).toContain('Data Quality');
await page.waitForSelector(`[data-testid=${DBT.dataQualityTest1}]`);
const dataQualityTest1Text = await page.textContent(
`[data-testid=${DBT.dataQualityTest1}]`
await expect(page.getByTestId(DBT.dataQualityTest1)).toHaveText(
DBT.dataQualityTest1
);
expect(dataQualityTest1Text).toContain(DBT.dataQualityTest1);
await page.waitForSelector(`[data-testid=${DBT.dataQualityTest2}]`);
const dataQualityTest2Text = await page.textContent(
`[data-testid=${DBT.dataQualityTest2}]`
);
expect(dataQualityTest2Text).toContain(DBT.dataQualityTest2);
});
}

View File

@ -24,7 +24,7 @@ class S3IngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.Storage,
`pw-s3-storage-${uuid()}`,
`pw-s3-storage-with-%-${uuid()}`,
'S3',
'awsathena-database'
);

View File

@ -199,7 +199,7 @@ class ServiceBaseClass {
await page.waitForSelector('[data-testid="cron-type"]');
await page.click('[data-testid="cron-type"]');
await page.waitForSelector('.ant-select-item-option-content');
await page.click('.ant-select-item-option-content:has-text("Hour")');
await page.click('.ant-select-item-option-content:has-text("None")');
const deployPipelinePromise = page.waitForRequest(
`/api/v1/services/ingestionPipelines/deploy/**`
@ -221,9 +221,6 @@ class ServiceBaseClass {
// Queued status are not stored in DB. cc: @ulixius9
await page.waitForTimeout(2000);
await expect
.poll(
async () => {
const response = await apiContext
.get(
`/api/v1/services/ingestionPipelines?fields=pipelineStatuses&service=${
@ -234,7 +231,24 @@ class ServiceBaseClass {
)
.then((res) => res.json());
return response.data[0]?.pipelineStatuses?.pipelineState;
const workflowData = response.data.filter(
(d) => d.pipelineType === ingestionType
)[0];
const oneHourBefore = Date.now() - 86400000;
await expect
.poll(
async () => {
const response = await apiContext
.get(
`/api/v1/services/ingestionPipelines/${encodeURIComponent(
workflowData.fullyQualifiedName
)}/pipelineStatus?startTs=${oneHourBefore}&endTs=${Date.now()}`
)
.then((res) => res.json());
return response.data[0]?.pipelineState;
},
{
// Custom expect message for reporting, optional.
@ -243,7 +257,8 @@ class ServiceBaseClass {
intervals: [30_000, 15_000, 5_000],
}
)
.toBe('success');
// To allow partial success
.toContain('success');
const pipelinePromise = page.waitForRequest(
`/api/v1/services/ingestionPipelines?**`
@ -264,9 +279,12 @@ class ServiceBaseClass {
await page.click('[data-testid="ingestions"]');
await page.waitForSelector(`td:has-text("${ingestionType}")`);
await expect(page.getByTestId('pipeline-status').last()).toContainText(
'SUCCESS'
);
await expect(
page
.locator(`[data-row-key*="${workflowData.name}"]`)
.getByTestId('pipeline-status')
.last()
).toContainText('SUCCESS');
};
async updateService(page: Page) {
@ -442,6 +460,7 @@ class ServiceBaseClass {
}
async runAdditionalTests(
_page: Page,
_test: TestType<PlaywrightTestArgs, PlaywrightWorkerArgs>
) {
// Write service specific tests

View File

@ -22,7 +22,12 @@ import ServiceBaseClass from './ServiceBaseClass';
class SnowflakeIngestionClass extends ServiceBaseClass {
schema: string;
constructor() {
super(Services.Database, `pw-snowflake-${uuid()}`, 'Snowflake', 'CUSTOMER');
super(
Services.Database,
`pw-snowflake-with-%-${uuid()}`,
'Snowflake',
'CUSTOMER'
);
this.schema = 'TPCH_SF1000';
}

View File

@ -21,7 +21,7 @@ class SupersetIngestionClass extends ServiceBaseClass {
constructor() {
super(
Services.Dashboard,
`pw-Superset-${uuid()}`,
`pw-Superset-with-%-${uuid()}`,
'Superset',
"World Bank's Data"
);

View File

@ -118,7 +118,7 @@ export const testConnection = async (page: Page) => {
await page.waitForSelector('[data-testid="success-badge"]', {
state: 'attached',
timeout: 2 * 60 * 1000,
timeout: 2.5 * 60 * 1000,
});
await expect(page.getByTestId('messag-text')).toContainText(

View File

@ -17,6 +17,10 @@ import {
} from '../constant/settings';
import { SidebarItem, SIDEBAR_LIST_ITEMS } from '../constant/sidebar';
export type SettingOptionsType =
| keyof typeof SETTINGS_OPTIONS_PATH
| keyof typeof SETTING_CUSTOM_PROPERTIES_PATH;
export const clickOnLogo = async (page: Page) => {
await page.click('#openmetadata_logo > [data-testid="image"]');
await page.mouse.move(1280, 0); // Move mouse to top right corner
@ -38,7 +42,7 @@ export const sidebarClick = async (page: Page, id: string) => {
export const settingClick = async (
page: Page,
dataTestId: string,
dataTestId: SettingOptionsType,
isCustomProperty?: boolean
) => {
let paths = SETTINGS_OPTIONS_PATH[dataTestId];