From 8d81c0068d796aa0c8195356b4b9c500109aba3a Mon Sep 17 00:00:00 2001 From: Shailesh Parmar Date: Tue, 11 Jun 2024 16:57:09 +0530 Subject: [PATCH] playwright: migrate entity, database and service spec (#16584) * playwright: migrating entity spec * added test for customProperty * added custom property test * add boolean for slow test * add comment --- .../ui/playwright/constant/customProperty.ts | 33 ++ .../ui/playwright/e2e/Pages/Entity.spec.ts | 72 ++-- .../e2e/Pages/ServiceEntity.spec.ts | 157 ++++++++ .../support/entity/DatabaseClass.ts | 113 ++++++ .../support/entity/DatabaseSchemaClass.ts | 130 +++++++ .../support/entity/Entity.interface.ts | 16 + .../playwright/support/entity/EntityClass.ts | 88 ++++- .../support/entity/StoredProcedureClass.ts | 130 +++++++ .../playwright/support/glossary/Glossary.ts | 12 +- .../support/glossary/GlossaryTerm.ts | 12 +- .../ui/playwright/utils/customProperty.ts | 368 ++++++++++++++++++ .../resources/ui/playwright/utils/domain.ts | 3 + .../resources/ui/playwright/utils/entity.ts | 4 + 13 files changed, 1100 insertions(+), 38 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts new file mode 100644 index 00000000000..632f3dd8ad2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ + +import { EntityTypeEndpoint } from '../support/entity/Entity.interface'; + +export const CustomPropertySupportedEntityList = [ + EntityTypeEndpoint.Database, + EntityTypeEndpoint.DatabaseSchema, + EntityTypeEndpoint.Table, + EntityTypeEndpoint.StoreProcedure, + EntityTypeEndpoint.Topic, + EntityTypeEndpoint.Dashboard, + EntityTypeEndpoint.Pipeline, + EntityTypeEndpoint.Container, + EntityTypeEndpoint.MlModel, + EntityTypeEndpoint.GlossaryTerm, + EntityTypeEndpoint.SearchIndex, +]; + +export const ENTITY_REFERENCE_PROPERTIES = [ + 'Entity Reference', + 'Entity Reference List', +]; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts index c5f42b87e88..6cbccd6e60c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { test } from '@playwright/test'; -import { ENTITIES_WITHOUT_FOLLOWING_BUTTON } from '../../constant/delete'; +import { CustomPropertySupportedEntityList } from '../../constant/customProperty'; import { ContainerClass } from '../../support/entity/ContainerClass'; import { DashboardClass } from '../../support/entity/DashboardClass'; import { DashboardDataModelClass } from '../../support/entity/DashboardDataModelClass'; @@ -19,13 +19,7 @@ import { EntityDataClass } from '../../support/entity/EntityDataClass'; import { MlModelClass } from '../../support/entity/MlModelClass'; import { PipelineClass } from '../../support/entity/PipelineClass'; import { SearchIndexClass } from '../../support/entity/SearchIndexClass'; -import { DashboardServiceClass } from '../../support/entity/service/DashboardServiceClass'; -import { DatabaseServiceClass } from '../../support/entity/service/DatabaseServiceClass'; -import { MessagingServiceClass } from '../../support/entity/service/MessagingServiceClass'; -import { MlmodelServiceClass } from '../../support/entity/service/MlmodelServiceClass'; -import { PipelineServiceClass } from '../../support/entity/service/PipelineServiceClass'; -import { SearchIndexServiceClass } from '../../support/entity/service/SearchIndexServiceClass'; -import { StorageServiceClass } from '../../support/entity/service/StorageServiceClass'; +import { StoredProcedureClass } from '../../support/entity/StoredProcedureClass'; import { TableClass } from '../../support/entity/TableClass'; import { TopicClass } from '../../support/entity/TopicClass'; import { @@ -34,16 +28,11 @@ import { getToken, redirectToHomePage, } from '../../utils/common'; +import { CustomPropertyTypeByName } from '../../utils/customProperty'; const entities = [ - DatabaseServiceClass, - DashboardServiceClass, - MessagingServiceClass, - MlmodelServiceClass, - PipelineServiceClass, - SearchIndexServiceClass, - StorageServiceClass, TableClass, + StoredProcedureClass, DashboardClass, PipelineClass, TopicClass, @@ -59,9 +48,6 @@ test.use({ storageState: 'playwright/.auth/admin.json' }); entities.forEach((EntityClass) => { const entity = new EntityClass(); const deleteEntity = new EntityClass(); - const allowFollowUnfollowTest = !ENTITIES_WITHOUT_FOLLOWING_BUTTON.includes( - entity.endpoint - ); test.describe(entity.getType(), () => { test.beforeAll('Setup pre-requests', async ({ browser }) => { @@ -69,6 +55,7 @@ entities.forEach((EntityClass) => { await EntityDataClass.preRequisitesForTests(apiContext); await entity.create(apiContext); + await entity.prepareForTests(apiContext); await afterAction(); }); @@ -128,15 +115,44 @@ entities.forEach((EntityClass) => { await entity.inactiveAnnouncement(page); }); - if (allowFollowUnfollowTest) { - test(`UpVote & DownVote entity`, async ({ page }) => { - await entity.upVote(page); - await entity.downVote(page); - }); + test(`UpVote & DownVote entity`, async ({ page }) => { + await entity.upVote(page); + await entity.downVote(page); + }); - test(`Follow & Un-follow entity`, async ({ page }) => { - const entityName = entity.entityResponseData?.['displayName']; - await entity.followUnfollowEntity(page, entityName); + test(`Follow & Un-follow entity`, async ({ page }) => { + const entityName = entity.entityResponseData?.['displayName']; + await entity.followUnfollowEntity(page, entityName); + }); + + // Create custom property only for supported entities + if (CustomPropertySupportedEntityList.includes(entity.endpoint)) { + const properties = Object.values(CustomPropertyTypeByName); + const titleText = properties.join(', '); + + test(`Set & Update ${titleText} Custom Property `, async ({ page }) => { + // increase timeout as it using single test for multiple steps + test.slow(true); + + await test.step(`Set ${titleText} Custom Property`, async () => { + for (const type of properties) { + await entity.setCustomProperty( + page, + entity.customPropertyValue[type].property, + entity.customPropertyValue[type].value + ); + } + }); + + await test.step(`Update ${titleText} Custom Property`, async () => { + for (const type of properties) { + await entity.updateCustomProperty( + page, + entity.customPropertyValue[type].property, + entity.customPropertyValue[type].newValue + ); + } + }); }); } @@ -146,6 +162,7 @@ entities.forEach((EntityClass) => { test.afterAll('Cleanup', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); + await entity.cleanup(apiContext); await entity.delete(apiContext); await EntityDataClass.postRequisitesForTests(apiContext); await afterAction(); @@ -153,6 +170,9 @@ entities.forEach((EntityClass) => { }); test(`Delete ${deleteEntity.getType()}`, async ({ page }) => { + // increase timeout as it using single test for multiple steps + test.slow(true); + await redirectToHomePage(page); // get the token from localStorage const token = await getToken(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts new file mode 100644 index 00000000000..9b3a3c5df98 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts @@ -0,0 +1,157 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ +import { test } from '@playwright/test'; +import { DatabaseClass } from '../../support/entity/DatabaseClass'; +import { DatabaseSchemaClass } from '../../support/entity/DatabaseSchemaClass'; +import { EntityDataClass } from '../../support/entity/EntityDataClass'; +import { DashboardServiceClass } from '../../support/entity/service/DashboardServiceClass'; +import { DatabaseServiceClass } from '../../support/entity/service/DatabaseServiceClass'; +import { MessagingServiceClass } from '../../support/entity/service/MessagingServiceClass'; +import { MlmodelServiceClass } from '../../support/entity/service/MlmodelServiceClass'; +import { PipelineServiceClass } from '../../support/entity/service/PipelineServiceClass'; +import { SearchIndexServiceClass } from '../../support/entity/service/SearchIndexServiceClass'; +import { StorageServiceClass } from '../../support/entity/service/StorageServiceClass'; +import { + createNewPage, + getAuthContext, + getToken, + redirectToHomePage, +} from '../../utils/common'; + +const entities = [ + DatabaseServiceClass, + DashboardServiceClass, + MessagingServiceClass, + MlmodelServiceClass, + PipelineServiceClass, + SearchIndexServiceClass, + StorageServiceClass, + DatabaseClass, + DatabaseSchemaClass, +] as const; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +entities.forEach((EntityClass) => { + const entity = new EntityClass(); + const deleteEntity = new EntityClass(); + + test.describe(entity.getType(), () => { + test.beforeAll('Setup pre-requests', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + + await EntityDataClass.preRequisitesForTests(apiContext); + await entity.create(apiContext); + await afterAction(); + }); + + test.beforeEach('Visit entity details page', async ({ page }) => { + await redirectToHomePage(page); + await entity.visitEntityPage(page); + }); + + test('Domain Add, Update and Remove', async ({ page }) => { + await entity.domain( + page, + EntityDataClass.domain1.responseData, + EntityDataClass.domain2.responseData + ); + }); + + test('User as Owner Add, Update and Remove', async ({ page }) => { + const OWNER1 = EntityDataClass.user1.getUserName(); + const OWNER2 = EntityDataClass.user2.getUserName(); + await entity.owner(page, OWNER1, OWNER2); + }); + + test('Team as Owner Add, Update and Remove', async ({ page }) => { + const OWNER1 = EntityDataClass.team1.data.displayName; + const OWNER2 = EntityDataClass.team2.data.displayName; + await entity.owner(page, OWNER1, OWNER2, 'Teams'); + }); + + test('Tier Add, Update and Remove', async ({ page }) => { + await entity.tier(page, 'Tier1', 'Tier5'); + }); + + test('Update description', async ({ page }) => { + await entity.descriptionUpdate(page); + }); + + test('Tag Add, Update and Remove', async ({ page }) => { + await entity.tag(page, 'PersonalData.Personal', 'PII.None'); + }); + + test('Glossary Term Add, Update and Remove', async ({ page }) => { + await entity.glossaryTerm( + page, + EntityDataClass.glossaryTerm1.responseData, + EntityDataClass.glossaryTerm2.responseData + ); + }); + + test(`Announcement create & delete`, async ({ page }) => { + await entity.announcement( + page, + entity.entityResponseData?.['fullyQualifiedName'] + ); + }); + + test(`Inactive Announcement create & delete`, async ({ page }) => { + await entity.inactiveAnnouncement(page); + }); + + test(`Update displayName`, async ({ page }) => { + await entity.renameEntity(page, entity.entity.name); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await entity.delete(apiContext); + await EntityDataClass.postRequisitesForTests(apiContext); + await afterAction(); + }); + }); + + test(`Delete ${deleteEntity.getType()}`, async ({ page }) => { + // increase timeout as it using single test for multiple steps + test.slow(true); + + await redirectToHomePage(page); + // get the token from localStorage + const token = await getToken(page); + + // create a new context with the token + const apiContext = await getAuthContext(token); + await deleteEntity.create(apiContext); + await redirectToHomePage(page); + await deleteEntity.visitEntityPage(page); + + await test.step('Soft delete', async () => { + await deleteEntity.softDeleteEntity( + page, + deleteEntity.entity.name, + deleteEntity.entityResponseData?.['displayName'] + ); + }); + + await test.step('Hard delete', async () => { + await deleteEntity.hardDeleteEntity( + page, + deleteEntity.entity.name, + deleteEntity.entityResponseData?.['displayName'] + ); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts new file mode 100644 index 00000000000..0ee6d1c8f0f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseClass.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ +import { APIRequestContext, Page } from '@playwright/test'; +import { SERVICE_TYPE } from '../../constant/service'; +import { uuid } from '../../utils/common'; +import { visitServiceDetailsPage } from '../../utils/service'; +import { EntityTypeEndpoint } from './Entity.interface'; +import { EntityClass } from './EntityClass'; + +export class DatabaseClass extends EntityClass { + service = { + name: `pw-database-service-${uuid()}`, + serviceType: 'Mysql', + connection: { + config: { + type: 'Mysql', + scheme: 'mysql+pymysql', + username: 'username', + authType: { + password: 'password', + }, + hostPort: 'mysql:3306', + supportsMetadataExtraction: true, + supportsDBTExtraction: true, + supportsProfiler: true, + supportsQueryComment: true, + }, + }, + }; + entity = { + name: `pw-database-${uuid()}`, + service: this.service.name, + }; + + serviceResponseData: unknown; + entityResponseData: unknown; + + constructor(name?: string) { + super(EntityTypeEndpoint.Database); + this.service.name = name ?? this.service.name; + this.type = 'Database'; + } + + async create(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.post( + '/api/v1/services/databaseServices', + { + data: this.service, + } + ); + const entityResponse = await apiContext.post('/api/v1/databases', { + data: this.entity, + }); + + const service = await serviceResponse.json(); + const entity = await entityResponse.json(); + + this.serviceResponseData = service; + this.entityResponseData = entity; + + return { + service, + entity, + }; + } + + get() { + return { + service: this.serviceResponseData, + entity: this.entityResponseData, + }; + } + + async visitEntityPage(page: Page) { + await visitServiceDetailsPage( + page, + { + name: this.service.name, + type: SERVICE_TYPE.Database, + }, + false + ); + + const databaseResponse = page.waitForResponse( + `/api/v1/databases/name/*${this.entity.name}?**` + ); + await page.getByTestId(this.entity.name).click(); + await databaseResponse; + } + + async delete(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.delete( + `/api/v1/services/databaseServices/name/${encodeURIComponent( + this.serviceResponseData?.['fullyQualifiedName'] + )}?recursive=true&hardDelete=true` + ); + + return { + service: serviceResponse.body, + entity: this.entityResponseData, + }; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts new file mode 100644 index 00000000000..39ad636a493 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DatabaseSchemaClass.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ +import { APIRequestContext, Page } from '@playwright/test'; +import { SERVICE_TYPE } from '../../constant/service'; +import { uuid } from '../../utils/common'; +import { visitServiceDetailsPage } from '../../utils/service'; +import { EntityTypeEndpoint } from './Entity.interface'; +import { EntityClass } from './EntityClass'; + +export class DatabaseSchemaClass extends EntityClass { + service = { + name: `pw-database-service-${uuid()}`, + serviceType: 'Mysql', + connection: { + config: { + type: 'Mysql', + scheme: 'mysql+pymysql', + username: 'username', + authType: { + password: 'password', + }, + hostPort: 'mysql:3306', + supportsMetadataExtraction: true, + supportsDBTExtraction: true, + supportsProfiler: true, + supportsQueryComment: true, + }, + }, + }; + database = { + name: `pw-database-${uuid()}`, + service: this.service.name, + }; + entity = { + name: `pw-database-schema-${uuid()}`, + database: `${this.service.name}.${this.database.name}`, + }; + + serviceResponseData: unknown; + databaseResponseData: unknown; + entityResponseData: unknown; + + constructor(name?: string) { + super(EntityTypeEndpoint.DatabaseSchema); + this.service.name = name ?? this.service.name; + this.type = 'Database Schema'; + } + + async create(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.post( + '/api/v1/services/databaseServices', + { + data: this.service, + } + ); + const databaseResponse = await apiContext.post('/api/v1/databases', { + data: this.database, + }); + const entityResponse = await apiContext.post('/api/v1/databaseSchemas', { + data: this.entity, + }); + + const service = await serviceResponse.json(); + const database = await databaseResponse.json(); + const entity = await entityResponse.json(); + + this.serviceResponseData = service; + this.databaseResponseData = database; + this.entityResponseData = entity; + + return { + service, + database, + entity, + }; + } + + get() { + return { + service: this.serviceResponseData, + database: this.databaseResponseData, + entity: this.entityResponseData, + }; + } + + async visitEntityPage(page: Page) { + await visitServiceDetailsPage( + page, + { + name: this.service.name, + type: SERVICE_TYPE.Database, + }, + false + ); + + const databaseResponse = page.waitForResponse( + `/api/v1/databases/name/*${this.database.name}?**` + ); + await page.getByTestId(this.database.name).click(); + await databaseResponse; + const databaseSchemaResponse = page.waitForResponse( + `/api/v1/databaseSchemas/name/*${this.entity}?*` + ); + await page.getByTestId(this.entity.name).click(); + await databaseSchemaResponse; + } + + async delete(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.delete( + `/api/v1/services/databaseServices/name/${encodeURIComponent( + this.serviceResponseData?.['fullyQualifiedName'] + )}?recursive=true&hardDelete=true` + ); + + return { + service: serviceResponse.body, + entity: this.entityResponseData, + }; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts index d262f0fb20e..0c91f048c5b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/Entity.interface.ts @@ -41,3 +41,19 @@ export type EntityDataType = { entityDetails: unknown; endPoint: EntityTypeEndpoint; }; + +export enum ENTITY_PATH { + tables = 'table', + topics = 'topic', + dashboards = 'dashboard', + pipelines = 'pipeline', + mlmodels = 'mlmodel', + containers = 'container', + tags = 'tag', + glossaries = 'glossary', + searchIndexes = 'searchIndex', + storedProcedures = 'storedProcedure', + glossaryTerm = 'glossaryTerm', + databases = 'database', + databaseSchemas = 'databaseSchema', +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts index cfe34233925..feb571389ab 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts @@ -10,7 +10,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Page } from '@playwright/test'; +import { APIRequestContext, Page } from '@playwright/test'; +import { CustomPropertySupportedEntityList } from '../../constant/customProperty'; +import { + createCustomPropertyForEntity, + CustomProperty, + setValueForProperty, + validateValueForProperty, +} from '../../utils/customProperty'; import { assignDomain, removeDomain, updateDomain } from '../../utils/domain'; import { addOwner, @@ -38,12 +45,17 @@ import { } from '../../utils/entity'; import { Domain } from '../domain/Domain'; import { GlossaryTerm } from '../glossary/GlossaryTerm'; -import { EntityTypeEndpoint } from './Entity.interface'; +import { EntityTypeEndpoint, ENTITY_PATH } from './Entity.interface'; export class EntityClass { type: string; endpoint: EntityTypeEndpoint; + customPropertyValue: Record< + string, + { value: string; newValue: string; property: CustomProperty } + >; + constructor(endpoint: EntityTypeEndpoint) { this.endpoint = endpoint; } @@ -57,6 +69,40 @@ export class EntityClass { // Override for entity visit } + // Prepare for tests + async prepareForTests(apiContext: APIRequestContext) { + // Create custom property only for supported entities + if (CustomPropertySupportedEntityList.includes(this.endpoint)) { + const data = await createCustomPropertyForEntity( + apiContext, + this.endpoint + ); + + this.customPropertyValue = data; + } + } + + async cleanup(apiContext: APIRequestContext) { + // Delete custom property only for supported entities + if (CustomPropertySupportedEntityList.includes(this.endpoint)) { + const entitySchemaResponse = await apiContext.get( + `/api/v1/metadata/types/name/${ENTITY_PATH[this.endpoint]}` + ); + const entitySchema = await entitySchemaResponse.json(); + await apiContext.patch(`/api/v1/metadata/types/${entitySchema.id}`, { + data: [ + { + op: 'remove', + path: '/customProperties', + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + }); + } + } + async domain( page: Page, domain1: Domain['responseData'], @@ -173,4 +219,42 @@ export class EntityClass { async hardDeleteEntity(page: Page, entityName: string, displayName?: string) { await hardDeleteEntity(page, displayName ?? entityName, this.endpoint); } + + async setCustomProperty( + page: Page, + propertydetails: CustomProperty, + value: string + ) { + await setValueForProperty({ + page, + propertyName: propertydetails.name, + value, + propertyType: propertydetails.propertyType.name, + }); + await validateValueForProperty({ + page, + propertyName: propertydetails.name, + value, + propertyType: propertydetails.propertyType.name, + }); + } + + async updateCustomProperty( + page: Page, + propertydetails: CustomProperty, + value: string + ) { + await setValueForProperty({ + page, + propertyName: propertydetails.name, + value, + propertyType: propertydetails.propertyType.name, + }); + await validateValueForProperty({ + page, + propertyName: propertydetails.name, + value, + propertyType: propertydetails.propertyType.name, + }); + } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts new file mode 100644 index 00000000000..5b385defa2e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/StoredProcedureClass.ts @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ +import { APIRequestContext, Page } from '@playwright/test'; +import { uuid } from '../../utils/common'; +import { visitEntityPage } from '../../utils/entity'; +import { EntityTypeEndpoint } from './Entity.interface'; +import { EntityClass } from './EntityClass'; + +export class StoredProcedureClass extends EntityClass { + service = { + name: `pw-database-service-${uuid()}`, + serviceType: 'Mysql', + connection: { + config: { + type: 'Mysql', + scheme: 'mysql+pymysql', + username: 'username', + authType: { + password: 'password', + }, + hostPort: 'mysql:3306', + supportsMetadataExtraction: true, + supportsDBTExtraction: true, + supportsProfiler: true, + supportsQueryComment: true, + }, + }, + }; + database = { + name: `pw-database-${uuid()}`, + service: this.service.name, + }; + schema = { + name: `pw-database-schema-${uuid()}`, + database: `${this.service.name}.${this.database.name}`, + }; + entity = { + name: `pw-stored-procedure-${uuid()}`, + databaseSchema: `${this.service.name}.${this.database.name}.${this.schema.name}`, + storedProcedureCode: { + code: 'CREATE OR REPLACE PROCEDURE output_message(message VARCHAR)\nRETURNS VARCHAR NOT NULL\nLANGUAGE SQL\nAS\n$$\nBEGIN\n RETURN message;\nEND;\n$$\n;', + }, + }; + + serviceResponseData: unknown; + databaseResponseData: unknown; + schemaResponseData: unknown; + entityResponseData: unknown; + + constructor(name?: string) { + super(EntityTypeEndpoint.StoreProcedure); + this.service.name = name ?? this.service.name; + this.type = 'Store Procedure'; + } + + async create(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.post( + '/api/v1/services/databaseServices', + { + data: this.service, + } + ); + const databaseResponse = await apiContext.post('/api/v1/databases', { + data: this.database, + }); + const schemaResponse = await apiContext.post('/api/v1/databaseSchemas', { + data: this.schema, + }); + const entityResponse = await apiContext.post('/api/v1/storedProcedures', { + data: this.entity, + }); + + const service = await serviceResponse.json(); + const database = await databaseResponse.json(); + const schema = await schemaResponse.json(); + const entity = await entityResponse.json(); + + this.serviceResponseData = service; + this.databaseResponseData = database; + this.schemaResponseData = schema; + this.entityResponseData = entity; + + return { + service, + database, + schema, + entity, + }; + } + + get() { + return { + service: this.serviceResponseData, + database: this.databaseResponseData, + schema: this.schemaResponseData, + entity: this.entityResponseData, + }; + } + + async visitEntityPage(page: Page) { + await visitEntityPage({ + page, + searchTerm: this.entityResponseData?.['fullyQualifiedName'], + dataTestId: `${this.service.name}-${this.entity.name}`, + }); + } + + async delete(apiContext: APIRequestContext) { + const serviceResponse = await apiContext.delete( + `/api/v1/services/databaseServices/name/${encodeURIComponent( + this.serviceResponseData?.['fullyQualifiedName'] + )}?recursive=true&hardDelete=true` + ); + + return { + service: serviceResponse.body, + entity: this.entityResponseData, + }; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts index 84952336fa9..9343893d1fd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts @@ -12,6 +12,7 @@ */ import { APIRequestContext } from '@playwright/test'; import { uuid } from '../../utils/common'; +import { getRandomFirstName } from '../../utils/user'; type ResponseDataType = { name: string; @@ -25,9 +26,10 @@ type ResponseDataType = { }; export class Glossary { + randomName = getRandomFirstName(); data = { - name: `PW%General.${uuid()}`, - displayName: `PW % General ${uuid()}`, + name: `PW%${uuid()}.${this.randomName}`, + displayName: `PW % ${uuid()} ${this.randomName}`, description: 'Glossary terms that describe general conceptual terms. **Note that these conceptual terms are used for automatically labeling the data.**', reviewers: [], @@ -48,10 +50,10 @@ export class Glossary { this.responseData = await response.json(); - return response.body; + return await response.json(); } - async get() { + get() { return this.responseData; } @@ -62,6 +64,6 @@ export class Glossary { )}?recursive=true&hardDelete=true` ); - return response.body; + return await response.json(); } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/GlossaryTerm.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/GlossaryTerm.ts index 35518b5b5c6..64a3edf485d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/GlossaryTerm.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/GlossaryTerm.ts @@ -12,6 +12,7 @@ */ import { APIRequestContext } from '@playwright/test'; import { uuid } from '../../utils/common'; +import { getRandomLastName } from '../../utils/user'; type ResponseDataType = { name: string; @@ -28,9 +29,10 @@ type ResponseDataType = { }; export class GlossaryTerm { + randomName = getRandomLastName(); data = { - name: `PW.Bank%Number-${uuid()}`, - displayName: `PW BankNumber ${uuid()}`, + name: `PW.${uuid()}%${this.randomName}`, + displayName: `PW ${uuid()}%${this.randomName}`, description: 'A bank account number.', mutuallyExclusive: false, glossary: '', @@ -50,10 +52,10 @@ export class GlossaryTerm { this.responseData = await response.json(); - return response.body; + return await response.json(); } - async get() { + get() { return this.responseData; } @@ -64,6 +66,6 @@ export class GlossaryTerm { )}?recursive=true&hardDelete=true` ); - return response.body; + return await response.json(); } } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts new file mode 100644 index 00000000000..0af614ad229 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts @@ -0,0 +1,368 @@ +/* + * Copyright 2024 Collate. + * Licensed 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. + */ +import { APIRequestContext, expect, Page } from '@playwright/test'; +import { + EntityTypeEndpoint, + ENTITY_PATH, +} from '../support/entity/Entity.interface'; +import { uuid } from './common'; + +export enum CustomPropertyType { + STRING = 'String', + INTEGER = 'Integer', + MARKDOWN = 'Markdown', +} +export enum CustomPropertyTypeByName { + STRING = 'string', + INTEGER = 'integer', + MARKDOWN = 'markdown', + NUMBER = 'number', + DURATION = 'duration', + EMAIL = 'email', + ENUM = 'enum', + SQL_QUERY = 'sqlQuery', + TIMESTAMP = 'timestamp', + ENTITY_REFERENCE = 'entityReference', + ENTITY_REFERENCE_LIST = 'entityReferenceList', +} + +export interface CustomProperty { + name: string; + type: CustomPropertyType; + description: string; + propertyType: { + name: string; + type: string; + }; +} + +export const setValueForProperty = async (data: { + page: Page; + propertyName: string; + value: string; + propertyType: string; +}) => { + const { page, propertyName, value, propertyType } = data; + await page.click('[data-testid="custom_properties"]'); + + await expect(page.getByRole('cell', { name: propertyName })).toContainText( + propertyName + ); + + const editButton = page.locator( + `[data-row-key="${propertyName}"] [data-testid="edit-icon"]` + ); + await editButton.scrollIntoViewIfNeeded(); + await editButton.click({ force: true }); + + switch (propertyType) { + case 'markdown': + await page + .locator( + '.toastui-editor-md-container > .toastui-editor > .ProseMirror' + ) + .isVisible(); + await page + .locator( + '.toastui-editor-md-container > .toastui-editor > .ProseMirror' + ) + .fill(value); + await page.locator('[data-testid="save"]').click(); + + break; + + case 'email': + await page.locator('[data-testid="email-input"]').isVisible(); + await page.locator('[data-testid="email-input"]').fill(value); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'duration': + await page.locator('[data-testid="duration-input"]').isVisible(); + await page.locator('[data-testid="duration-input"]').fill(value); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'enum': + await page.locator('#enumValues').click(); + await page.locator('#enumValues').fill(value); + await page.locator('#enumValues').press('Enter'); + await page.mouse.click(0, 0); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'sqlQuery': + await page.locator("pre[role='presentation']").last().click(); + await page.keyboard.type(value); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'timestamp': + await page.locator('[data-testid="timestamp-input"]').isVisible(); + await page.locator('[data-testid="timestamp-input"]').fill(value); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'timeInterval': { + const [startValue, endValue] = value.split(','); + await page.locator('[data-testid="start-input"]').isVisible(); + await page.locator('[data-testid="start-input"]').fill(startValue); + await page.locator('[data-testid="end-input"]').isVisible(); + await page.locator('[data-testid="end-input"]').fill(endValue); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + } + + case 'string': + case 'integer': + case 'number': + await page.locator('[data-testid="value-input"]').isVisible(); + await page.locator('[data-testid="value-input"]').fill(value); + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + + case 'entityReference': + case 'entityReferenceList': { + const refValues = value.split(','); + + for (const val of refValues) { + const searchApi = `**/api/v1/search/query?q=*${encodeURIComponent( + val + )}*`; + await page.route(searchApi, (route) => route.continue()); + await page.locator('#entityReference').clear(); + await page.locator('#entityReference').fill(val); + await page.waitForResponse(searchApi); + await page.locator(`[data-testid="${val}"]`).click(); + } + + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + } + } + + await page.waitForResponse('/api/v1/*/*'); + if (propertyType === 'enum') { + await expect( + page.getByLabel('Custom Properties').getByTestId('enum-value') + ).toContainText(value); + } else if (propertyType === 'timeInterval') { + const [startValue, endValue] = value.split(','); + + await expect( + page.getByLabel('Custom Properties').getByTestId('time-interval-value') + ).toContainText(startValue); + await expect( + page.getByLabel('Custom Properties').getByTestId('time-interval-value') + ).toContainText(endValue); + } else if (propertyType === 'sqlQuery') { + await expect( + page.getByLabel('Custom Properties').locator('.CodeMirror-scroll') + ).toContainText(value); + } else if ( + !['entityReference', 'entityReferenceList'].includes(propertyType) + ) { + await expect(page.getByRole('row', { name: propertyName })).toContainText( + value.replace(/\*|_/gi, '') + ); + } +}; + +export const validateValueForProperty = async (data: { + page: Page; + propertyName: string; + value: string; + propertyType: string; +}) => { + const { page, propertyName, value, propertyType } = data; + await page.click('[data-testid="custom_properties"]'); + + if (propertyType === 'enum') { + await expect( + page.getByLabel('Custom Properties').getByTestId('enum-value') + ).toContainText(value); + } else if (propertyType === 'timeInterval') { + const [startValue, endValue] = value.split(','); + + await expect( + page.getByLabel('Custom Properties').getByTestId('time-interval-value') + ).toContainText(startValue); + await expect( + page.getByLabel('Custom Properties').getByTestId('time-interval-value') + ).toContainText(endValue); + } else if (propertyType === 'sqlQuery') { + await expect( + page.getByLabel('Custom Properties').locator('.CodeMirror-scroll') + ).toContainText(value); + } else if ( + !['entityReference', 'entityReferenceList'].includes(propertyType) + ) { + await expect(page.getByRole('row', { name: propertyName })).toContainText( + value.replace(/\*|_/gi, '') + ); + } +}; + +export const getPropertyValues = (type: string) => { + switch (type) { + case 'integer': + return { + value: '123', + newValue: '456', + }; + case 'string': + return { + value: 'string value', + newValue: 'new string value', + }; + case 'markdown': + return { + value: '**Bold statement**', + newValue: '__Italic statement__', + }; + + case 'number': + return { + value: '123', + newValue: '456', + }; + case 'duration': + return { + value: 'PT1H', + newValue: 'PT2H', + }; + case 'email': + return { + value: 'john@gamil.com', + newValue: 'user@getcollate.io', + }; + case 'enum': + return { + value: 'small', + newValue: 'medium', + }; + case 'sqlQuery': + return { + value: 'Select * from table', + newValue: 'Select * from table where id = 1', + }; + + case 'timestamp': + return { + value: '1710831125922', + newValue: '1710831125923', + }; + case 'entityReference': + return { + value: 'Adam Matthews', + newValue: 'Aaron Singh', + }; + + case 'entityReferenceList': + return { + value: 'Aaron Johnson,Organization', + newValue: 'Aaron Warren', + }; + + default: + return { + value: '', + newValue: '', + }; + } +}; + +export const createCustomPropertyForEntity = async ( + apiContext: APIRequestContext, + endpoint: EntityTypeEndpoint +) => { + const propertiesResponse = await apiContext.get( + '/api/v1/metadata/types?category=field&limit=20' + ); + const properties = await propertiesResponse.json(); + const propertyList = properties.data.filter((item) => + Object.values(CustomPropertyTypeByName).includes(item.name) + ); + + const entitySchemaResponse = await apiContext.get( + `/api/v1/metadata/types/name/${ENTITY_PATH[endpoint]}` + ); + const entitySchema = await entitySchemaResponse.json(); + + let customProperties = {} as Record< + string, + { + value: string; + newValue: string; + property: CustomProperty; + } + >; + + for (const item of propertyList) { + const customPropertyResponse = await apiContext.put( + `/api/v1/metadata/types/${entitySchema.id}`, + { + data: { + name: `cyCustomProperty${uuid()}`, + description: `cyCustomProperty${uuid()}`, + propertyType: { + id: item.id ?? '', + type: 'type', + }, + ...(item.name === 'enum' + ? { + customPropertyConfig: { + config: { + multiSelect: true, + values: ['small', 'medium', 'large'], + }, + }, + } + : {}), + ...(['entityReference', 'entityReferenceList'].includes(item.name) + ? { + customPropertyConfig: { + config: ['user', 'team'], + }, + } + : {}), + }, + } + ); + + const customProperty = await customPropertyResponse.json(); + + // Process the custom properties + customProperties = customProperty.customProperties.reduce((prev, curr) => { + const propertyTypeName = curr.propertyType.name; + + return { + ...prev, + [propertyTypeName]: { + ...getPropertyValues(propertyTypeName), + property: curr, + }, + }; + }, {}); + } + + return customProperties; +}; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts index da8f1f55edb..0ed948f3d7a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/domain.ts @@ -15,6 +15,7 @@ import { Domain } from '../support/domain/Domain'; export const assignDomain = async (page: Page, domain: Domain['data']) => { await page.getByTestId('add-domain').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await page .getByTestId('selectable-list') .getByTestId('searchbar') @@ -31,6 +32,7 @@ export const assignDomain = async (page: Page, domain: Domain['data']) => { export const updateDomain = async (page: Page, domain: Domain['data']) => { await page.getByTestId('add-domain').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await page.getByTestId('selectable-list').getByTestId('searchbar').clear(); await page .getByTestId('selectable-list') @@ -48,6 +50,7 @@ export const updateDomain = async (page: Page, domain: Domain['data']) => { export const removeDomain = async (page: Page) => { await page.getByTestId('add-domain').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await expect(page.getByTestId('remove-owner').locator('path')).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 2e1db3ba12c..592fec2de3c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -33,6 +33,7 @@ export const visitEntityPage = async (data: { const { page, searchTerm, dataTestId } = data; await page.getByTestId('searchBox').fill(searchTerm); + await page.waitForResponse('/api/v1/search/query?q=*index=dataAsset*'); await page.getByTestId(dataTestId).getByTestId('data-name').click(); await page.getByTestId('searchBox').clear(); }; @@ -98,6 +99,7 @@ export const updateOwner = async ( export const removeOwner = async (page: Page, dataTestId?: string) => { await page.getByTestId('edit-owner').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await expect(page.getByTestId('remove-owner').locator('svg')).toBeVisible(); @@ -110,6 +112,7 @@ export const removeOwner = async (page: Page, dataTestId?: string) => { export const assignTier = async (page: Page, tier: string) => { await page.getByTestId('edit-tier').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await page.getByTestId(`radio-btn-${tier}`).click(); await page.getByTestId('Tier').click(); @@ -118,6 +121,7 @@ export const assignTier = async (page: Page, tier: string) => { export const removeTier = async (page: Page) => { await page.getByTestId('edit-tier').click(); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); await page.getByTestId('clear-tier').click(); await page.getByTestId('Tier').click();