diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/search/lineage/AbstractLineageGraphBuilderTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/search/lineage/AbstractLineageGraphBuilderTest.java index 584a687adf2c..e14e1449d78c 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/search/lineage/AbstractLineageGraphBuilderTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/search/lineage/AbstractLineageGraphBuilderTest.java @@ -739,7 +739,7 @@ private static class TestableLineageGraphBuilder extends AbstractLineageGraphBui } @Override - public int estimateGraphSize(LineageQueryContext context) throws IOException { + public int estimateGraphSize(LineageQueryContext context) { return 0; } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts deleted file mode 100644 index 46798cb5f694..000000000000 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage.spec.ts +++ /dev/null @@ -1,2015 +0,0 @@ -/* - * 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, { expect } from '@playwright/test'; -import { get } from 'lodash'; -import { SidebarItem } from '../../constant/sidebar'; -import { ApiEndpointClass } from '../../support/entity/ApiEndpointClass'; -import { ChartClass } from '../../support/entity/ChartClass'; -import { ContainerClass } from '../../support/entity/ContainerClass'; -import { DashboardClass } from '../../support/entity/DashboardClass'; -import { MetricClass } from '../../support/entity/MetricClass'; -import { MlModelClass } from '../../support/entity/MlModelClass'; -import { PipelineClass } from '../../support/entity/PipelineClass'; -import { SearchIndexClass } from '../../support/entity/SearchIndexClass'; -import { ApiServiceClass } from '../../support/entity/service/ApiServiceClass'; -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 { StorageServiceClass } from '../../support/entity/service/StorageServiceClass'; -import { TableClass } from '../../support/entity/TableClass'; -import { TopicClass } from '../../support/entity/TopicClass'; -import { - clickOutside, - createNewPage, - getApiContext, - redirectToHomePage, - uuid, -} from '../../utils/common'; -import { waitForAllLoadersToDisappear } from '../../utils/entity'; -import { - activateColumnLayer, - addColumnLineage, - addPipelineBetweenNodes, - applyPipelineFromModal, - clickLineageNode, - connectEdgeBetweenNodes, - connectEdgeBetweenNodesViaAPI, - deleteEdge, - deleteNode, - editLineage, - editLineageClick, - performZoomOut, - rearrangeNodes, - removeColumnLineage, - setupEntitiesForLineage, - toggleLineageFilters, - verifyColumnLayerInactive, - verifyColumnLineageInCSV, - verifyExportLineageCSV, - verifyExportLineagePNG, - verifyLineageConfig, - verifyNodePresent, - verifyPlatformLineageForEntity, - visitLineageTab, -} from '../../utils/lineage'; -import { sidebarClick } from '../../utils/sidebar'; - -test.describe.configure({ mode: 'serial' }); - -// use the admin user to login -test.use({ - storageState: 'playwright/.auth/admin.json', -}); - -const entities = [ - TableClass, - DashboardClass, - TopicClass, - MlModelClass, - ContainerClass, - SearchIndexClass, - ApiEndpointClass, - MetricClass, -] as const; - -const pipeline = new PipelineClass(); - -test.beforeAll('Setup pre-requests', async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await pipeline.create(apiContext); - await afterAction(); -}); - -test.afterAll('Cleanup', async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await pipeline.delete(apiContext); - await afterAction(); -}); - -test.beforeEach(async ({ page }) => { - await redirectToHomePage(page); -}); - -for (const EntityClass of entities) { - const defaultEntity = new EntityClass(); - - test.skip(`Lineage creation from ${defaultEntity.getType()} entity`, async ({ - page, - }) => { - // 5 minutes to avoid test timeout happening some times in AUTs - test.setTimeout(300_000); - - const { currentEntity, entities, cleanup } = await setupEntitiesForLineage( - page, - defaultEntity - ); - - try { - await test.step('Should create lineage for the entity', async () => { - await currentEntity.visitEntityPage(page); - - await visitLineageTab(page); - - await verifyColumnLayerInactive(page); - await editLineage(page); - await performZoomOut(page); - for (const entity of entities) { - await connectEdgeBetweenNodes(page, currentEntity, entity); - await rearrangeNodes(page); - } - - const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); - await page.reload(); - await lineageRes; - await page.getByTestId('edit-lineage').waitFor({ - state: 'visible', - }); - - await waitForAllLoadersToDisappear(page); - await performZoomOut(page); - - for (const entity of entities) { - await verifyNodePresent(page, entity); - } - - // Check the Entity Drawer - await performZoomOut(page); - - for (const entity of entities) { - const toNodeFqn = get( - entity, - 'entityResponseData.fullyQualifiedName' - ); - - await clickLineageNode(page, toNodeFqn); - - await expect( - page - .locator('.lineage-entity-panel') - .getByTestId('entity-header-title') - ).toHaveText(get(entity, 'entityResponseData.displayName')); - - await page.getByTestId('drawer-close-icon').click(); - - // Panel should not be visible after closing it - await expect(page.locator('.lineage-entity-panel')).not.toBeVisible(); - } - }); - - await test.step('Should create pipeline between entities', async () => { - await editLineage(page); - - await page.getByTestId('fit-screen').click(); - await page.getByRole('menuitem', { name: 'Fit to screen' }).click(); - await performZoomOut(page, 8); - await waitForAllLoadersToDisappear(page); - - const fromNodeFqn = get( - currentEntity, - 'entityResponseData.fullyQualifiedName' - ); - - await clickLineageNode(page, fromNodeFqn); - - for (const entity of entities) { - await applyPipelineFromModal(page, currentEntity, entity, pipeline); - } - }); - - await waitForAllLoadersToDisappear(page); - - await test.step('Verify Lineage Export CSV', async () => { - await editLineageClick(page); - await waitForAllLoadersToDisappear(page); - await performZoomOut(page); - await verifyExportLineageCSV(page, currentEntity, entities, pipeline); - }); - - await test.step('Verify Lineage Export PNG', async () => { - await verifyExportLineagePNG(page); - }); - - await test.step('Remove lineage between nodes for the entity', async () => { - await editLineage(page); - await page.getByTestId('fit-screen').click(); - await page.getByRole('menuitem', { name: 'Fit to screen' }).click(); - await waitForAllLoadersToDisappear(page); - - await performZoomOut(page); - - for (const entity of entities) { - await deleteEdge(page, currentEntity, entity); - } - }); - - await test.step('Verify Lineage Config', async () => { - await editLineageClick(page); - await verifyLineageConfig(page); - }); - } finally { - await cleanup(); - } - }); -} - -test('Verify column lineage between tables', async ({ page }) => { - const { apiContext, afterAction } = await getApiContext(page); - const table1 = new TableClass(); - const table2 = new TableClass(); - - await Promise.all([table1.create(apiContext), table2.create(apiContext)]); - - const sourceTableFqn = get(table1, 'entityResponseData.fullyQualifiedName'); - const sourceCol = `${sourceTableFqn}.${get( - table1, - 'entityResponseData.columns[0].name' - )}`; - - const targetTableFqn = get(table2, 'entityResponseData.fullyQualifiedName'); - const targetCol = `${targetTableFqn}.${get( - table2, - 'entityResponseData.columns[0].name' - )}`; - - await addPipelineBetweenNodes(page, table1, table2); - await activateColumnLayer(page); - - // Add column lineage - await addColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - await performZoomOut(page, 1); - - await removeColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - - await deleteNode(page, table2); - await table1.delete(apiContext); - await table2.delete(apiContext); - - await afterAction(); -}); - -test('Verify column lineage between table and topic', async ({ page }) => { - test.slow(); - - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - const topic = new TopicClass(); - await Promise.all([table.create(apiContext), topic.create(apiContext)]); - - const tableServiceFqn = get( - table, - 'entityResponseData.service.fullyQualifiedName' - ); - - const topicServiceFqn = get( - topic, - 'entityResponseData.service.fullyQualifiedName' - ); - - const sourceTableFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const sourceCol = `${sourceTableFqn}.${get( - table, - 'entityResponseData.columns[0].name' - )}`; - const targetCol = get( - topic, - 'entityResponseData.messageSchema.schemaFields[0].children[0].fullyQualifiedName' - ); - - await addPipelineBetweenNodes(page, table, topic); - await activateColumnLayer(page); - - // Add column lineage - await addColumnLineage(page, sourceCol, targetCol); - - // Verify column lineage - await redirectToHomePage(page); - await table.visitEntityPage(page); - await visitLineageTab(page); - await verifyColumnLineageInCSV(page, table, topic, sourceCol, targetCol); - - await verifyPlatformLineageForEntity(page, tableServiceFqn, topicServiceFqn); - - await table.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await editLineageClick(page); - - await removeColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - - await deleteNode(page, topic); - await table.delete(apiContext); - await topic.delete(apiContext); - - await afterAction(); -}); - -test('Verify column lineage between topic and api endpoint', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - const topic = new TopicClass(); - const apiEndpoint = new ApiEndpointClass(); - - await Promise.all([topic.create(apiContext), apiEndpoint.create(apiContext)]); - - const sourceCol = get( - topic, - 'entityResponseData.messageSchema.schemaFields[0].children[0].fullyQualifiedName' - ); - - const targetCol = get( - apiEndpoint, - 'entityResponseData.responseSchema.schemaFields[0].children[1].fullyQualifiedName' - ); - - await addPipelineBetweenNodes(page, topic, apiEndpoint); - await activateColumnLayer(page); - - // Add column lineage - await addColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - - await removeColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - - await deleteNode(page, apiEndpoint); - await topic.delete(apiContext); - await apiEndpoint.delete(apiContext); - - await afterAction(); -}); - -test('Verify column lineage between table and api endpoint', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - const apiEndpoint = new ApiEndpointClass(); - await Promise.all([table.create(apiContext), apiEndpoint.create(apiContext)]); - - const sourceTableFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const sourceCol = `${sourceTableFqn}.${get( - table, - 'entityResponseData.columns[0].name' - )}`; - const targetCol = get( - apiEndpoint, - 'entityResponseData.responseSchema.schemaFields[0].children[0].fullyQualifiedName' - ); - - await addPipelineBetweenNodes(page, table, apiEndpoint); - await activateColumnLayer(page); - - // Add column lineage - await addColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - await removeColumnLineage(page, sourceCol, targetCol); - await editLineageClick(page); - - await deleteNode(page, apiEndpoint); - await table.delete(apiContext); - await apiEndpoint.delete(apiContext); - - await afterAction(); -}); - -test('Verify function data in edge drawer', async ({ page }) => { - test.slow(); - - const { apiContext, afterAction } = await getApiContext(page); - const table1 = new TableClass(); - const table2 = new TableClass(); - - try { - await Promise.all([table1.create(apiContext), table2.create(apiContext)]); - const sourceTableFqn = get(table1, 'entityResponseData.fullyQualifiedName'); - const sourceColName = `${sourceTableFqn}.${get( - table1, - 'entityResponseData.columns[0].name' - )}`; - - const targetTableFqn = get(table2, 'entityResponseData.fullyQualifiedName'); - const targetColName = `${targetTableFqn}.${get( - table2, - 'entityResponseData.columns[0].name' - )}`; - - await addPipelineBetweenNodes(page, table1, table2); - await activateColumnLayer(page); - await addColumnLineage(page, sourceColName, targetColName); - - const lineageReq = page.waitForResponse('/api/v1/lineage/getLineage?*'); - await page.reload(); - await lineageReq; - - await activateColumnLayer(page); - - await page - .locator(`[data-testid="column-edge-${sourceColName}-${targetColName}"]`) - .dispatchEvent('click'); - - await page.locator('.sql-function-section').waitFor({ - state: 'visible', - }); - - await page - .locator('.sql-function-section') - .getByTestId('edit-button') - .click(); - await page.getByTestId('sql-function-input').fill('count'); - const saveRes = page.waitForResponse('/api/v1/lineage'); - await page.getByTestId('save').click(); - await saveRes; - - await expect(page.getByTestId('sql-function')).toContainText('count'); - - const lineageReq1 = page.waitForResponse('/api/v1/lineage/getLineage?*'); - await page.reload(); - await lineageReq1; - - await activateColumnLayer(page); - await page - .locator(`[data-testid="column-edge-${sourceColName}-${targetColName}"]`) - .dispatchEvent('click'); - - await page.locator('.edge-info-drawer').isVisible(); - - await expect(page.locator('[data-testid="sql-function"]')).toContainText( - 'count' - ); - } finally { - await Promise.all([table1.delete(apiContext), table2.delete(apiContext)]); - await afterAction(); - } -}); - -test('Verify table search with special characters as handled', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - - // Create a table with '/' in the name to test encoding functionality - const tableNameWithSlash = `pw-table-with/slash-${uuid()}`; - const table = new TableClass(tableNameWithSlash); - - await table.create(apiContext); - - const db = table.databaseResponseData.name; - try { - await sidebarClick(page, SidebarItem.LINEAGE); - - await page.getByTestId('search-entity-select').waitFor(); - await page.click('[data-testid="search-entity-select"]'); - - await page.fill( - '[data-testid="search-entity-select"] .ant-select-selection-search-input', - table.entity.name - ); - - await page.waitForRequest( - (req) => - req.url().includes('/api/v1/search/query') && - req.url().includes('deleted=false') - ); - - await page.locator('.ant-select-dropdown').waitFor(); - - const nodeFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const dbFqn = get(table, 'entityResponseData.database.fullyQualifiedName'); - await page - .locator(`[data-testid="node-suggestion-${nodeFqn}"]`) - .dispatchEvent('click'); - - await page.waitForResponse('/api/v1/lineage/getLineage?*'); - - await expect(page.locator('[data-testid="lineage-details"]')).toBeVisible(); - - await expect( - page.locator(`[data-testid="lineage-node-${nodeFqn}"]`) - ).toBeVisible(); - - await redirectToHomePage(page); - await sidebarClick(page, SidebarItem.LINEAGE); - await page.getByTestId('search-entity-select').waitFor(); - await page.click('[data-testid="search-entity-select"]'); - - await page.fill( - '[data-testid="search-entity-select"] .ant-select-selection-search-input', - db - ); - await page.getByTestId(`node-suggestion-${dbFqn}`).waitFor(); - await page - .locator(`[data-testid="node-suggestion-${dbFqn}"]`) - .dispatchEvent('click'); - await page.waitForResponse('/api/v1/lineage/getLineage?*'); - - await expect(page.locator('[data-testid="lineage-details"]')).toBeVisible(); - - await clickLineageNode(page, dbFqn); - - await expect( - page.locator('.lineage-entity-panel').getByTestId('entity-header-title') - ).toBeVisible(); - } finally { - // Cleanup - await table.delete(apiContext); - await afterAction(); - } -}); - -test('Verify cycle lineage should be handled properly', async ({ page }) => { - test.slow(); - - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - const topic = new TopicClass(); - const dashboard = new DashboardClass(); - - try { - await Promise.all([ - table.create(apiContext), - topic.create(apiContext), - dashboard.create(apiContext), - ]); - - const tableFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const topicFqn = get(topic, 'entityResponseData.fullyQualifiedName'); - const dashboardFqn = get( - dashboard, - 'entityResponseData.fullyQualifiedName' - ); - - // connect table to topic - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: table.entityResponseData.id, - type: 'table', - }, - { - id: topic.entityResponseData.id, - type: 'topic', - } - ); - - // connect topic to dashboard - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: topic.entityResponseData.id, - type: 'topic', - }, - { - id: dashboard.entityResponseData.id, - type: 'dashboard', - } - ); - - // connect dashboard to table - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: dashboard.entityResponseData.id, - type: 'dashboard', - }, - { - id: table.entityResponseData.id, - type: 'table', - } - ); - - await redirectToHomePage(page); - await table.visitEntityPage(page); - await visitLineageTab(page); - - await performZoomOut(page); - - await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); - await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`) - ).toBeVisible(); - - // Collapse the cycle dashboard lineage downstreamNodeHandler - await page - .getByTestId(`lineage-node-${dashboardFqn}`) - .getByTestId('downstream-collapse-handle') - .dispatchEvent('click'); - - await expect( - page.getByTestId(`edge-${dashboardFqn}-${tableFqn}`) - ).not.toBeVisible(); - - await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); - await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`) - ).toBeVisible(); - - await expect( - page - .getByTestId(`lineage-node-${tableFqn}`) - .getByTestId('upstream-collapse-handle') - ).not.toBeVisible(); - - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`).getByTestId('plus-icon') - ).toBeVisible(); - - // Reclick the plus icon to expand the cycle dashboard lineage downstreamNodeHandler - const downstreamResponse = page.waitForResponse( - `/api/v1/lineage/getLineage/Downstream?fqn=${dashboardFqn}&type=dashboard**` - ); - await page - .getByTestId(`lineage-node-${dashboardFqn}`) - .getByTestId('plus-icon') - .dispatchEvent('click'); - - await downstreamResponse; - - await expect( - page - .getByTestId(`lineage-node-${tableFqn}`) - .getByTestId('upstream-collapse-handle') - .getByTestId('minus-icon') - ).toBeVisible(); - - // Click the Upstream Node to expand the cycle dashboard lineage - await page - .getByTestId(`lineage-node-${dashboardFqn}`) - .getByTestId('upstream-collapse-handle') - .dispatchEvent('click'); - - await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`) - ).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${topicFqn}`) - ).not.toBeVisible(); - - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`).getByTestId('plus-icon') - ).toBeVisible(); - - // Reclick the plus icon to expand the cycle dashboard lineage upstreamNodeHandler - const upStreamResponse2 = page.waitForResponse( - `/api/v1/lineage/getLineage/Upstream?fqn=${dashboardFqn}&type=dashboard**` - ); - await page - .getByTestId(`lineage-node-${dashboardFqn}`) - .getByTestId('plus-icon') - .dispatchEvent('click'); - await upStreamResponse2; - - await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`) - ).toBeVisible(); - await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); - - // Collapse the Node from the Parent Cycle Node - await page - .getByTestId(`lineage-node-${topicFqn}`) - .getByTestId('downstream-collapse-handle') - .dispatchEvent('click'); - - await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); - await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); - await expect( - page.getByTestId(`lineage-node-${dashboardFqn}`) - ).not.toBeVisible(); - } finally { - await Promise.all([ - table.delete(apiContext), - topic.delete(apiContext), - dashboard.delete(apiContext), - ]); - await afterAction(); - } -}); - -test('Verify column layer is applied on entering edit mode', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - - await table.create(apiContext); - - try { - await table.visitEntityPage(page); - await visitLineageTab(page); - - const columnLayerBtn = page.locator( - '[data-testid="lineage-layer-column-btn"]' - ); - - await test.step('Verify column layer is inactive initially', async () => { - await page.click('[data-testid="lineage-layer-btn"]'); - - await expect(columnLayerBtn).not.toHaveClass(/Mui-selected/); - - await clickOutside(page); - }); - - await test.step('Enter edit mode and verify column layer is active', async () => { - await editLineageClick(page); - - await page.click('[data-testid="lineage-layer-btn"]'); - - await expect(columnLayerBtn).toHaveClass(/Mui-selected/); - - await clickOutside(page); - }); - } finally { - await table.delete(apiContext); - await afterAction(); - } -}); - -test('Verify there is no traced nodes and columns on exiting edit mode', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - - await table.create(apiContext); - - try { - await table.visitEntityPage(page); - await visitLineageTab(page); - - const tableFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const tableNode = page.locator(`[data-testid="lineage-node-${tableFqn}"]`); - const firstColumnName = get(table, 'entityResponseData.columns[0].name'); - const firstColumn = page.locator( - `[data-testid="column-${tableFqn}.${firstColumnName}"]` - ); - - await test.step('Verify node tracing is cleared on exiting edit mode', async () => { - await editLineageClick(page); - - await expect(tableNode).not.toHaveClass(/custom-node-header-active/); - - await tableNode.click({ position: { x: 5, y: 5 } }); - - await expect(tableNode).toHaveClass(/custom-node-header-active/); - - await editLineageClick(page); - - await expect(tableNode).not.toHaveClass(/custom-node-header-active/); - }); - - await test.step('Verify column tracing is cleared on exiting edit mode', async () => { - await editLineageClick(page); - - await firstColumn.click(); - - await expect(firstColumn).toHaveClass( - /custom-node-header-column-tracing/ - ); - - await editLineageClick(page); - - await toggleLineageFilters(page, tableFqn); - - await expect(firstColumn).not.toHaveClass( - /custom-node-header-column-tracing/ - ); - }); - } finally { - await table.delete(apiContext); - await afterAction(); - } -}); - -test('Verify node full path is present as breadcrumb in lineage node', async ({ - page, -}) => { - const { apiContext, afterAction } = await getApiContext(page); - const table = new TableClass(); - - await table.create(apiContext); - - try { - await table.visitEntityPage(page); - await visitLineageTab(page); - - const tableFqn = get(table, 'entityResponseData.fullyQualifiedName'); - const tableNode = page.locator(`[data-testid="lineage-node-${tableFqn}"]`); - - await expect(tableNode).toBeVisible(); - - const breadcrumbContainer = tableNode.locator( - '[data-testid="lineage-breadcrumbs"]' - ); - await expect(breadcrumbContainer).toBeVisible(); - - const breadcrumbItems = breadcrumbContainer.locator( - '.lineage-breadcrumb-item' - ); - const breadcrumbCount = await breadcrumbItems.count(); - - expect(breadcrumbCount).toBeGreaterThan(0); - - const fqnParts: Array = tableFqn.split('.'); - fqnParts.pop(); - - expect(breadcrumbCount).toBe(fqnParts.length); - - for (let i = 0; i < breadcrumbCount; i++) { - await expect(breadcrumbItems.nth(i)).toHaveText(fqnParts[i]); - } - } finally { - await table.delete(apiContext); - await afterAction(); - } -}); - -test.fixme( - 'Edges are not getting hidden when column is selected and column layer is removed', - async ({ page }) => { - const { apiContext, afterAction } = await getApiContext(page); - const table1 = new TableClass(); - const table2 = new TableClass(); - - try { - await Promise.all([table1.create(apiContext), table2.create(apiContext)]); - - const table1Fqn = get(table1, 'entityResponseData.fullyQualifiedName'); - const table2Fqn = get(table2, 'entityResponseData.fullyQualifiedName'); - - const sourceCol = `${table1Fqn}.${get( - table1, - 'entityResponseData.columns[0].name' - )}`; - const targetCol = `${table2Fqn}.${get( - table2, - 'entityResponseData.columns[0].name' - )}`; - - await test.step('1. Create 2 tables and create column level lineage between them.', async () => { - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: table1.entityResponseData.id, - type: 'table', - }, - { - id: table2.entityResponseData.id, - type: 'table', - }, - [ - { - fromColumns: [sourceCol], - toColumn: targetCol, - }, - ] - ); - - await table1.visitEntityPage(page); - await visitLineageTab(page); - }); - - await test.step('2. Verify edge between 2 tables is visible', async () => { - const tableEdge = page.getByTestId( - `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` - ); - await expect(tableEdge).toBeVisible(); - }); - - await test.step('3. Activate column layer and select a column - table edge should be hidden', async () => { - await activateColumnLayer(page); - - const firstColumn = page.locator(`[data-testid="column-${sourceCol}"]`); - await firstColumn.click(); - - const tableEdge = page.getByTestId( - `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` - ); - await expect(tableEdge).not.toBeVisible(); - }); - - await test.step('4. Remove column layer - table edge should be visible again', async () => { - const columnLayerBtn = page.locator( - '[data-testid="lineage-layer-column-btn"]' - ); - - await page.click('[data-testid="lineage-layer-btn"]'); - await columnLayerBtn.click(); - await clickOutside(page); - - const tableEdge = page.getByTestId( - `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` - ); - await expect(tableEdge).toBeVisible(); - }); - } finally { - await Promise.all([table1.delete(apiContext), table2.delete(apiContext)]); - await afterAction(); - } - } -); - -test.describe('node selection edge behavior', () => { - /** - * Test setup: - * - table1 -> table2 -> table3 - * -> table4 - * - * This creates a lineage graph where: - * - table1 is upstream of table2 - * - table2 is upstream of table3 and table4 - * - When table3 is selected, the traced path is: table1 -> table2 -> table3 - * - The edge table2 -> table4 should be dimmed (not in traced path) - */ - const table1 = new TableClass(); - const table2 = new TableClass(); - const table3 = new TableClass(); - const table4 = new TableClass(); - - let table1Fqn: string; - let table2Fqn: string; - let table3Fqn: string; - let table4Fqn: string; - - let table1Col: string; - let table2Col: string; - let table3Col: string; - let table4Col: string; - - test.beforeAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - - await Promise.all([ - table1.create(apiContext), - table2.create(apiContext), - table3.create(apiContext), - table4.create(apiContext), - ]); - - table1Fqn = get(table1, 'entityResponseData.fullyQualifiedName'); - table2Fqn = get(table2, 'entityResponseData.fullyQualifiedName'); - table3Fqn = get(table3, 'entityResponseData.fullyQualifiedName'); - table4Fqn = get(table4, 'entityResponseData.fullyQualifiedName'); - - table1Col = `${table1Fqn}.${get( - table1, - 'entityResponseData.columns[0].name' - )}`; - table2Col = `${table2Fqn}.${get( - table2, - 'entityResponseData.columns[0].name' - )}`; - table3Col = `${table3Fqn}.${get( - table3, - 'entityResponseData.columns[0].name' - )}`; - table4Col = `${table4Fqn}.${get( - table4, - 'entityResponseData.columns[0].name' - )}`; - - await connectEdgeBetweenNodesViaAPI( - apiContext, - { id: table1.entityResponseData.id, type: 'table' }, - { id: table2.entityResponseData.id, type: 'table' }, - [{ fromColumns: [table1Col], toColumn: table2Col }] - ); - - await connectEdgeBetweenNodesViaAPI( - apiContext, - { id: table2.entityResponseData.id, type: 'table' }, - { id: table3.entityResponseData.id, type: 'table' }, - [{ fromColumns: [table2Col], toColumn: table3Col }] - ); - - await connectEdgeBetweenNodesViaAPI( - apiContext, - { id: table2.entityResponseData.id, type: 'table' }, - { id: table4.entityResponseData.id, type: 'table' }, - [{ fromColumns: [table2Col], toColumn: table4Col }] - ); - - await afterAction(); - }); - - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await Promise.all([ - table1.delete(apiContext), - table2.delete(apiContext), - table3.delete(apiContext), - table4.delete(apiContext), - ]); - await afterAction(); - }); - - test.beforeEach(async ({ page }) => { - await redirectToHomePage(page); - }); - - test.fixme( - 'highlights traced node-to-node edges when a node is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await performZoomOut(page); - - await clickLineageNode(page, table3Fqn); - - await page.keyboard.press('Escape'); - - const tracedEdge1 = page.locator( - `[data-testid="edge-${table1Fqn}-${table2Fqn}"]` - ); - const tracedEdge2 = page.locator( - `[data-testid="edge-${table2Fqn}-${table3Fqn}"]` - ); - - await expect(tracedEdge1).toBeVisible(); - await expect(tracedEdge2).toBeVisible(); - - const tracedEdge1Style = await tracedEdge1.getAttribute('style'); - const tracedEdge2Style = await tracedEdge2.getAttribute('style'); - - expect(tracedEdge1Style).toContain('opacity: 1'); - expect(tracedEdge2Style).toContain('opacity: 1'); - } - ); - - test.fixme( - 'hides column-to-column edges when a node is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - - const columnEdge = page.locator( - `[data-testid="column-edge-${table1Col}-${table2Col}"]` - ); - await expect(columnEdge).toBeVisible(); - - await clickLineageNode(page, table3Fqn); - - const columnEdgeStyle = await columnEdge.getAttribute('style'); - - expect(columnEdgeStyle).toContain('display: none'); - } - ); - - test.fixme( - 'grays out non-traced node-to-node edges when a node is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await performZoomOut(page); - - await clickLineageNode(page, table3Fqn); - - const nonTracedEdge = page.locator( - `[data-testid="edge-${table2Fqn}-${table4Fqn}"]` - ); - - await expect(nonTracedEdge).toBeVisible(); - - const nonTracedEdgeStyle = await nonTracedEdge.getAttribute('style'); - - expect(nonTracedEdgeStyle).toContain('opacity: 0.3'); - } - ); - - test.fixme( - 'highlights traced column-to-column edges when a column is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - - const table1Column = page.locator(`[data-testid="column-${table1Col}"]`); - await table1Column.click(); - - const tracedColumnEdge = page.locator( - `[data-testid="column-edge-${table1Col}-${table2Col}"]` - ); - - await expect(tracedColumnEdge).toBeVisible(); - - const tracedEdgeStyle = await tracedColumnEdge.getAttribute('style'); - - expect(tracedEdgeStyle).toContain('opacity: 1'); - expect(tracedEdgeStyle).not.toContain('display: none'); - } - ); - - test.fixme( - 'hides non-traced column-to-column edges when a column is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - - const table3Column = page.locator(`[data-testid="column-${table3Col}"]`); - await table3Column.click(); - - const nonTracedColumnEdge = page.locator( - `[data-testid="column-edge-${table2Col}-${table4Col}"]` - ); - - const edgeStyle = await nonTracedColumnEdge.getAttribute('style'); - - expect(edgeStyle).toContain('display: none'); - } - ); - - test.fixme( - 'grays out node-to-node edges when a column is selected', - async ({ page }) => { - await table2.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - - const table3Column = page.locator(`[data-testid="column-${table3Col}"]`); - await table3Column.click(); - - const nodeEdge = page.locator( - `[data-testid="edge-${table2Fqn}-${table3Fqn}"]` - ); - - await expect(nodeEdge).toBeVisible(); - - const nodeEdgeStyle = await nodeEdge.getAttribute('style'); - - expect(nodeEdgeStyle).toContain('opacity: 0.3'); - } - ); -}); - -test.describe.serial('Test pagination in column level lineage', () => { - const generateColumnsWithNames = (count: number) => { - const columns = []; - for (let i = 0; i < count; i++) { - columns.push({ - name: `column_${i}_${uuid()}`, - dataType: 'VARCHAR', - dataLength: 100, - dataTypeDisplay: 'varchar', - description: `Test column ${i} for pagination`, - }); - } - - return columns; - }; - - const table1Columns = generateColumnsWithNames(21); - const table2Columns = generateColumnsWithNames(22); - - const table1 = new TableClass(); - const table2 = new TableClass(); - - let table1Fqn: string; - let table2Fqn: string; - - test.beforeAll(async ({ browser }) => { - const { apiContext, afterAction, page } = await createNewPage(browser); - - await redirectToHomePage(page); - table1.entity.columns = table1Columns; - table2.entity.columns = table2Columns; - - const [table1Response, table2Response] = await Promise.all([ - table1.create(apiContext), - table2.create(apiContext), - ]); - - table1Fqn = get(table1Response, 'entity.fullyQualifiedName'); - table2Fqn = get(table2Response, 'entity.fullyQualifiedName'); - - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: table1Response.entity.id, - type: 'table', - }, - { - id: table2Response.entity.id, - type: 'table', - } - ); - - const table1ColumnFqn = table1Response.entity.columns?.map( - (col: { fullyQualifiedName: string }) => col.fullyQualifiedName - ) as string[]; - const table2ColumnFqn = table2Response.entity.columns?.map( - (col: { fullyQualifiedName: string }) => col.fullyQualifiedName - ) as string[]; - - await test.step('Add edges between T1-P1 and T2-P1', async () => { - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: table1Response.entity.id, - type: 'table', - }, - { - id: table2Response.entity.id, - type: 'table', - }, - [ - { - fromColumns: [table1ColumnFqn[0]], - toColumn: table2ColumnFqn[0], - }, - { - fromColumns: [table1ColumnFqn[1]], - toColumn: table2ColumnFqn[1], - }, - { - fromColumns: [table1ColumnFqn[2]], - toColumn: table2ColumnFqn[2], - }, - { - fromColumns: [table1ColumnFqn[0]], - toColumn: table2ColumnFqn[15], - }, - { - fromColumns: [table1ColumnFqn[1]], - toColumn: table2ColumnFqn[16], - }, - { - fromColumns: [table1ColumnFqn[3]], - toColumn: table2ColumnFqn[17], - }, - { - fromColumns: [table1ColumnFqn[4]], - toColumn: table2ColumnFqn[17], - }, - { - fromColumns: [table1ColumnFqn[15]], - toColumn: table2ColumnFqn[15], - }, - { - fromColumns: [table1ColumnFqn[16]], - toColumn: table2ColumnFqn[16], - }, - { - fromColumns: [table1ColumnFqn[18]], - toColumn: table2ColumnFqn[17], - }, - ] - ); - }); - - await afterAction(); - }); - - test.afterAll(async ({ browser }) => { - const { apiContext, afterAction } = await createNewPage(browser); - await Promise.all([table1.delete(apiContext), table2.delete(apiContext)]); - await afterAction(); - }); - - test.beforeEach(async ({ page }) => { - await redirectToHomePage(page); - }); - - test('Verify column visibility across pagination pages', async ({ page }) => { - test.slow(); - - await table1.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - await toggleLineageFilters(page, table1Fqn); - await toggleLineageFilters(page, table2Fqn); - - const table1Node = page.locator( - `[data-testid="lineage-node-${table1Fqn}"]` - ); - const table2Node = page.locator( - `[data-testid="lineage-node-${table2Fqn}"]` - ); - - const table1NextBtn = table1Node.getByTestId('column-scroll-down'); - const table2NextBtn = table2Node.getByTestId('column-scroll-down'); - - const allColumnTestIds = { - table1: table1Columns.map((col) => `column-${table1Fqn}.${col.name}`), - table2: table2Columns.map((col) => `column-${table2Fqn}.${col.name}`), - }; - - const columnTestIds: Record = { - 'T1-P1': allColumnTestIds.table1.slice(0, 10), - 'T1-P2': allColumnTestIds.table1.slice(10, 20), - 'T1-P3': allColumnTestIds.table1.slice(11, 21), - 'T2-P1': allColumnTestIds.table2.slice(0, 10), - 'T2-P2': allColumnTestIds.table2.slice(10, 20), - 'T2-P3': allColumnTestIds.table2.slice(12, 22), - }; - - await test.step('Verify T1-P1: C1-C10 visible, C10-C21 hidden', async () => { - for (const testId of columnTestIds['T1-P1']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table1) { - if (!columnTestIds['T1-P1'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - - await test.step('Verify T2-P1: C1-C10 visible, C10-C22 hidden', async () => { - for (const testId of columnTestIds['T2-P1']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table2) { - if (!columnTestIds['T2-P1'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - - await test.step('Navigate to T1-P2 and verify visibility', async () => { - if (await table1NextBtn.isVisible()) { - await table1NextBtn.click(); - } - - for (const testId of columnTestIds['T1-P2']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table1) { - if (!columnTestIds['T1-P2'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - - await test.step('Navigate to T2-P2 and verify visibility', async () => { - if (await table2NextBtn.isVisible()) { - await table2NextBtn.click(); - } - - for (const testId of columnTestIds['T2-P2']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table2) { - if (!columnTestIds['T2-P2'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - - await test.step('Navigate to T1-P3 and verify visibility', async () => { - if (await table1NextBtn.isVisible()) { - await table1NextBtn.click(); - } - - for (const testId of columnTestIds['T1-P3']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table1) { - if (!columnTestIds['T1-P3'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - - await test.step('Navigate to T2-P3 and verify visibility', async () => { - if (await table2NextBtn.isVisible()) { - await table2NextBtn.click(); - } - - for (const testId of columnTestIds['T2-P3']) { - await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); - } - - for (const testId of allColumnTestIds.table2) { - if (!columnTestIds['T2-P3'].includes(testId)) { - await expect( - page.locator(`[data-testid="${testId}"]`) - ).not.toBeVisible(); - } - } - }); - }); - - test('Verify edges when no column is hovered or selected', async ({ - page, - }) => { - test.slow(); - - await table1.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - await toggleLineageFilters(page, table1Fqn); - await toggleLineageFilters(page, table2Fqn); - - const table1Node = page.locator( - `[data-testid="lineage-node-${table1Fqn}"]` - ); - const table2Node = page.locator( - `[data-testid="lineage-node-${table2Fqn}"]` - ); - - const table1NextBtn = table1Node.getByTestId('column-scroll-down'); - const table2NextBtn = table2Node.getByTestId('column-scroll-down'); - - await test.step('Verify T1-P1 and T2-P1: Only (T1,C1)-(T2,C1), (T1,C2)-(T2,C2), (T1,C3)-(T2,C3) edges visible', async () => { - const visibleEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[1].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[2].name}`}-${`${table2Fqn}.${table2Columns[2].name}`}`, - ]; - - const hiddenEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[3].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[4].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[15].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[16].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[18].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - for (const edgeId of visibleEdges) { - await expect(page.locator(`[data-testid="${edgeId}"]`)).toBeVisible(); - } - - for (const edgeId of hiddenEdges) { - await expect( - page.locator(`[data-testid="${edgeId}"]`) - ).not.toBeVisible(); - } - }); - - await test.step('Navigate to T2-P2 and verify (T1,C1)-(T2,C6), (T1,C2)-(T2,C7), (T1,C4)-(T2,C8), (T1,C5)-(T2,C8) edges visible', async () => { - if (await table2NextBtn.isVisible()) { - await table2NextBtn.click(); - } - - const visibleEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[3].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[4].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - const hiddenEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[1].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[2].name}`}-${`${table2Fqn}.${table2Columns[2].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[15].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[16].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[18].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - for (const edgeId of visibleEdges) { - await expect(page.locator(`[data-testid="${edgeId}"]`)).toBeVisible(); - } - - for (const edgeId of hiddenEdges) { - await expect( - page.locator(`[data-testid="${edgeId}"]`) - ).not.toBeVisible(); - } - }); - - await test.step('Navigate to T1-P2 and verify (T1,C6)-(T2,C6), (T1,C7)-(T2,C7), (T1,C9)-(T2,C8) edges visible', async () => { - if (await table1NextBtn.isVisible()) { - await table1NextBtn.click(); - } - - const visibleEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[15].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[16].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[18].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - const hiddenEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[1].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[2].name}`}-${`${table2Fqn}.${table2Columns[2].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[3].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[4].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - for (const edgeId of visibleEdges) { - await expect(page.locator(`[data-testid="${edgeId}"]`)).toBeVisible(); - } - - for (const edgeId of hiddenEdges) { - await expect( - page.locator(`[data-testid="${edgeId}"]`) - ).not.toBeVisible(); - } - }); - }); - - test('Verify columns and edges when a column is hovered', async ({ - page, - }) => { - test.slow(); - - await table1.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - await toggleLineageFilters(page, table1Fqn); - await toggleLineageFilters(page, table2Fqn); - - await test.step('Hover on (T1,C1) and verify highlighted columns and edges', async () => { - const c1Column = page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[0].name}"]` - ); - - await c1Column.hover(); - - // Verify (T1,C1), (T2,C1) and (T2,C6) are highlighted and visible - const t1c1 = page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[0].name}"]` - ); - const t2c1 = page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[0].name}"]` - ); - const t2c6 = page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[15].name}"]` - ); - - await expect(t1c1).toBeVisible(); - await expect(t1c1).toHaveClass(/custom-node-header-column-tracing/); - - await expect(t2c1).toBeVisible(); - await expect(t2c1).toHaveClass(/custom-node-header-column-tracing/); - - await expect(t2c6).toBeVisible(); - await expect(t2c6).toHaveClass(/custom-node-header-column-tracing/); - - // Verify edges are visible - const edge_t1c1_to_t2c1 = `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`; - const edge_t1c1_to_t2c6 = `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`; - - await expect( - page.locator(`[data-testid="${edge_t1c1_to_t2c1}"]`) - ).toBeVisible(); - await expect( - page.locator(`[data-testid="${edge_t1c1_to_t2c6}"]`) - ).toBeVisible(); - }); - }); - - test('Verify columns and edges when a column is clicked', async ({ - page, - }) => { - test.slow(); - - await table1.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - await toggleLineageFilters(page, table1Fqn); - await toggleLineageFilters(page, table2Fqn); - - await test.step('Navigate to T1-P2 and T2-P2, click (T2,C6) and verify highlighted columns and edges', async () => { - const table1Node = page.locator( - `[data-testid="lineage-node-${table1Fqn}"]` - ); - const table2Node = page.locator( - `[data-testid="lineage-node-${table2Fqn}"]` - ); - - // Navigate to T1-P2 - const table1NextBtn = table1Node.getByTestId('column-scroll-down'); - if (await table1NextBtn.isVisible()) { - await table1NextBtn.click(); - } - - // Navigate to T2-P2 - const table2NextBtn = table2Node.getByTestId('column-scroll-down'); - if (await table2NextBtn.isVisible()) { - await table2NextBtn.click(); - } - - // Click on (T2,C6) - const t2c6Column = page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[15].name}"]` - ); - await t2c6Column.click(); - - // Verify (T1,C1), (T1,C6) and (T2,C6) are highlighted and visible - const t1c1 = page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[0].name}"]` - ); - const t1c6 = page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[15].name}"]` - ); - const t2c6 = page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[15].name}"]` - ); - - await expect(t1c1).toBeVisible(); - await expect(t1c1).toHaveClass(/custom-node-header-column-tracing/); - - await expect(t1c6).toBeVisible(); - await expect(t1c6).toHaveClass(/custom-node-header-column-tracing/); - - await expect(t2c6).toBeVisible(); - await expect(t2c6).toHaveClass(/custom-node-header-column-tracing/); - - // Verify edges are visible - const edge_t1c1_to_t2c6 = `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`; - const edge_t1c6_to_t2c6 = `column-edge-${`${table1Fqn}.${table1Columns[15].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`; - - await expect( - page.locator(`[data-testid="${edge_t1c1_to_t2c6}"]`) - ).toBeVisible(); - await expect( - page.locator(`[data-testid="${edge_t1c6_to_t2c6}"]`) - ).toBeVisible(); - }); - }); - - test('Verify edges for column level lineage between 2 nodes when filter is toggled', async ({ - page, - }) => { - test.slow(); - - const { afterAction } = await getApiContext(page); - - try { - await test.step('1. Load both the table', async () => { - await table1.visitEntityPage(page); - await visitLineageTab(page); - await activateColumnLayer(page); - await performZoomOut(page); - }); - - await toggleLineageFilters(page, table1Fqn); - await toggleLineageFilters(page, table2Fqn); - - await test.step('2. Verify edges visible and hidden for page1 of both the tables', async () => { - const visibleEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[1].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[2].name}`}-${`${table2Fqn}.${table2Columns[2].name}`}`, - ]; - - const hiddenEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[3].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - for (const edgeId of visibleEdges) { - await expect(page.locator(`[data-testid="${edgeId}"]`)).toBeVisible(); - } - - for (const edgeId of hiddenEdges) { - await expect( - page.locator(`[data-testid="${edgeId}"]`) - ).not.toBeVisible(); - } - }); - - await test.step('3. Enable the filter for table1 by clicking filter button', async () => { - await toggleLineageFilters(page, table1Fqn); - }); - - await test.step('4. Verify that only columns with lineage are visible in table1', async () => { - const columnsWithLineage = [0, 1, 2, 3, 4, 15, 16, 18]; - const columnsWithoutLineage = [7, 9, 10]; - - for (const index of columnsWithLineage) { - await expect( - page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[index].name}"]` - ) - ).toBeVisible(); - } - - for (const index of columnsWithoutLineage) { - await expect( - page.locator( - `[data-testid="column-${table1Fqn}.${table1Columns[index].name}"]` - ) - ).not.toBeVisible(); - } - }); - - await test.step('5. Enable the filter for table2 by clicking filter button', async () => { - await toggleLineageFilters(page, table2Fqn); - }); - - await test.step('6. Verify that only columns with lineage are visible in table2', async () => { - const columnsWithLineage = [0, 1, 2, 15, 16, 17]; - const columnsWithoutLineage = [3, 4, 8, 9, 10, 11]; - - for (const index of columnsWithLineage) { - await expect( - page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[index].name}"]` - ) - ).toBeVisible(); - } - - for (const index of columnsWithoutLineage) { - await expect( - page.locator( - `[data-testid="column-${table2Fqn}.${table2Columns[index].name}"]` - ) - ).not.toBeVisible(); - } - }); - - await test.step('7. Verify new edges are now visible.', async () => { - const allVisibleEdges = [ - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[0].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[1].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[2].name}`}-${`${table2Fqn}.${table2Columns[2].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[0].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[1].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[3].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[4].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[15].name}`}-${`${table2Fqn}.${table2Columns[15].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[16].name}`}-${`${table2Fqn}.${table2Columns[16].name}`}`, - `column-edge-${`${table1Fqn}.${table1Columns[18].name}`}-${`${table2Fqn}.${table2Columns[17].name}`}`, - ]; - - for (const edgeId of allVisibleEdges) { - await expect(page.locator(`[data-testid="${edgeId}"]`)).toBeVisible(); - } - }); - } finally { - await afterAction(); - } - }); -}); - -test('Verify custom properties tab visibility in lineage sidebar', async ({ - page, -}) => { - const { apiContext } = await getApiContext(page); - const currentTable = new TableClass(); - const upstreamTable = new TableClass(); - const downstreamTable = new TableClass(); - - // Create test entities - await Promise.all([ - currentTable.create(apiContext), - upstreamTable.create(apiContext), - downstreamTable.create(apiContext), - ]); - - await test.step('Create lineage connections', async () => { - const currentTableId = currentTable.entityResponseData?.id; - const upstreamTableId = upstreamTable.entityResponseData?.id; - const downstreamTableId = downstreamTable.entityResponseData?.id; - - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: upstreamTableId, - type: 'table', - }, - { - id: currentTableId, - type: 'table', - }, - [] - ); - await connectEdgeBetweenNodesViaAPI( - apiContext, - { - id: currentTableId, - type: 'table', - }, - { - id: downstreamTableId, - type: 'table', - }, - [] - ); - }); - - await test.step('Navigate to lineage tab and verify custom properties tab in sidebar', async () => { - // Navigate to the entity detail page first (required for visitLineageTab) - const searchTerm = - currentTable.entityResponseData?.['fullyQualifiedName'] || - currentTable.entity.name; - - await currentTable.visitEntityPage(page, searchTerm); - - // Navigate to lineage tab (this navigates to the full lineage page) - await visitLineageTab(page); - - // Click on the current entity node to open the sidebar drawer - const nodeFqn = currentTable.entityResponseData?.['fullyQualifiedName']; - - await clickLineageNode(page, nodeFqn); - - // Wait for the lineage entity panel (sidebar drawer) to open - const lineagePanel = page.getByTestId('lineage-entity-panel'); - await expect(lineagePanel).toBeVisible(); - - // Wait for the panel content to load - await waitForAllLoadersToDisappear(page); - - // Try to find custom properties tab in the lineage sidebar - use data-testid first (priority 1) - const customPropertiesTab = lineagePanel.getByTestId( - 'custom-properties-tab' - ); - - await expect(customPropertiesTab).toBeVisible(); - - await customPropertiesTab.click(); - await waitForAllLoadersToDisappear(page); - }); -}); - -test.describe('Verify custom properties tab visibility logic for supported entity types', () => { - const supportedEntities = [ - { entity: new TableClass(), type: 'table' }, - { entity: new TopicClass(), type: 'topic' }, - { entity: new DashboardClass(), type: 'dashboard' }, - { entity: new PipelineClass(), type: 'pipeline' }, - { entity: new MlModelClass(), type: 'mlmodel' }, - { entity: new ContainerClass(), type: 'container' }, - { entity: new SearchIndexClass(), type: 'searchIndex' }, - { entity: new ApiEndpointClass(), type: 'apiEndpoint' }, - { entity: new MetricClass(), type: 'metric' }, - { entity: new ChartClass(), type: 'chart' }, - ]; - - test.beforeAll(async ({ browser }) => { - const { apiContext } = await createNewPage(browser); - - for (const { entity } of supportedEntities) { - await entity.create(apiContext); - } - }); - - test.beforeEach(async ({ page }) => { - await redirectToHomePage(page); - }); - - for (const { entity, type } of supportedEntities) { - test(`Verify custom properties tab IS visible for supported type: ${type}`, async ({ - page, - }) => { - test.slow(); - - const searchTerm = - entity.entityResponseData?.['fullyQualifiedName'] || entity.entity.name; - - await entity.visitEntityPage(page, searchTerm); - await visitLineageTab(page); - - const nodeFqn = entity.entityResponseData?.['fullyQualifiedName']; - - await clickLineageNode(page, nodeFqn); - - const lineagePanel = page.getByTestId('lineage-entity-panel'); - await expect(lineagePanel).toBeVisible(); - await waitForAllLoadersToDisappear(page); - - const customPropertiesTab = lineagePanel.getByTestId( - 'custom-properties-tab' - ); - await expect(customPropertiesTab).toBeVisible(); - - const closeButton = lineagePanel.getByTestId('drawer-close-icon'); - if (await closeButton.isVisible()) { - await closeButton.click(); - await expect(lineagePanel).not.toBeVisible(); - } - }); - } -}); - -test.describe('Verify custom properties tab is NOT visible for unsupported entity types in platform lineage', () => { - const unsupportedServices = [ - { service: new DatabaseServiceClass(), type: 'databaseService' }, - { service: new MessagingServiceClass(), type: 'messagingService' }, - { service: new DashboardServiceClass(), type: 'dashboardService' }, - { service: new PipelineServiceClass(), type: 'pipelineService' }, - { service: new MlmodelServiceClass(), type: 'mlmodelService' }, - { service: new StorageServiceClass(), type: 'storageService' }, - { service: new ApiServiceClass(), type: 'apiService' }, - ]; - - test.beforeAll(async ({ browser }) => { - const { apiContext } = await createNewPage(browser); - - for (const { service } of unsupportedServices) { - await service.create(apiContext); - } - }); - - test.beforeEach(async ({ page }) => { - await redirectToHomePage(page); - }); - - for (const { service, type } of unsupportedServices) { - test(`Verify custom properties tab is NOT visible for ${type} in platform lineage`, async ({ - page, - }) => { - test.slow(); - - const serviceFqn = get(service, 'entityResponseData.fullyQualifiedName'); - - await sidebarClick(page, SidebarItem.LINEAGE); - - const searchEntitySelect = page.getByTestId('search-entity-select'); - await expect(searchEntitySelect).toBeVisible(); - await searchEntitySelect.click(); - - const searchInput = page - .getByTestId('search-entity-select') - .locator('.ant-select-selection-search-input'); - - const searchResponse = page.waitForResponse((response) => - response.url().includes('/api/v1/search/query') - ); - await searchInput.fill(service.entity.name); - - const searchResponseResult = await searchResponse; - expect(searchResponseResult.status()).toBe(200); - - const nodeSuggestion = page.getByTestId(`node-suggestion-${serviceFqn}`); - //small timeout to wait for the node suggestion to be visible in dropdown - await expect(nodeSuggestion).toBeVisible(); - - const lineageResponse = page.waitForResponse((response) => - response.url().includes('/api/v1/lineage/getLineage') - ); - - await nodeSuggestion.click(); - - const lineageResponseResult = await lineageResponse; - expect(lineageResponseResult.status()).toBe(200); - - await expect( - page.getByTestId(`lineage-node-${serviceFqn}`) - ).toBeVisible(); - - await clickLineageNode(page, serviceFqn); - - const lineagePanel = page.getByTestId('lineage-entity-panel'); - await expect(lineagePanel).toBeVisible(); - await waitForAllLoadersToDisappear(page); - - const customPropertiesTab = lineagePanel.getByTestId( - 'custom-properties-tab' - ); - const customPropertiesTabByRole = lineagePanel.getByRole('menuitem', { - name: /custom propert/i, - }); - - await expect(customPropertiesTab).not.toBeVisible(); - await expect(customPropertiesTabByRole).not.toBeVisible(); - - const closeButton = lineagePanel.getByTestId('drawer-close-icon'); - if (await closeButton.isVisible()) { - await closeButton.click(); - await expect(lineagePanel).not.toBeVisible(); - } - }); - } -}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/DataAssetLineage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/DataAssetLineage.spec.ts new file mode 100644 index 000000000000..6716369a7960 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/DataAssetLineage.spec.ts @@ -0,0 +1,508 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get, startCase } from 'lodash'; +import { ApiEndpointClass } from '../../../support/entity/ApiEndpointClass'; +import { ContainerClass } from '../../../support/entity/ContainerClass'; +import { DashboardClass } from '../../../support/entity/DashboardClass'; +import { DashboardDataModelClass } from '../../../support/entity/DashboardDataModelClass'; +import { DirectoryClass } from '../../../support/entity/DirectoryClass'; +import { FileClass } from '../../../support/entity/FileClass'; +import { MetricClass } from '../../../support/entity/MetricClass'; +import { MlModelClass } from '../../../support/entity/MlModelClass'; +import { PipelineClass } from '../../../support/entity/PipelineClass'; +import { SearchIndexClass } from '../../../support/entity/SearchIndexClass'; +import { SpreadsheetClass } from '../../../support/entity/SpreadsheetClass'; +import { StoredProcedureClass } from '../../../support/entity/StoredProcedureClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { TopicClass } from '../../../support/entity/TopicClass'; +import { WorksheetClass } from '../../../support/entity/WorksheetClass'; +import { + clickOutside, + getApiContext, + getDefaultAdminAPIContext, + redirectToHomePage, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { + activateColumnLayer, + addColumnLineage, + addPipelineBetweenNodes, + applyPipelineFromModal, + clickLineageNode, + connectEdgeBetweenNodes, + deleteEdge, + deleteNode, + editLineage, + editLineageClick, + getEntityColumns, + performZoomOut, + rearrangeNodes, + removeColumnLineage, + toggleLineageFilters, + verifyColumnLineageInCSV, + verifyExportLineageCSV, + verifyExportLineagePNG, + verifyNodePresent, + verifyPlatformLineageForEntity, + visitLineageTab, +} from '../../../utils/lineage'; +import { test } from '../../fixtures/pages'; + +// Contains list of entity supported +const allEntities = { + table: TableClass, + container: ContainerClass, + topic: TopicClass, + dashboard: DashboardClass, + mlmodel: MlModelClass, + pipeline: PipelineClass, + storedProcedure: StoredProcedureClass, + searchIndex: SearchIndexClass, + dataModel: DashboardDataModelClass, + apiEndpoint: ApiEndpointClass, + metric: MetricClass, + directory: DirectoryClass, + file: FileClass, + spreadsheet: SpreadsheetClass, + worksheet: WorksheetClass, +}; + +const columnLevelEntities = { + table: TableClass, + container: ContainerClass, + topic: TopicClass, + apiEndpoint: ApiEndpointClass, + dashboard: DashboardClass, + dashboardDataModel: DashboardDataModelClass, + searchIndex: SearchIndexClass, + mlModel: MlModelClass, +}; + +type EntityClassUnion = + | TableClass + | ContainerClass + | TopicClass + | DashboardClass + | MlModelClass + | PipelineClass + | StoredProcedureClass + | SearchIndexClass + | DashboardDataModelClass + | ApiEndpointClass + | MetricClass + | DirectoryClass + | FileClass + | SpreadsheetClass + | WorksheetClass; + +test.describe('Data asset lineage', () => { + const pipeline = new PipelineClass(); + const entities: EntityClassUnion[] = []; + + test.beforeAll( + 'setup lineage creation with other entity creation', + async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + + Object.values(allEntities).forEach((EntityClass) => { + const lineageEntity = new EntityClass(); + + entities.push(lineageEntity); + }); + + try { + await pipeline.create(apiContext); + await Promise.all(entities.map((entity) => entity.create(apiContext))); + } catch (error) { + console.error('Error creating entities:', error); + } + } + ); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + Object.entries(allEntities).forEach(([key, EntityClass]) => { + const lineageEntity = new EntityClass(); + + test(`verify create lineage for entity - ${startCase(key)}`, async ({ + page, + }) => { + // 7 minute timeout + test.setTimeout(7 * 60 * 1000); + + await test.step('prepare entity', async () => { + const { apiContext } = await getApiContext(page); + + await lineageEntity.create(apiContext); + await lineageEntity.visitEntityPage(page); + await visitLineageTab(page); + await editLineageClick(page); + }); + + await test.step('should create lineage with normal edge', async () => { + for (const entity of entities) { + await connectEdgeBetweenNodes(page, lineageEntity, entity); + await rearrangeNodes(page); + await performZoomOut(page); + } + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.reload(); + await lineageRes; + await page.getByTestId('edit-lineage').waitFor({ + state: 'visible', + }); + + await waitForAllLoadersToDisappear(page); + await page + .getByTestId( + `lineage-node-${lineageEntity.entityResponseData.fullyQualifiedName}` + ) + .waitFor(); + await rearrangeNodes(page); + await performZoomOut(page); + + for (const entity of entities) { + await verifyNodePresent(page, entity); + } + + // Check the Entity Drawer + await performZoomOut(page); + + for (const entity of entities) { + const toNodeFqn = get( + entity, + 'entityResponseData.fullyQualifiedName', + '' + ); + const entityName = get( + entity, + 'entityResponseData.displayName', + get(entity, 'entityResponseData.name', '') + ); + + await clickLineageNode(page, toNodeFqn); + + await expect( + page + .locator('.lineage-entity-panel') + .getByTestId('entity-header-title') + ).toHaveText(entityName); + + await page.getByTestId('drawer-close-icon').click(); + + // Panel should not be visible after closing it + await expect(page.locator('.lineage-entity-panel')).not.toBeVisible(); + } + }); + + await test.step('should create lineage with edge having pipeline', async () => { + await editLineage(page); + + await page.getByTestId('fit-screen').click(); + await page.getByRole('menuitem', { name: 'Fit to screen' }).click(); + await performZoomOut(page, 8); + await waitForAllLoadersToDisappear(page); + + const fromNodeFqn = get( + lineageEntity, + 'entityResponseData.fullyQualifiedName', + '' + ); + + await clickLineageNode(page, fromNodeFqn); + + for (const entity of entities) { + await applyPipelineFromModal(page, lineageEntity, entity, pipeline); + } + }); + + await test.step('Verify Lineage Export CSV', async () => { + await editLineageClick(page); + await waitForAllLoadersToDisappear(page); + await performZoomOut(page); + await verifyExportLineageCSV(page, lineageEntity, entities, pipeline); + }); + + await test.step('Verify Lineage Export PNG', async () => { + await verifyExportLineagePNG(page); + }); + + await test.step('Remove lineage between nodes for the entity', async () => { + await editLineage(page); + await page.getByTestId('fit-screen').click(); + await page.getByRole('menuitem', { name: 'Fit to screen' }).click(); + await waitForAllLoadersToDisappear(page); + + await performZoomOut(page); + + for (const entity of entities) { + await deleteEdge(page, lineageEntity, entity); + } + }); + }); + }); +}); + +test.describe('Column Level Lineage', () => { + const entities: Map = new Map(); + + test.beforeAll( + 'setup lineage creation with other entity creation', + async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + + Object.entries(columnLevelEntities).forEach(([key, EntityClass]) => { + const lineageEntity = new EntityClass(); + + entities.set(key, lineageEntity); + }); + + try { + await Promise.all( + Array.from(entities.values()).map((entity) => + entity.create(apiContext) + ) + ); + } catch (error) { + console.error('Error creating entities:', error); + } + } + ); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + Object.entries(columnLevelEntities).forEach(([key, EntityClassSource]) => { + const sourceEntity = new EntityClassSource(); + const entityKeys = Object.keys(columnLevelEntities); + + entityKeys.forEach((targetKey) => { + test(`Column lineage for ${key} -> ${targetKey}`, async ({ page }) => { + const targetEntity = entities.get(targetKey) as EntityClassUnion; + const { apiContext, afterAction } = await getApiContext(page); + + await sourceEntity.create(apiContext); + + const sourceColumns = getEntityColumns(sourceEntity, key); + const targetColumns = getEntityColumns(targetEntity, targetKey); + + const sourceCol = get(sourceColumns, '[0].fullyQualifiedName', ''); + const targetCol = get(targetColumns, '[0].fullyQualifiedName', ''); + + await test.step('Add column lineage', async () => { + await addPipelineBetweenNodes(page, sourceEntity, targetEntity); + await activateColumnLayer(page); + + // Add column lineage + await addColumnLineage(page, sourceCol, targetCol); + }); + + await test.step('Column lineage export as CSV', async () => { + // Verify column lineage + await redirectToHomePage(page); + await sourceEntity.visitEntityPage(page); + await visitLineageTab(page); + await verifyColumnLineageInCSV( + page, + sourceEntity, + targetEntity, + sourceCol, + targetCol + ); + }); + + await test.step('Verify nodes in Platform Lineage', async () => { + await verifyPlatformLineageForEntity( + page, + sourceEntity.entityResponseData.fullyQualifiedName ?? '', + targetEntity.entityResponseData.fullyQualifiedName ?? '' + ); + }); + + await test.step('Remove column lineage', async () => { + await sourceEntity.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await editLineageClick(page); + + await removeColumnLineage(page, sourceCol, targetCol); + await editLineageClick(page); + }); + + await deleteNode(page, targetEntity); + await sourceEntity.delete(apiContext); + + await afterAction(); + }); + }); + }); + + test('Verify column layer is applied on entering edit mode', async ({ + page, + }) => { + const { apiContext, afterAction } = await getApiContext(page); + const table = new TableClass(); + + await table.create(apiContext); + + try { + await table.visitEntityPage(page); + await visitLineageTab(page); + + const columnLayerBtn = page.locator( + '[data-testid="lineage-layer-column-btn"]' + ); + + await test.step('Verify column layer is inactive initially', async () => { + await page.click('[data-testid="lineage-layer-btn"]'); + + await expect(columnLayerBtn).not.toHaveClass(/Mui-selected/); + + await clickOutside(page); + }); + + await test.step('Enter edit mode and verify column layer is active', async () => { + await editLineageClick(page); + + await page.click('[data-testid="lineage-layer-btn"]'); + + await expect(columnLayerBtn).toHaveClass(/Mui-selected/); + + await clickOutside(page); + }); + } finally { + await table.delete(apiContext); + await afterAction(); + } + }); + + test('Verify there is no traced nodes and columns on exiting edit mode', async ({ + page, + }) => { + const { apiContext, afterAction } = await getApiContext(page); + const table = new TableClass(); + + await table.create(apiContext); + + try { + await table.visitEntityPage(page); + await visitLineageTab(page); + + const tableFqn = get(table, 'entityResponseData.fullyQualifiedName', ''); + const tableNode = page.getByTestId(`lineage-node-${tableFqn}`); + const firstColumnName = get( + table, + 'entityResponseData.columns[0].fullyQualifiedName' + ); + const firstColumn = page.getByTestId(`column-${firstColumnName}`); + + await test.step('Verify node tracing is cleared on exiting edit mode', async () => { + await editLineageClick(page); + + await expect(tableNode).not.toHaveClass(/custom-node-header-active/); + + await tableNode.click({ position: { x: 5, y: 5 } }); + + await expect(tableNode).toHaveClass(/custom-node-header-active/); + + await editLineageClick(page); + + await expect(tableNode).not.toHaveClass(/custom-node-header-active/); + }); + + await test.step('Verify column tracing is cleared on exiting edit mode', async () => { + await editLineageClick(page); + + await firstColumn.click(); + + await expect(firstColumn).toHaveClass( + /custom-node-header-column-tracing/ + ); + + await editLineageClick(page); + + await toggleLineageFilters(page, tableFqn); + + await expect(firstColumn).not.toHaveClass( + /custom-node-header-column-tracing/ + ); + }); + } finally { + await table.delete(apiContext); + await afterAction(); + } + }); +}); + +test.describe('Lineage Settings modal', () => { + const table = new TableClass(); + + test.beforeAll(async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + await table.create(apiContext); + }); + + test.beforeEach(async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + }); + + test('Verify opening config modal', async ({ page }) => { + await page.getByTestId('lineage-config').click(); + + await expect(page.locator('[role="dialog"]')).toBeVisible(); + + await expect(page.getByLabel(/upstream/i)).toBeVisible(); + await expect(page.getByLabel(/downstream/i)).toBeVisible(); + }); + + test('Verify updating depth configuration', async ({ page }) => { + await page.getByTestId('lineage-config').click(); + + await page.getByLabel(/upstream/i).fill('5'); + await page.getByLabel(/downstream/i).fill('4'); + + await page.getByRole('button', { name: /Ok/i }).click(); + + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + + await page.reload(); + await waitForAllLoadersToDisappear(page); + + await page.getByTestId('lineage-config').click(); + + await expect(page.getByLabel(/upstream/i)).toHaveValue('5'); + await expect(page.getByLabel(/downstream/i)).toHaveValue('4'); + }); + + test('Verify validation for invalid depth', async ({ page }) => { + await page.getByTestId('lineage-config').click(); + + await page.getByLabel(/upstream/i).fill('-1'); + await page.getByRole('button', { name: /Ok/i }).click(); + + await expect(page.getByText(/cannot be less than/i)).toBeVisible(); + + await expect(page.locator('[role="dialog"]')).toBeVisible(); + + await page.getByLabel(/upstream/i).fill('3'); + await page.getByRole('button', { name: /Ok/i }).click(); + + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts new file mode 100644 index 000000000000..3016af067bb6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageControls.spec.ts @@ -0,0 +1,253 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get } from 'lodash'; +import { EntityDataClass } from '../../../support/entity/EntityDataClass'; +import { PipelineClass } from '../../../support/entity/PipelineClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { TopicClass } from '../../../support/entity/TopicClass'; +import { + getApiContext, + getDefaultAdminAPIContext, + redirectToHomePage, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { + clickLineageNode, + connectEdgeBetweenNodesViaAPI, + performZoomOut, + visitLineageTab, +} from '../../../utils/lineage'; +import { sidebarClick } from '../../../utils/sidebar'; +import { test } from '../../fixtures/pages'; + +const table = new TableClass(); +const topic = new TopicClass(); +const pipeline = new PipelineClass(); + +test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext(browser); + + await Promise.all([ + table.create(apiContext), + topic.create(apiContext), + pipeline.create(apiContext), + ]); + + // Patch topic with metadata for filter tests + await topic.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/owners/0', + value: { + type: 'user', + id: EntityDataClass.user1.responseData.id, + }, + }, + { + op: 'add', + path: '/domains/0', + value: { + type: 'domain', + id: EntityDataClass.domain1.responseData.id, + }, + }, + ], + }); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table.entityResponseData.id, type: 'table' }, + { id: topic.entityResponseData.id, type: 'topic' } + ); + + await afterAction(); +}); + +test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext(browser); + await Promise.all([ + table.delete(apiContext), + topic.delete(apiContext), + pipeline.delete(apiContext), + ]); + await afterAction(); +}); + +test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); +}); + +// ==================== +// Suite 1: Canvas Control Buttons (4 tests) +// ==================== +test.describe('Canvas Controls', () => { + test.beforeEach(async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + }); + + test('Verify zoom in and zoom out controls', async ({ page }) => { + const zoomInBtn = page.getByTestId('zoom-in'); + const zoomOutBtn = page.getByTestId('zoom-out'); + + await performZoomOut(page, 5); + + for (let i = 0; i < 3; i++) { + await zoomInBtn.click(); + } + + for (let i = 0; i < 3; i++) { + await zoomOutBtn.click(); + } + + await expect(zoomInBtn).toBeVisible(); + await expect(zoomOutBtn).toBeVisible(); + }); + + test('Verify fit view options menu', async ({ page }) => { + await page.getByTestId('fit-screen').click(); + await expect(page.locator('#lineage-view-options-menu')).toBeVisible(); + + await page.getByRole('menuitem', { name: 'Fit to screen' }).click(); + + const tableFqn = get(table, 'entityResponseData.fullyQualifiedName', ''); + await clickLineageNode(page, tableFqn); + + await page.getByTestId('fit-screen').click(); + await page.getByRole('menuitem', { name: 'Refocused to selected' }).click(); + + await page.getByTestId('fit-screen').click(); + await page.getByRole('menuitem', { name: 'Rearrange Nodes' }).click(); + + await page.getByTestId('fit-screen').click(); + await page.getByRole('menuitem', { name: 'Refocused to home' }).click(); + }); + + test('Verify minimap toggle functionality', async ({ page }) => { + const minimap = page.locator('.react-flow__minimap'); + await expect(minimap).toBeVisible(); + + await page.getByTestId('toggle-mind-map').click(); + await expect(minimap).not.toBeVisible(); + + await page.getByTestId('toggle-mind-map').click(); + await expect(minimap).toBeVisible(); + }); + + test('Verify fullscreen toggle', async ({ page }) => { + expect(page.url()).not.toContain('fullscreen=true'); + + await page.getByTestId('full-screen').click(); + + expect(page.url()).toContain('fullscreen=true'); + + await page.getByTestId('exit-full-screen').click(); + + expect(page.url()).not.toContain('fullscreen=true'); + }); +}); + +test.describe('Lineage Layers', () => { + test.describe('Data Observability Layer', () => { + test.beforeEach(async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + }); + + test('Verify DQ layer toggle activation', async ({ page }) => { + await page.getByTestId('lineage-layer-btn').click(); + + const observabilityBtn = page.getByTestId( + 'lineage-layer-observability-btn' + ); + await expect(observabilityBtn).toBeVisible(); + + await expect(observabilityBtn).not.toHaveClass(/Mui-selected/); + + await observabilityBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(observabilityBtn).toHaveClass(/Mui-selected/); + }); + + test('Verify DQ layer toggle off removes highlights', async ({ page }) => { + await page.getByTestId('lineage-layer-btn').click(); + + const observabilityBtn = page.getByTestId( + 'lineage-layer-observability-btn' + ); + + await observabilityBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(observabilityBtn).toHaveClass(/Mui-selected/); + + await observabilityBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(observabilityBtn).not.toHaveClass(/Mui-selected/); + }); + }); + + test.describe('Error Handling', () => { + test('Verify invalid entity search handling', async ({ page }) => { + await sidebarClick(page, 'Govern'); + await page.getByTestId('appbar-item-lineage').click(); + + await waitForAllLoadersToDisappear(page); + + const searchSelect = page.getByTestId('search-entity-select'); + await expect(searchSelect).toBeVisible(); + + await searchSelect.click(); + + await page + .locator( + '[data-testid="search-entity-select"] .ant-select-selection-search-input' + ) + .fill('invalid_fqn_does_not_exist_12345'); + + const noResultsText = page.getByText(/no match/i); + if ((await noResultsText.count()) > 0) { + await expect(noResultsText).toBeVisible(); + } + }); + + test('Verify lineage tab with no lineage data', async ({ page }) => { + const emptyTable = new TableClass(); + const { apiContext, afterAction } = await getApiContext(page); + + await emptyTable.create(apiContext); + + await emptyTable.visitEntityPage(page); + await visitLineageTab(page); + + await waitForAllLoadersToDisappear(page); + + const tableFqn = get(emptyTable, 'entityResponseData.fullyQualifiedName'); + const tableNode = page.getByTestId(`lineage-node-${tableFqn}`); + await expect(tableNode).toBeVisible(); + + await emptyTable.delete(apiContext); + await afterAction(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageFilters.spec.ts new file mode 100644 index 000000000000..7b20f49a8679 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageFilters.spec.ts @@ -0,0 +1,809 @@ +/* + * Copyright 2026 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 } from '@playwright/test'; +import { get, lowerCase } from 'lodash'; +import { ApiEndpointClass } from '../../../support/entity/ApiEndpointClass'; +import { ContainerClass } from '../../../support/entity/ContainerClass'; +import { DashboardClass } from '../../../support/entity/DashboardClass'; +import { DashboardDataModelClass } from '../../../support/entity/DashboardDataModelClass'; +import { DirectoryClass } from '../../../support/entity/DirectoryClass'; +import { EntityDataClass } from '../../../support/entity/EntityDataClass'; +import { FileClass } from '../../../support/entity/FileClass'; +import { MetricClass } from '../../../support/entity/MetricClass'; +import { MlModelClass } from '../../../support/entity/MlModelClass'; +import { PipelineClass } from '../../../support/entity/PipelineClass'; +import { SearchIndexClass } from '../../../support/entity/SearchIndexClass'; +import { SpreadsheetClass } from '../../../support/entity/SpreadsheetClass'; +import { StoredProcedureClass } from '../../../support/entity/StoredProcedureClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { TopicClass } from '../../../support/entity/TopicClass'; +import { WorksheetClass } from '../../../support/entity/WorksheetClass'; +import { + getApiContext, + getDefaultAdminAPIContext, + getEntityTypeSearchIndexMapping, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { + connectEdgeBetweenNodesViaAPI, + performZoomOut, + rearrangeNodes, + visitLineageTab, +} from '../../../utils/lineage'; +import { test } from '../../fixtures/pages'; + +type EntityClassUnion = + | TableClass + | ContainerClass + | TopicClass + | DashboardClass + | MlModelClass + | PipelineClass + | StoredProcedureClass + | SearchIndexClass + | DashboardDataModelClass + | ApiEndpointClass + | MetricClass + | DirectoryClass + | FileClass + | SpreadsheetClass + | WorksheetClass; + +// Contains list of entity supported +const allEntities = { + table: TableClass, + container: ContainerClass, + topic: TopicClass, + dashboard: DashboardClass, + mlmodel: MlModelClass, + pipeline: PipelineClass, + storedProcedure: StoredProcedureClass, + searchIndex: SearchIndexClass, + dataModel: DashboardDataModelClass, + apiEndpoint: ApiEndpointClass, + metric: MetricClass, + directory: DirectoryClass, + file: FileClass, + spreadsheet: SpreadsheetClass, + worksheet: WorksheetClass, +}; + +test.describe('Lineage Filters', () => { + const lineageEntity = new TableClass(); + const entities = Object.values(allEntities).map( + (EntityClass) => new EntityClass() + ); + const [depth1Entity, ...depth2ndEntities] = entities; + + test.beforeAll(async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + + await lineageEntity.create(apiContext); + await Promise.all(entities.map((entity) => entity.create(apiContext))); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: lineageEntity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(lineageEntity.type), + }, + { + id: depth1Entity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(depth1Entity.type), + } + ); + + for (const entity of depth2ndEntities) { + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: depth1Entity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(lineageEntity.type), + }, + { + id: entity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(entity.type), + } + ); + } + }); + + test.beforeEach(async ({ page }) => { + await lineageEntity.visitEntityPage(page); + await visitLineageTab(page); + await waitForAllLoadersToDisappear(page); + await rearrangeNodes(page); + await performZoomOut(page); + await expect( + page.getByTestId( + `lineage-node-${lineageEntity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + }); + + const filterConfigs = [ + { + filterName: 'Domains', + filterTestId: 'Domains', + setupMetadata: async ( + apiContext: APIRequestContext, + entitiesToPatch: EntityClassUnion[] + ) => { + for (const entity of entitiesToPatch) { + await entity.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: { + type: 'domain', + id: EntityDataClass.domain1.responseData.id, + }, + path: '/domains/0', + }, + ], + }); + } + }, + filterValue: EntityDataClass.domain1.responseData.displayName, + }, + { + filterName: 'Owners', + filterTestId: 'Owners', + setupMetadata: async ( + apiContext: APIRequestContext, + entitiesToPatch: EntityClassUnion[] + ) => { + for (const entity of entitiesToPatch) { + await entity.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: { + type: 'user', + id: EntityDataClass.user1.responseData.id, + }, + path: '/owners/0', + }, + ], + }); + } + }, + filterValue: EntityDataClass.user1.responseData.displayName, + }, + { + filterName: 'Tag', + filterTestId: 'Tag', + setupMetadata: async ( + apiContext: APIRequestContext, + entitiesToPatch: EntityClassUnion[] + ) => { + for (const entity of entitiesToPatch) { + await entity.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: [ + { + tagFQN: + EntityDataClass.tag1.responseData.fullyQualifiedName, + source: 'Classification', + labelType: 'Manual', + state: 'Confirmed', + }, + ], + path: '/tags', + }, + ], + }); + } + }, + filterValue: EntityDataClass.tag1.responseData.fullyQualifiedName, + }, + { + filterName: 'Tier', + filterTestId: 'Tier', + setupMetadata: async ( + apiContext: APIRequestContext, + entitiesToPatch: EntityClassUnion[] + ) => { + for (const entity of entitiesToPatch) { + await entity.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: [ + { + tagFQN: + EntityDataClass.tierTag1.responseData.fullyQualifiedName, + source: 'Classification', + labelType: 'Manual', + state: 'Confirmed', + }, + ], + path: '/tags', + }, + ], + }); + } + }, + filterValue: EntityDataClass.tierTag1.responseData.displayName, + }, + ]; + + filterConfigs.forEach( + ({ filterName, filterTestId, setupMetadata, filterValue }) => { + test(`Verify lineage ${filterName} filter with partial metadata assignment`, async ({ + page, + }) => { + const { apiContext, afterAction } = await getApiContext(page); + + const entitiesToShow: EntityClassUnion[] = [ + lineageEntity, + depth1Entity, + ]; + const entitiesToHide: EntityClassUnion[] = []; + + depth2ndEntities.forEach((entity, index) => { + if (index % 2 === 0) { + entitiesToShow.push(entity); + } else { + entitiesToHide.push(entity); + } + }); + + await setupMetadata(apiContext, entitiesToShow); + + await page.reload(); + await waitForAllLoadersToDisappear(page); + + await page.getByTestId('filters-button').click(); + await page.getByTestId(`search-dropdown-${filterTestId}`).click(); + + await page.getByTitle(filterValue).click(); + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await rearrangeNodes(page); + await performZoomOut(page); + + for (const entity of entitiesToShow) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + } + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + await afterAction(); + }); + } + ); + + test('Verify lineage filter panel toggle', async ({ page }) => { + const filterBtn = page.locator('[aria-label="Filters"]'); + + await filterBtn.click(); + await expect( + page + .getByTestId('lineage-details') + .getByRole('button', { name: 'Domains' }) + ).toBeVisible(); + await expect(page.getByTestId('search-dropdown-Owners')).toBeVisible(); + await expect(page.getByTestId('search-dropdown-Tier')).toBeVisible(); + + await filterBtn.click(); + await expect( + page + .getByTestId('lineage-details') + .getByRole('button', { name: 'Domains' }) + ).not.toBeVisible(); + await expect(page.getByTestId('search-dropdown-Owners')).not.toBeVisible(); + await expect(page.getByTestId('search-dropdown-Tier')).not.toBeVisible(); + }); + + test('Verify lineage service filter selection', async ({ page }) => { + await page.locator('[aria-label="Filters"]').click(); + + for (let index = 0; index < depth2ndEntities.length; index++) { + const entity = depth2ndEntities[index]; + // TODO: Need to fix container selection + if (entity.type === 'Metric' || entity.type === 'Container') { + continue; + } + await test.step(`Select service type for ${entity.entityResponseData.fullyQualifiedName}`, async () => { + await page.getByTestId('search-dropdown-Service').click(); + await page.getByTestId('drop-down-menu').getByTestId('loader').waitFor({ + state: 'hidden', + }); + const serviceName = get( + entity, + entity.type === 'Metric' + ? 'entityResponseData.name' + : 'entityResponseData.service.name', + '' + ); + + const searchResponse = page.waitForResponse( + (response) => + response.url().includes(`/api/v1/search/aggregate`) && + response.request().method() === 'POST' + ); + + await page + .getByTestId('drop-down-menu') + .getByTestId('search-input') + .fill(serviceName); + await searchResponse; + await page + .getByTestId('drop-down-menu') + .getByTestId(`${serviceName}-checkbox`) + .waitFor(); + await page + .getByTestId('drop-down-menu') + .getByTestId(`${serviceName}-checkbox`) + .click(); + + const entitiesToShow = [lineageEntity, depth1Entity, entity]; + + // service entity and base entity will be visible + // rest of them will be hidden + const entitiesToHide = depth2ndEntities.filter( + (_, idx) => idx !== index + ); + + await page.getByRole('button', { name: 'Update' }).click(); + await expect(page.getByRole('button', { name: 'Update' })).toBeHidden(); + + await rearrangeNodes(page); + await performZoomOut(page); + + for (const entity of entitiesToShow) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + } + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + // clear the filter after validation + const clearAllBtn = page.getByRole('button', { name: /clear/i }); + await expect(clearAllBtn).toBeEnabled(); + + await clearAllBtn.click(); + + await waitForAllLoadersToDisappear(page); + }); + } + }); + + test.fixme( + 'Verify lineage service type filter selection', + async ({ page }) => { + await page.locator('[aria-label="Filters"]').click(); + + for (let index = 0; index < depth2ndEntities.length; index++) { + const entity = depth2ndEntities[index]; + + if (entity.type === 'Metric' || entity.type === 'Container') { + continue; + } + + await test.step(`Select service type for ${entity.entityResponseData.fullyQualifiedName}`, async () => { + await page.getByTestId('search-dropdown-Service Type').click(); + const serviceType = lowerCase( + get(entity, 'entityResponseData.serviceType', '') + ); + + const searchResponse = page.waitForResponse( + (response) => + response.url().includes(`/api/v1/search/aggregate`) && + response.request().method() === 'POST' + ); + await page + .getByTestId('drop-down-menu') + .getByTestId('search-input') + .fill(serviceType); + await searchResponse; + await page + .getByTestId('drop-down-menu') + .getByTestId(`${serviceType}-checkbox`) + .waitFor(); + await page + .getByTestId('drop-down-menu') + .getByTestId(`${serviceType}-checkbox`) + .click(); + + const entitiesToShow = [lineageEntity, depth1Entity, entity]; + + // service entity and base entity will be visible + // rest of them will be hidden + const entitiesToHide = entities.filter((_, idx) => idx !== index); + + const lineageRes = page.waitForResponse( + '/api/v1/lineage/getLineage?*' + ); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await rearrangeNodes(page); + await performZoomOut(page); + + for (const entity of entitiesToShow) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + } + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + // clear the filter after validation + const clearAllBtn = page.getByRole('button', { name: /clear/i }); + await expect(clearAllBtn).toBeEnabled(); + + await clearAllBtn.click(); + + await waitForAllLoadersToDisappear(page); + }); + } + } + ); + + test.describe('Verify lineage Database service related filters', () => { + test.beforeAll( + 'prepare lineage for database service connection', + async ({ browser }) => { + const mainEntity: TableClass = entities[0]; + const { apiContext } = await getDefaultAdminAPIContext(browser); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: lineageEntity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(lineageEntity.type), + }, + { + id: mainEntity.entityResponseData.id, + type: getEntityTypeSearchIndexMapping(mainEntity.type), + }, + [ + { + fromColumns: [ + lineageEntity.entityResponseData.columns[0] + .fullyQualifiedName ?? '', + ], + toColumn: + mainEntity.entityResponseData.columns[0].fullyQualifiedName ?? + '', + }, + { + fromColumns: [ + lineageEntity.entityResponseData.columns[0] + .fullyQualifiedName ?? '', + ], + toColumn: + mainEntity.entityResponseData.columns[0].fullyQualifiedName ?? + '', + }, + ] + ); + } + ); + + test('Verify lineage database filter selection', async ({ page }) => { + await page.locator('[aria-label="Filters"]').click(); + await page.getByTestId('search-dropdown-Database').click(); + + const [entityToTest, ...entitiesToHide] = entities; + + const databaseName = get( + entityToTest, + 'entityResponseData.database.name', + '' + ); + await page.getByTitle(databaseName).click(); + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await rearrangeNodes(page); + await performZoomOut(page); + + // filtered service node should be visible + await expect( + page.getByTestId( + `lineage-node-${entityToTest.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + // main entity node should always be visible + await expect( + page.getByTestId( + `lineage-node-${lineageEntity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + await waitForAllLoadersToDisappear(page); + }); + + test('Verify lineage schema filter selection', async ({ page }) => { + await page.locator('[aria-label="Filters"]').click(); + await page.getByTestId('search-dropdown-Schema').click(); + + const [entityToTest, ...entitiesToHide] = entities; + + const databaseSchemaName = get( + entityToTest, + 'entityResponseData.databaseSchema.name', + '' + ); + await page.getByTitle(databaseSchemaName).click(); + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await rearrangeNodes(page); + await performZoomOut(page); + + // filtered service node should be visible + await expect( + page.getByTestId( + `lineage-node-${entityToTest.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + // main entity node should always be visible + await expect( + page.getByTestId( + `lineage-node-${lineageEntity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + await waitForAllLoadersToDisappear(page); + }); + + test('Verify lineage column filter selection', async ({ page }) => { + await page.locator('[aria-label="Filters"]').click(); + await page.getByTestId('search-dropdown-Column').click(); + + const [entityToTest, ...entitiesToHide] = entities; + + const columnName = get( + entityToTest, + 'entityResponseData.columns[0].name', + '' + ); + await page.getByTitle(columnName).click(); + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await rearrangeNodes(page); + await performZoomOut(page); + + // filtered service node should be visible + await expect( + page.getByTestId( + `lineage-node-${entityToTest.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + // main entity node should always be visible + await expect( + page.getByTestId( + `lineage-node-${lineageEntity.entityResponseData.fullyQualifiedName}` + ) + ).toBeVisible(); + + for (const entity of entitiesToHide) { + await expect( + page.getByTestId( + `lineage-node-${entity.entityResponseData.fullyQualifiedName}` + ) + ).not.toBeVisible(); + } + + await waitForAllLoadersToDisappear(page); + }); + }); + + test('Verify lineage clear all filters', async ({ page }) => { + const { apiContext } = await getApiContext(page); + const entity = entities[2]; + await entity.patch({ + apiContext, + patchData: [ + { + op: 'add', + value: { + type: 'user', + id: EntityDataClass.user1.responseData.id, + }, + path: '/owners/0', + }, + ], + }); + + await page.reload(); + await waitForAllLoadersToDisappear(page); + + await page.locator('[aria-label="Filters"]').click(); + + await page.getByTestId('search-dropdown-Owners').click(); + await page.getByTitle(EntityDataClass.user1.responseData.name).click(); + + const lineageRes = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.getByRole('button', { name: 'Update' }).click(); + await lineageRes; + + await page.getByTestId('search-dropdown-Owners').click(); + + const clearAllBtn = page.getByRole('button', { name: /clear/i }); + await expect(clearAllBtn).toBeEnabled(); + + await clearAllBtn.click(); + }); + + test('Verify LineageSearchSelect in lineage mode', async ({ page }) => { + const searchSelect = page.getByTestId('lineage-search'); + await expect(searchSelect).toBeVisible(); + const topicEntity = entities[1]; + + await searchSelect.click(); + await page + .getByTestId('lineage-search') + .getByRole('combobox') + .fill(topicEntity.entity.name); + + const topicFqn = get(topicEntity, 'entityResponseData.fullyQualifiedName'); + await page.getByTestId(`option-${topicFqn}`).click(); + + await page.locator('.lineage-entity-panel').waitFor(); + await page + .getByTestId('entity-summary-panel-container') + .getByTestId('entity-header-title') + .waitFor(); + await expect( + page + .getByTestId('entity-summary-panel-container') + .getByTestId('entity-header-title') + ).toHaveText( + topicEntity.entityResponseData.displayName ?? topicEntity.entity.name + ); + + await page.getByTestId('drawer-close-icon').click(); + await page.locator('.lineage-entity-panel').waitFor({ + state: 'hidden', + }); + + await rearrangeNodes(page); + await performZoomOut(page); + + await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); + }); + + test.describe('Verify filters for Impact Analysis', () => { + test.beforeEach('navigate to impact analysis', async ({ page }) => { + await page.getByRole('tab', { name: 'Impact Analysis' }).click(); + await waitForAllLoadersToDisappear(page); + }); + + test('verify downstream count for all the entities', async ({ page }) => { + // validate main entity count + const count = entities.length; + await expect( + page.getByRole('button', { name: `Downstream ${count}` }) + ).toBeVisible(); + + await depth1Entity.visitEntityPage(page); + await visitLineageTab(page); + await page.getByRole('tab', { name: 'Impact Analysis' }).click(); + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByRole('button', { name: `Downstream ${count - 1}` }) + ).toBeVisible(); + + for (const entity of depth2ndEntities) { + await entity.visitEntityPage(page); + await visitLineageTab(page); + await page.getByRole('tab', { name: 'Impact Analysis' }).click(); + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByRole('button', { name: `Downstream 0` }) + ).toBeVisible(); + } + }); + + test('verify upstream count for all the entities', async ({ page }) => { + // Verify Dashboard is visible in Impact Analysis for Upstream + await page.getByRole('button', { name: 'Upstream' }).click(); + await waitForAllLoadersToDisappear(page); + + // validate main entity count + const count = 0; + await expect( + page.getByRole('button', { name: `Downstream ${count}` }) + ).toBeVisible(); + + await depth1Entity.visitEntityPage(page); + await visitLineageTab(page); + await page.getByRole('tab', { name: 'Impact Analysis' }).click(); + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByRole('button', { name: `Downstream ${count + 1}` }) + ).toBeVisible(); + + for (const entity of depth2ndEntities) { + await entity.visitEntityPage(page); + await visitLineageTab(page); + await page.getByRole('tab', { name: 'Impact Analysis' }).click(); + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByRole('button', { name: `Downstream 2` }) + ).toBeVisible(); + } + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageInteraction.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageInteraction.spec.ts new file mode 100644 index 000000000000..48207a1ba77f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageInteraction.spec.ts @@ -0,0 +1,970 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get } from 'lodash'; +import { DashboardClass } from '../../../support/entity/DashboardClass'; +import { EntityDataClass } from '../../../support/entity/EntityDataClass'; +import { MlModelClass } from '../../../support/entity/MlModelClass'; +import { PipelineClass } from '../../../support/entity/PipelineClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { TopicClass } from '../../../support/entity/TopicClass'; +import { performAdminLogin } from '../../../utils/admin'; +import { + clickOutside, + getApiContext, + getDefaultAdminAPIContext, + redirectToHomePage, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { + activateColumnLayer, + addColumnLineage, + addPipelineBetweenNodes, + clickEdgeBetweenNodes, + clickLineageNode, + connectEdgeBetweenNodesViaAPI, + editLineage, + editLineageClick, + performZoomOut, + verifyNodePresent, + visitLineageTab, +} from '../../../utils/lineage'; +import { test } from '../../fixtures/pages'; + +test.describe('Lineage Interactions', () => { + const table1 = new TableClass(); + const table2 = new TableClass(); + const topic = new TopicClass(); + const dashboard = new DashboardClass(); + const mlmodel = new MlModelClass(); + const pipeline = new PipelineClass(); + + test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); + + await Promise.all([ + table1.create(apiContext), + table2.create(apiContext), + topic.create(apiContext), + dashboard.create(apiContext), + mlmodel.create(apiContext), + pipeline.create(apiContext), + ]); + + await topic.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/owners/0', + value: { + type: 'user', + id: EntityDataClass.user1.responseData.id, + }, + }, + { + op: 'add', + path: '/domains/0', + value: { + type: 'domain', + id: EntityDataClass.domain1.responseData.id, + }, + }, + ], + }); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table1.entityResponseData.id, type: 'table' }, + { id: topic.entityResponseData.id, type: 'topic' } + ); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: topic.entityResponseData.id, type: 'topic' }, + { id: dashboard.entityResponseData.id, type: 'dashboard' } + ); + + await afterAction(); + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await Promise.all([ + table1.delete(apiContext), + table2.delete(apiContext), + topic.delete(apiContext), + dashboard.delete(apiContext), + mlmodel.delete(apiContext), + pipeline.delete(apiContext), + ]); + await afterAction(); + }); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + test.describe('Lineage Layers Toggle', () => { + test('Verify multiple non-platform layers can be active simultaneously', async ({ + page, + }) => { + await table1.visitEntityPage(page); + await visitLineageTab(page); + + await page.getByTestId('lineage-layer-btn').click(); + + const columnBtn = page.getByTestId('lineage-layer-column-btn'); + const observabilityBtn = page.getByTestId( + 'lineage-layer-observability-btn' + ); + + await columnBtn.click(); + await observabilityBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(columnBtn).toHaveClass(/Mui-selected/); + await expect(observabilityBtn).toHaveClass(/Mui-selected/); + }); + }); + + test.describe('Edge Interaction', () => { + test.beforeEach(async ({ page }) => { + await table1.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + }); + + test('Verify edge click opens edge drawer', async ({ page }) => { + await clickEdgeBetweenNodes(page, table1, topic, false); + + await expect(page.locator('.edge-info-drawer')).toBeVisible(); + + await expect( + page.getByText(table1.entityResponseData.displayName ?? '') + ).toBeVisible(); + await expect( + page.getByText(topic.entityResponseData.displayName ?? '') + ).toBeVisible(); + }); + + test('Verify edge delete button in drawer', async ({ page }) => { + const table1Fqn = get(table1, 'entityResponseData.fullyQualifiedName'); + + await editLineage(page); + + await clickEdgeBetweenNodes(page, table1, topic, false); + + const deleteBtn = page + .locator('.edge-info-drawer') + .getByTestId('delete-edge'); + await expect(deleteBtn).toBeVisible(); + + await deleteBtn.click(); + + await expect( + page.locator('[data-testid="confirmation-modal"]') + ).toBeVisible(); + + await page + .locator('[data-testid="confirmation-modal"]') + .getByRole('button', { name: 'Confirm' }) + .click(); + + await waitForAllLoadersToDisappear(page); + + await editLineageClick(page); + + const edge = page.locator(`[data-fromnode="${table1Fqn}"]`); + await expect(edge).not.toBeVisible(); + }); + + test('Verify add pipeline to edge', async ({ page }) => { + const { apiContext } = await getApiContext(page); + await table2.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await editLineage(page); + + const table2Fqn = get( + table2, + 'entityResponseData.fullyQualifiedName', + '' + ); + + await verifyNodePresent(page, table2); + + const tableFqn = get(table1, 'entityResponseData.fullyQualifiedName'); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table2.entityResponseData.id, type: 'table' }, + { id: table1.entityResponseData.id, type: 'table' } + ); + + await page.reload(); + await visitLineageTab(page); + await performZoomOut(page); + + await editLineage(page); + + await clickEdgeBetweenNodes(page, table2, table1, false); + + const addPipelineBtn = page + .locator('.edge-info-drawer') + .getByTestId('add-pipeline'); + await addPipelineBtn.click(); + + await expect(page.locator('[data-testid="entity-modal"]')).toBeVisible(); + + await page + .locator('[data-testid="entity-modal"]') + .getByTestId('searchbar') + .fill(pipeline.entity.name); + + await page.waitForTimeout(500); + + const pipelineFqn = get( + pipeline, + 'entityResponseData.fullyQualifiedName' + ); + await page.getByTestId(`${pipelineFqn}-container`).click(); + + const saveRes = page.waitForResponse('/api/v1/lineage'); + await page.getByRole('button', { name: 'Save' }).click(); + await saveRes; + + await waitForAllLoadersToDisappear(page); + + await editLineageClick(page); + + await expect( + page.getByTestId(`pipeline-label-${table2Fqn}-${tableFqn}`) + ).toBeVisible(); + }); + + test('Verify function data in edge drawer', async ({ page }) => { + test.slow(); + + const { apiContext, afterAction } = await getApiContext(page); + const table1 = new TableClass(); + const table2 = new TableClass(); + + try { + await Promise.all([ + table1.create(apiContext), + table2.create(apiContext), + ]); + const sourceTableFqn = get( + table1, + 'entityResponseData.fullyQualifiedName' + ); + const sourceColName = `${sourceTableFqn}.${get( + table1, + 'entityResponseData.columns[0].name' + )}`; + + const targetTableFqn = get( + table2, + 'entityResponseData.fullyQualifiedName' + ); + const targetColName = `${targetTableFqn}.${get( + table2, + 'entityResponseData.columns[0].name' + )}`; + + await addPipelineBetweenNodes(page, table1, table2); + await activateColumnLayer(page); + await addColumnLineage(page, sourceColName, targetColName); + + const lineageReq = page.waitForResponse('/api/v1/lineage/getLineage?*'); + await page.reload(); + await lineageReq; + + await activateColumnLayer(page); + + await page + .locator( + `[data-testid="column-edge-${sourceColName}-${targetColName}"]` + ) + .dispatchEvent('click'); + + await page.locator('.sql-function-section').waitFor({ + state: 'visible', + }); + + await page + .locator('.sql-function-section') + .getByTestId('edit-button') + .click(); + await page.getByTestId('sql-function-input').fill('count'); + const saveRes = page.waitForResponse('/api/v1/lineage'); + await page.getByTestId('save').click(); + await saveRes; + + await expect(page.getByTestId('sql-function')).toContainText('count'); + + const lineageReq1 = page.waitForResponse( + '/api/v1/lineage/getLineage?*' + ); + await page.reload(); + await lineageReq1; + + await activateColumnLayer(page); + await page + .locator( + `[data-testid="column-edge-${sourceColName}-${targetColName}"]` + ) + .dispatchEvent('click'); + + await page.locator('.edge-info-drawer').isVisible(); + + await expect( + page.locator('[data-testid="sql-function"]') + ).toContainText('count'); + } finally { + await Promise.all([ + table1.delete(apiContext), + table2.delete(apiContext), + ]); + await afterAction(); + } + }); + + test.fixme( + 'Edges are not getting hidden when column is selected and column layer is removed', + async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const table1 = new TableClass(); + const table2 = new TableClass(); + + try { + await Promise.all([ + table1.create(apiContext), + table2.create(apiContext), + ]); + + const table1Fqn = get( + table1, + 'entityResponseData.fullyQualifiedName' + ); + const table2Fqn = get( + table2, + 'entityResponseData.fullyQualifiedName' + ); + + const sourceCol = `${table1Fqn}.${get( + table1, + 'entityResponseData.columns[0].name' + )}`; + const targetCol = `${table2Fqn}.${get( + table2, + 'entityResponseData.columns[0].name' + )}`; + + await test.step('1. Create 2 tables and create column level lineage between them.', async () => { + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: table1.entityResponseData.id, + type: 'table', + }, + { + id: table2.entityResponseData.id, + type: 'table', + }, + [ + { + fromColumns: [sourceCol], + toColumn: targetCol, + }, + ] + ); + + await table1.visitEntityPage(page); + await visitLineageTab(page); + }); + + await test.step('2. Verify edge between 2 tables is visible', async () => { + const tableEdge = page.getByTestId( + `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` + ); + await expect(tableEdge).toBeVisible(); + }); + + await test.step('3. Activate column layer and select a column - table edge should be hidden', async () => { + await activateColumnLayer(page); + + const firstColumn = page.locator( + `[data-testid="column-${sourceCol}"]` + ); + await firstColumn.click(); + + const tableEdge = page.getByTestId( + `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` + ); + await expect(tableEdge).not.toBeVisible(); + }); + + await test.step('4. Remove column layer - table edge should be visible again', async () => { + const columnLayerBtn = page.locator( + '[data-testid="lineage-layer-column-btn"]' + ); + + await page.click('[data-testid="lineage-layer-btn"]'); + await columnLayerBtn.click(); + await clickOutside(page); + + const tableEdge = page.getByTestId( + `edge-${table1.entityResponseData.fullyQualifiedName}-${table2.entityResponseData.fullyQualifiedName}` + ); + await expect(tableEdge).toBeVisible(); + }); + } finally { + await Promise.all([ + table1.delete(apiContext), + table2.delete(apiContext), + ]); + await afterAction(); + } + } + ); + }); + + test.describe('Node Interaction', () => { + test.beforeEach(async ({ page }) => { + await table1.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + }); + + test('Verify node panel opens on click', async ({ page }) => { + const topicFqn = get(topic, 'entityResponseData.fullyQualifiedName', ''); + + await clickLineageNode(page, topicFqn); + + await expect(page.locator('[role="dialog"]')).toBeVisible(); + + await expect( + page.getByText(topic.entityResponseData.displayName ?? '') + ).toBeVisible(); + + await page.getByLabel('Close').first().click(); + + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); + + test('Verify node full path is present as breadcrumb in lineage node', async ({ + page, + }) => { + const { apiContext, afterAction } = await getApiContext(page); + const table = new TableClass(); + + await table.create(apiContext); + + try { + await table.visitEntityPage(page); + await visitLineageTab(page); + + const tableFqn = get( + table, + 'entityResponseData.fullyQualifiedName', + '' + ); + const tableNode = page.locator( + `[data-testid="lineage-node-${tableFqn}"]` + ); + + await expect(tableNode).toBeVisible(); + + const breadcrumbContainer = tableNode.locator( + '[data-testid="lineage-breadcrumbs"]' + ); + await expect(breadcrumbContainer).toBeVisible(); + + const breadcrumbItems = breadcrumbContainer.locator( + '.lineage-breadcrumb-item' + ); + const breadcrumbCount = await breadcrumbItems.count(); + + expect(breadcrumbCount).toBeGreaterThan(0); + + const fqnParts: Array = tableFqn.split('.'); + fqnParts.pop(); + + expect(breadcrumbCount).toBe(fqnParts.length); + + for (let i = 0; i < breadcrumbCount; i++) { + await expect(breadcrumbItems.nth(i)).toHaveText(fqnParts[i]); + } + } finally { + await table.delete(apiContext); + await afterAction(); + } + }); + + test.describe('node selection edge behavior', () => { + /** + * Test setup: + * - table1 -> table2 -> table3 + * -> table4 + * + * This creates a lineage graph where: + * - table1 is upstream of table2 + * - table2 is upstream of table3 and table4 + * - When table3 is selected, the traced path is: table1 -> table2 -> table3 + * - The edge table2 -> table4 should be dimmed (not in traced path) + */ + const table1 = new TableClass(); + const table2 = new TableClass(); + const table3 = new TableClass(); + const table4 = new TableClass(); + + let table1Fqn: string; + let table2Fqn: string; + let table3Fqn: string; + let table4Fqn: string; + + let table1Col: string; + let table2Col: string; + let table3Col: string; + let table4Col: string; + + test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); + + await Promise.all([ + table1.create(apiContext), + table2.create(apiContext), + table3.create(apiContext), + table4.create(apiContext), + ]); + + table1Fqn = get(table1, 'entityResponseData.fullyQualifiedName'); + table2Fqn = get(table2, 'entityResponseData.fullyQualifiedName'); + table3Fqn = get(table3, 'entityResponseData.fullyQualifiedName'); + table4Fqn = get(table4, 'entityResponseData.fullyQualifiedName'); + + table1Col = `${table1Fqn}.${get( + table1, + 'entityResponseData.columns[0].name' + )}`; + table2Col = `${table2Fqn}.${get( + table2, + 'entityResponseData.columns[0].name' + )}`; + table3Col = `${table3Fqn}.${get( + table3, + 'entityResponseData.columns[0].name' + )}`; + table4Col = `${table4Fqn}.${get( + table4, + 'entityResponseData.columns[0].name' + )}`; + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table1.entityResponseData.id, type: 'table' }, + { id: table2.entityResponseData.id, type: 'table' }, + [{ fromColumns: [table1Col], toColumn: table2Col }] + ); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table2.entityResponseData.id, type: 'table' }, + { id: table3.entityResponseData.id, type: 'table' }, + [{ fromColumns: [table2Col], toColumn: table3Col }] + ); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table2.entityResponseData.id, type: 'table' }, + { id: table4.entityResponseData.id, type: 'table' }, + [{ fromColumns: [table2Col], toColumn: table4Col }] + ); + + await afterAction(); + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); + await Promise.all([ + table1.delete(apiContext), + table2.delete(apiContext), + table3.delete(apiContext), + table4.delete(apiContext), + ]); + await afterAction(); + }); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + test.fixme( + 'highlights traced node-to-node edges when a node is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await clickLineageNode(page, table3Fqn); + + await page.keyboard.press('Escape'); + + const tracedEdge1 = page.locator( + `[data-testid="edge-${table1Fqn}-${table2Fqn}"]` + ); + const tracedEdge2 = page.locator( + `[data-testid="edge-${table2Fqn}-${table3Fqn}"]` + ); + + await expect(tracedEdge1).toBeVisible(); + await expect(tracedEdge2).toBeVisible(); + + const tracedEdge1Style = await tracedEdge1.getAttribute('style'); + const tracedEdge2Style = await tracedEdge2.getAttribute('style'); + + expect(tracedEdge1Style).toContain('opacity: 1'); + expect(tracedEdge2Style).toContain('opacity: 1'); + } + ); + + test.fixme( + 'hides column-to-column edges when a node is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await performZoomOut(page); + + const columnEdge = page.locator( + `[data-testid="column-edge-${table1Col}-${table2Col}"]` + ); + await expect(columnEdge).toBeVisible(); + + await clickLineageNode(page, table3Fqn); + + const columnEdgeStyle = await columnEdge.getAttribute('style'); + + expect(columnEdgeStyle).toContain('display: none'); + } + ); + + test.fixme( + 'grays out non-traced node-to-node edges when a node is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await clickLineageNode(page, table3Fqn); + + const nonTracedEdge = page.locator( + `[data-testid="edge-${table2Fqn}-${table4Fqn}"]` + ); + + await expect(nonTracedEdge).toBeVisible(); + + const nonTracedEdgeStyle = await nonTracedEdge.getAttribute('style'); + + expect(nonTracedEdgeStyle).toContain('opacity: 0.3'); + } + ); + + test.fixme( + 'highlights traced column-to-column edges when a column is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await performZoomOut(page); + + const table1Column = page.locator( + `[data-testid="column-${table1Col}"]` + ); + await table1Column.click(); + + const tracedColumnEdge = page.locator( + `[data-testid="column-edge-${table1Col}-${table2Col}"]` + ); + + await expect(tracedColumnEdge).toBeVisible(); + + const tracedEdgeStyle = await tracedColumnEdge.getAttribute('style'); + + expect(tracedEdgeStyle).toContain('opacity: 1'); + expect(tracedEdgeStyle).not.toContain('display: none'); + } + ); + + test.fixme( + 'hides non-traced column-to-column edges when a column is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await performZoomOut(page); + + const table3Column = page.locator( + `[data-testid="column-${table3Col}"]` + ); + await table3Column.click(); + + const nonTracedColumnEdge = page.locator( + `[data-testid="column-edge-${table2Col}-${table4Col}"]` + ); + + const edgeStyle = await nonTracedColumnEdge.getAttribute('style'); + + expect(edgeStyle).toContain('display: none'); + } + ); + + test.fixme( + 'grays out node-to-node edges when a column is selected', + async ({ page }) => { + await table2.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await performZoomOut(page); + + const table3Column = page.locator( + `[data-testid="column-${table3Col}"]` + ); + await table3Column.click(); + + const nodeEdge = page.locator( + `[data-testid="edge-${table2Fqn}-${table3Fqn}"]` + ); + + await expect(nodeEdge).toBeVisible(); + + const nodeEdgeStyle = await nodeEdge.getAttribute('style'); + + expect(nodeEdgeStyle).toContain('opacity: 0.3'); + } + ); + }); + }); + + test.describe('Edit Mode Operations', () => { + test.beforeEach(async ({ page }) => { + await table1.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + }); + + test('Verify edit mode with edge operations', async ({ page }) => { + await editLineage(page); + + await clickEdgeBetweenNodes(page, table1, topic, false); + + await expect(page.locator('.edge-info-drawer')).toBeVisible(); + + const addPipelineBtn = page + .locator('.edge-info-drawer') + .getByTestId('add-pipeline'); + + if ((await addPipelineBtn.count()) > 0) { + await expect(addPipelineBtn).toBeVisible(); + } + + await editLineageClick(page); + }); + }); + + test('Verify cycle lineage should be handled properly', async ({ page }) => { + test.slow(); + + const { apiContext, afterAction } = await getApiContext(page); + const table = new TableClass(); + const topic = new TopicClass(); + const dashboard = new DashboardClass(); + + try { + await Promise.all([ + table.create(apiContext), + topic.create(apiContext), + dashboard.create(apiContext), + ]); + + const tableFqn = get(table, 'entityResponseData.fullyQualifiedName'); + const topicFqn = get(topic, 'entityResponseData.fullyQualifiedName'); + const dashboardFqn = get( + dashboard, + 'entityResponseData.fullyQualifiedName' + ); + + // connect table to topic + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: table.entityResponseData.id, + type: 'table', + }, + { + id: topic.entityResponseData.id, + type: 'topic', + } + ); + + // connect topic to dashboard + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: topic.entityResponseData.id, + type: 'topic', + }, + { + id: dashboard.entityResponseData.id, + type: 'dashboard', + } + ); + + // connect dashboard to table + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: dashboard.entityResponseData.id, + type: 'dashboard', + }, + { + id: table.entityResponseData.id, + type: 'table', + } + ); + + await redirectToHomePage(page); + await table.visitEntityPage(page); + await visitLineageTab(page); + + await performZoomOut(page); + + await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); + await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${dashboardFqn}`) + ).toBeVisible(); + + // Collapse the cycle dashboard lineage downstreamNodeHandler + await page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('downstream-collapse-handle') + .dispatchEvent('click'); + + await expect( + page.getByTestId(`edge-${dashboardFqn}-${tableFqn}`) + ).not.toBeVisible(); + + await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); + await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${dashboardFqn}`) + ).toBeVisible(); + + await expect( + page + .getByTestId(`lineage-node-${tableFqn}`) + .getByTestId('upstream-collapse-handle') + ).not.toBeVisible(); + + await expect( + page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('plus-icon') + ).toBeVisible(); + + // Reclick the plus icon to expand the cycle dashboard lineage downstreamNodeHandler + const downstreamResponse = page.waitForResponse( + `/api/v1/lineage/getLineage/Downstream?fqn=${dashboardFqn}&type=dashboard**` + ); + await page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('plus-icon') + .dispatchEvent('click'); + + await downstreamResponse; + + await expect( + page + .getByTestId(`lineage-node-${tableFqn}`) + .getByTestId('upstream-collapse-handle') + .getByTestId('minus-icon') + ).toBeVisible(); + + // Click the Upstream Node to expand the cycle dashboard lineage + await page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('upstream-collapse-handle') + .dispatchEvent('click'); + + await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${dashboardFqn}`) + ).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${topicFqn}`) + ).not.toBeVisible(); + + await expect( + page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('plus-icon') + ).toBeVisible(); + + // Reclick the plus icon to expand the cycle dashboard lineage upstreamNodeHandler + const upStreamResponse2 = page.waitForResponse( + `/api/v1/lineage/getLineage/Upstream?fqn=${dashboardFqn}&type=dashboard**` + ); + await page + .getByTestId(`lineage-node-${dashboardFqn}`) + .getByTestId('plus-icon') + .dispatchEvent('click'); + await upStreamResponse2; + + await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${dashboardFqn}`) + ).toBeVisible(); + await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); + + // Collapse the Node from the Parent Cycle Node + await page + .getByTestId(`lineage-node-${topicFqn}`) + .getByTestId('downstream-collapse-handle') + .dispatchEvent('click'); + + await expect(page.getByTestId(`lineage-node-${tableFqn}`)).toBeVisible(); + await expect(page.getByTestId(`lineage-node-${topicFqn}`)).toBeVisible(); + await expect( + page.getByTestId(`lineage-node-${dashboardFqn}`) + ).not.toBeVisible(); + } finally { + await Promise.all([ + table.delete(apiContext), + topic.delete(apiContext), + dashboard.delete(apiContext), + ]); + await afterAction(); + } + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageNodePagination.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageNodePagination.spec.ts new file mode 100644 index 000000000000..78be608a6e60 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageNodePagination.spec.ts @@ -0,0 +1,575 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get } from 'lodash'; +import { Column } from '../../../../src/generated/entity/data/table'; +import { TableClass } from '../../../support/entity/TableClass'; +import { + getDefaultAdminAPIContext, + redirectToHomePage, + uuid, +} from '../../../utils/common'; +import { + activateColumnLayer, + connectEdgeBetweenNodesViaAPI, + performZoomOut, + toggleLineageFilters, + visitLineageTab, +} from '../../../utils/lineage'; +import { test } from '../../fixtures/pages'; + +const generateColumnsWithNames = (count: number) => { + const columns = []; + for (let i = 0; i < count; i++) { + columns.push({ + name: `column_${i}_${uuid()}`, + dataType: 'VARCHAR', + dataLength: 100, + dataTypeDisplay: 'varchar', + description: `Test column ${i} for pagination`, + }); + } + + return columns; +}; + +const table1Columns = generateColumnsWithNames(21); +const table2Columns = generateColumnsWithNames(22); +test.describe.serial('Test pagination in column level lineage', () => { + const table1 = new TableClass(); + const table2 = new TableClass(); + + let table1Fqn: string; + let table2Fqn: string; + + test.beforeAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); + + table1.entity.columns = table1Columns as Column[]; + table2.entity.columns = table2Columns as Column[]; + + const [table1Response, table2Response] = await Promise.all([ + table1.create(apiContext), + table2.create(apiContext), + ]); + + table1Fqn = get(table1Response, 'entity.fullyQualifiedName'); + table2Fqn = get(table2Response, 'entity.fullyQualifiedName'); + + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: table1Response.entity.id, + type: 'table', + }, + { + id: table2Response.entity.id, + type: 'table', + } + ); + + const table1ColumnFqn = table1Response.entity.columns?.map( + (col: { fullyQualifiedName: string }) => col.fullyQualifiedName + ) as string[]; + const table2ColumnFqn = table2Response.entity.columns?.map( + (col: { fullyQualifiedName: string }) => col.fullyQualifiedName + ) as string[]; + + await test.step('Add edges between T1-P1 and T2-P1', async () => { + await connectEdgeBetweenNodesViaAPI( + apiContext, + { + id: table1Response.entity.id, + type: 'table', + }, + { + id: table2Response.entity.id, + type: 'table', + }, + [ + { + fromColumns: [table1ColumnFqn[0]], + toColumn: table2ColumnFqn[0], + }, + { + fromColumns: [table1ColumnFqn[1]], + toColumn: table2ColumnFqn[1], + }, + { + fromColumns: [table1ColumnFqn[2]], + toColumn: table2ColumnFqn[2], + }, + { + fromColumns: [table1ColumnFqn[0]], + toColumn: table2ColumnFqn[15], + }, + { + fromColumns: [table1ColumnFqn[1]], + toColumn: table2ColumnFqn[16], + }, + { + fromColumns: [table1ColumnFqn[3]], + toColumn: table2ColumnFqn[17], + }, + { + fromColumns: [table1ColumnFqn[4]], + toColumn: table2ColumnFqn[17], + }, + { + fromColumns: [table1ColumnFqn[15]], + toColumn: table2ColumnFqn[15], + }, + { + fromColumns: [table1ColumnFqn[16]], + toColumn: table2ColumnFqn[16], + }, + { + fromColumns: [table1ColumnFqn[18]], + toColumn: table2ColumnFqn[17], + }, + ] + ); + }); + + await afterAction(); + }); + + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); + await Promise.all([table1.delete(apiContext), table2.delete(apiContext)]); + await afterAction(); + }); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + test.beforeEach(async ({ page }) => { + await table1.visitEntityPage(page); + await visitLineageTab(page); + await activateColumnLayer(page); + await performZoomOut(page); + await toggleLineageFilters(page, table1Fqn); + await toggleLineageFilters(page, table2Fqn); + }); + + test('Verify column visibility across pagination pages', async ({ page }) => { + test.slow(); + + const table1Node = page.locator( + `[data-testid="lineage-node-${table1Fqn}"]` + ); + const table2Node = page.locator( + `[data-testid="lineage-node-${table2Fqn}"]` + ); + + const table1NextBtn = table1Node.getByTestId('column-scroll-down'); + const table2NextBtn = table2Node.getByTestId('column-scroll-down'); + + const allColumnTestIds = { + table1: table1Columns.map((col) => `column-${table1Fqn}.${col.name}`), + table2: table2Columns.map((col) => `column-${table2Fqn}.${col.name}`), + }; + + const columnTestIds: Record = { + 'T1-P1': allColumnTestIds.table1.slice(0, 10), + 'T1-P2': allColumnTestIds.table1.slice(10, 20), + 'T1-P3': allColumnTestIds.table1.slice(11, 21), + 'T2-P1': allColumnTestIds.table2.slice(0, 10), + 'T2-P2': allColumnTestIds.table2.slice(10, 20), + 'T2-P3': allColumnTestIds.table2.slice(12, 22), + }; + + await test.step('Verify T1-P1: C1-C10 visible, C10-C21 hidden', async () => { + for (const testId of columnTestIds['T1-P1']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table1) { + if (!columnTestIds['T1-P1'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + + await test.step('Verify T2-P1: C1-C10 visible, C10-C22 hidden', async () => { + for (const testId of columnTestIds['T2-P1']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table2) { + if (!columnTestIds['T2-P1'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + + await test.step('Navigate to T1-P2 and verify visibility', async () => { + if (await table1NextBtn.isVisible()) { + await table1NextBtn.click(); + } + + for (const testId of columnTestIds['T1-P2']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table1) { + if (!columnTestIds['T1-P2'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + + await test.step('Navigate to T2-P2 and verify visibility', async () => { + if (await table2NextBtn.isVisible()) { + await table2NextBtn.click(); + } + + for (const testId of columnTestIds['T2-P2']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table2) { + if (!columnTestIds['T2-P2'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + + await test.step('Navigate to T1-P3 and verify visibility', async () => { + if (await table1NextBtn.isVisible()) { + await table1NextBtn.click(); + } + + for (const testId of columnTestIds['T1-P3']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table1) { + if (!columnTestIds['T1-P3'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + + await test.step('Navigate to T2-P3 and verify visibility', async () => { + if (await table2NextBtn.isVisible()) { + await table2NextBtn.click(); + } + + for (const testId of columnTestIds['T2-P3']) { + await expect(page.locator(`[data-testid="${testId}"]`)).toBeVisible(); + } + + for (const testId of allColumnTestIds.table2) { + if (!columnTestIds['T2-P3'].includes(testId)) { + await expect( + page.locator(`[data-testid="${testId}"]`) + ).not.toBeVisible(); + } + } + }); + }); + + test('Verify edges when no column is hovered or selected', async ({ + page, + }) => { + const table1Node = page.getByTestId(`lineage-node-${table1Fqn}`); + const table2Node = page.getByTestId(`lineage-node-${table2Fqn}`); + + const table1NextBtn = table1Node.getByTestId('column-scroll-down'); + const table2NextBtn = table2Node.getByTestId('column-scroll-down'); + + await test.step('Verify T1-P1 and T2-P1: Only (T1,C1)-(T2,C1), (T1,C2)-(T2,C2), (T1,C3)-(T2,C3) edges visible', async () => { + const visibleEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[1].name}`, + `column-edge-${table1Fqn}.${table1Columns[2].name}-${table2Fqn}.${table2Columns[2].name}`, + ]; + + const hiddenEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[3].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[4].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[15].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[16].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[18].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + for (const edgeId of visibleEdges) { + await expect(page.getByTestId(edgeId)).toBeVisible(); + } + + for (const edgeId of hiddenEdges) { + await expect(page.getByTestId(edgeId)).not.toBeVisible(); + } + }); + + await test.step('Navigate to T2-P2 and verify (T1,C1)-(T2,C6), (T1,C2)-(T2,C7), (T1,C4)-(T2,C8), (T1,C5)-(T2,C8) edges visible', async () => { + if (await table2NextBtn.isVisible()) { + await table2NextBtn.click(); + } + + const visibleEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[3].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[4].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + const hiddenEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[1].name}`, + `column-edge-${table1Fqn}.${table1Columns[2].name}-${table2Fqn}.${table2Columns[2].name}`, + `column-edge-${table1Fqn}.${table1Columns[15].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[16].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[18].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + for (const edgeId of visibleEdges) { + await expect(page.getByTestId(edgeId)).toBeVisible(); + } + + for (const edgeId of hiddenEdges) { + await expect(page.getByTestId(edgeId)).not.toBeVisible(); + } + }); + + await test.step('Navigate to T1-P2 and verify (T1,C6)-(T2,C6), (T1,C7)-(T2,C7), (T1,C9)-(T2,C8) edges visible', async () => { + if (await table1NextBtn.isVisible()) { + await table1NextBtn.click(); + } + + const visibleEdges = [ + `column-edge-${table1Fqn}.${table1Columns[15].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[16].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[18].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + const hiddenEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[1].name}`, + `column-edge-${table1Fqn}.${table1Columns[2].name}-${table2Fqn}.${table2Columns[2].name}`, + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[3].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[4].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + for (const edgeId of visibleEdges) { + await expect(page.getByTestId(edgeId)).toBeVisible(); + } + + for (const edgeId of hiddenEdges) { + await expect(page.getByTestId(edgeId)).not.toBeVisible(); + } + }); + }); + + test('Verify columns and edges when a column is hovered', async ({ + page, + }) => { + await test.step('Hover on (T1,C1) and verify highlighted columns and edges', async () => { + const c1Column = page.getByTestId( + `column-${table1Fqn}.${table1Columns[0].name}` + ); + + await c1Column.hover(); + + // Verify (T1,C1), (T2,C1) and (T2,C6) are highlighted and visible + const t1c1 = page.getByTestId( + `column-${table1Fqn}.${table1Columns[0].name}` + ); + const t2c1 = page.getByTestId( + `column-${table2Fqn}.${table2Columns[0].name}` + ); + const t2c6 = page.getByTestId( + `column-${table2Fqn}.${table2Columns[15].name}` + ); + + await expect(t1c1).toBeVisible(); + await expect(t1c1).toHaveClass(/custom-node-header-column-tracing/); + + await expect(t2c1).toBeVisible(); + await expect(t2c1).toHaveClass(/custom-node-header-column-tracing/); + + await expect(t2c6).toBeVisible(); + await expect(t2c6).toHaveClass(/custom-node-header-column-tracing/); + + // Verify edges are visible + const edge_t1c1_to_t2c1 = `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`; + const edge_t1c1_to_t2c6 = `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`; + + await expect(page.getByTestId(edge_t1c1_to_t2c1)).toBeVisible(); + await expect(page.getByTestId(edge_t1c1_to_t2c6)).toBeVisible(); + }); + }); + + test('Verify columns and edges when a column is clicked', async ({ + page, + }) => { + test.slow(); + + await test.step('Navigate to T1-P2 and T2-P2, click (T2,C6) and verify highlighted columns and edges', async () => { + const table1Node = page.getByTestId(`lineage-node-${table1Fqn}`); + const table2Node = page.getByTestId(`lineage-node-${table2Fqn}`); + + // Navigate to T1-P2 + const table1NextBtn = table1Node.getByTestId('column-scroll-down'); + if (await table1NextBtn.isVisible()) { + await table1NextBtn.click(); + } + + // Navigate to T2-P2 + const table2NextBtn = table2Node.getByTestId('column-scroll-down'); + if (await table2NextBtn.isVisible()) { + await table2NextBtn.click(); + } + + // Click on (T2,C6) + const t2c6Column = page.getByTestId( + `column-${table2Fqn}.${table2Columns[15].name}` + ); + await t2c6Column.click(); + + // Verify (T1,C1), (T1,C6) and (T2,C6) are highlighted and visible + const t1c1 = page.getByTestId( + `column-${table1Fqn}.${table1Columns[0].name}` + ); + const t1c6 = page.getByTestId( + `column-${table1Fqn}.${table1Columns[15].name}` + ); + const t2c6 = page.getByTestId( + `column-${table2Fqn}.${table2Columns[15].name}` + ); + + await expect(t1c1).toBeVisible(); + await expect(t1c1).toHaveClass(/custom-node-header-column-tracing/); + + await expect(t1c6).toBeVisible(); + await expect(t1c6).toHaveClass(/custom-node-header-column-tracing/); + + await expect(t2c6).toBeVisible(); + await expect(t2c6).toHaveClass(/custom-node-header-column-tracing/); + + // Verify edges are visible + const edge_t1c1_to_t2c6 = `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`; + const edge_t1c6_to_t2c6 = `column-edge-${table1Fqn}.${table1Columns[15].name}-${table2Fqn}.${table2Columns[15].name}`; + + await expect(page.getByTestId(edge_t1c1_to_t2c6)).toBeVisible(); + await expect(page.getByTestId(edge_t1c6_to_t2c6)).toBeVisible(); + }); + }); + + test('Verify edges for column level lineage between 2 nodes when filter is toggled', async ({ + page, + }) => { + await test.step('1. Verify edges visible and hidden for page1 of both the tables', async () => { + const visibleEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[1].name}`, + `column-edge-${table1Fqn}.${table1Columns[2].name}-${table2Fqn}.${table2Columns[2].name}`, + ]; + + const hiddenEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[3].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + for (const edgeId of visibleEdges) { + await expect(page.getByTestId(edgeId)).toBeVisible(); + } + + for (const edgeId of hiddenEdges) { + await expect(page.getByTestId(edgeId)).not.toBeVisible(); + } + }); + + await test.step('3. Enable the filter for table1 by clicking filter button', async () => { + await toggleLineageFilters(page, table1Fqn); + }); + + await test.step('4. Verify that only columns with lineage are visible in table1', async () => { + const columnsWithLineage = [0, 1, 2, 3, 4, 15, 16, 18]; + const columnsWithoutLineage = [7, 9, 10]; + + for (const index of columnsWithLineage) { + await expect( + page.getByTestId(`column-${table1Fqn}.${table1Columns[index].name}`) + ).toBeVisible(); + } + + for (const index of columnsWithoutLineage) { + await expect( + page.getByTestId(`column-${table1Fqn}.${table1Columns[index].name}`) + ).not.toBeVisible(); + } + }); + + await test.step('5. Enable the filter for table2 by clicking filter button', async () => { + await toggleLineageFilters(page, table2Fqn); + }); + + await test.step('6. Verify that only columns with lineage are visible in table2', async () => { + const columnsWithLineage = [0, 1, 2, 15, 16, 17]; + const columnsWithoutLineage = [3, 4, 8, 9, 10, 11]; + + for (const index of columnsWithLineage) { + await expect( + page.getByTestId(`column-${table2Fqn}.${table2Columns[index].name}`) + ).toBeVisible(); + } + + for (const index of columnsWithoutLineage) { + await expect( + page.getByTestId(`column-${table2Fqn}.${table2Columns[index].name}`) + ).not.toBeVisible(); + } + }); + + await test.step('7. Verify new edges are now visible.', async () => { + const allVisibleEdges = [ + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[0].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[1].name}`, + `column-edge-${table1Fqn}.${table1Columns[2].name}-${table2Fqn}.${table2Columns[2].name}`, + `column-edge-${table1Fqn}.${table1Columns[0].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[1].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[3].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[4].name}-${table2Fqn}.${table2Columns[17].name}`, + `column-edge-${table1Fqn}.${table1Columns[15].name}-${table2Fqn}.${table2Columns[15].name}`, + `column-edge-${table1Fqn}.${table1Columns[16].name}-${table2Fqn}.${table2Columns[16].name}`, + `column-edge-${table1Fqn}.${table1Columns[18].name}-${table2Fqn}.${table2Columns[17].name}`, + ]; + + for (const edgeId of allVisibleEdges) { + await expect(page.getByTestId(edgeId)).toBeVisible(); + } + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageRightPanel.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageRightPanel.spec.ts new file mode 100644 index 000000000000..8c0258877069 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/LineageRightPanel.spec.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get } from 'lodash'; +import { SidebarItem } from '../../../constant/sidebar'; +import { ApiEndpointClass } from '../../../support/entity/ApiEndpointClass'; +import { ChartClass } from '../../../support/entity/ChartClass'; +import { ContainerClass } from '../../../support/entity/ContainerClass'; +import { DashboardClass } from '../../../support/entity/DashboardClass'; +import { MetricClass } from '../../../support/entity/MetricClass'; +import { MlModelClass } from '../../../support/entity/MlModelClass'; +import { PipelineClass } from '../../../support/entity/PipelineClass'; +import { SearchIndexClass } from '../../../support/entity/SearchIndexClass'; +import { ApiServiceClass } from '../../../support/entity/service/ApiServiceClass'; +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 { StorageServiceClass } from '../../../support/entity/service/StorageServiceClass'; +import { TableClass } from '../../../support/entity/TableClass'; +import { TopicClass } from '../../../support/entity/TopicClass'; +import { + getDefaultAdminAPIContext, + redirectToHomePage, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { clickLineageNode, visitLineageTab } from '../../../utils/lineage'; +import { sidebarClick } from '../../../utils/sidebar'; +import { test } from '../../fixtures/pages'; + +test.describe('Verify custom properties tab visibility logic for supported entity types lineage', () => { + const supportedEntities = [ + { entity: new TableClass(), type: 'table' }, + { entity: new TopicClass(), type: 'topic' }, + { entity: new DashboardClass(), type: 'dashboard' }, + { entity: new PipelineClass(), type: 'pipeline' }, + { entity: new MlModelClass(), type: 'mlmodel' }, + { entity: new ContainerClass(), type: 'container' }, + { entity: new SearchIndexClass(), type: 'searchIndex' }, + { entity: new ApiEndpointClass(), type: 'apiEndpoint' }, + { entity: new MetricClass(), type: 'metric' }, + { entity: new ChartClass(), type: 'chart' }, + ]; + + test.beforeAll(async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + + const createEntityArray: Promise[] = []; + + supportedEntities.forEach(({ entity }) => { + createEntityArray.push(entity.create(apiContext)); + }); + + await Promise.all(createEntityArray); + }); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + for (const { entity, type } of supportedEntities) { + test(`Verify custom properties tab IS visible for supported type: ${type}`, async ({ + page, + }) => { + const searchTerm = + entity.entityResponseData?.['fullyQualifiedName'] || entity.entity.name; + + await entity.visitEntityPage(page, searchTerm); + await visitLineageTab(page); + + const nodeFqn = entity.entityResponseData?.['fullyQualifiedName'] ?? ''; + + await clickLineageNode(page, nodeFqn); + + const lineagePanel = page.getByTestId('lineage-entity-panel'); + await expect(lineagePanel).toBeVisible(); + await waitForAllLoadersToDisappear(page); + + const customPropertiesTab = lineagePanel.getByTestId( + 'custom-properties-tab' + ); + await expect(customPropertiesTab).toBeVisible(); + + const closeButton = lineagePanel.getByTestId('drawer-close-icon'); + if (await closeButton.isVisible()) { + await closeButton.click(); + await expect(lineagePanel).not.toBeVisible(); + } + }); + } +}); + +test.describe('Verify custom properties tab is NOT visible for unsupported entity types in platform lineage', () => { + const unsupportedServices = [ + { service: new DatabaseServiceClass(), type: 'databaseService' }, + { service: new MessagingServiceClass(), type: 'messagingService' }, + { service: new DashboardServiceClass(), type: 'dashboardService' }, + { service: new PipelineServiceClass(), type: 'pipelineService' }, + { service: new MlmodelServiceClass(), type: 'mlmodelService' }, + { service: new StorageServiceClass(), type: 'storageService' }, + { service: new ApiServiceClass(), type: 'apiService' }, + ]; + + test.beforeAll(async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + + const createEntityArray: Promise[] = []; + for (const { service } of unsupportedServices) { + createEntityArray.push(service.create(apiContext)); + } + await Promise.all(createEntityArray); + }); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + }); + + for (const { service, type } of unsupportedServices) { + test(`Verify custom properties tab is NOT visible for ${type} in platform lineage`, async ({ + page, + }) => { + const serviceFqn = get(service, 'entityResponseData.fullyQualifiedName'); + + await sidebarClick(page, SidebarItem.LINEAGE); + + const searchEntitySelect = page.getByTestId('search-entity-select'); + await expect(searchEntitySelect).toBeVisible(); + await searchEntitySelect.click(); + + const searchInput = page + .getByTestId('search-entity-select') + .locator('.ant-select-selection-search-input'); + + const searchResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/search/query') + ); + await searchInput.fill(service.entity.name); + + const searchResponseResult = await searchResponse; + expect(searchResponseResult.status()).toBe(200); + + const nodeSuggestion = page.getByTestId(`node-suggestion-${serviceFqn}`); + //small timeout to wait for the node suggestion to be visible in dropdown + await expect(nodeSuggestion).toBeVisible(); + + const lineageResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/lineage/getLineage') + ); + + await nodeSuggestion.click(); + + const lineageResponseResult = await lineageResponse; + expect(lineageResponseResult.status()).toBe(200); + + await expect( + page.getByTestId(`lineage-node-${serviceFqn}`) + ).toBeVisible(); + + await clickLineageNode(page, serviceFqn); + + const lineagePanel = page.getByTestId('lineage-entity-panel'); + await expect(lineagePanel).toBeVisible(); + await waitForAllLoadersToDisappear(page); + + const customPropertiesTab = lineagePanel.getByTestId( + 'custom-properties-tab' + ); + const customPropertiesTabByRole = lineagePanel.getByRole('menuitem', { + name: /custom propert/i, + }); + + await expect(customPropertiesTab).not.toBeVisible(); + await expect(customPropertiesTabByRole).not.toBeVisible(); + + const closeButton = lineagePanel.getByTestId('drawer-close-icon'); + if (await closeButton.isVisible()) { + await closeButton.click(); + await expect(lineagePanel).not.toBeVisible(); + } + }); + } +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts new file mode 100644 index 000000000000..3807b95f66b7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Lineage/PlatformLineage.spec.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { get } from 'lodash'; +import { SidebarItem } from '../../../constant/sidebar'; +import { TableClass } from '../../../support/entity/TableClass'; +import { + getDefaultAdminAPIContext, + redirectToHomePage, + uuid, +} from '../../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../../utils/entity'; +import { + clickLineageNode, + performZoomOut, + visitLineageTab, +} from '../../../utils/lineage'; +import { sidebarClick } from '../../../utils/sidebar'; +import { test } from '../../fixtures/pages'; + +// Create a table with '/' in the name to test encoding functionality +const tableNameWithSlash = `pw-table-with/slash-${uuid()}`; +const table = new TableClass(tableNameWithSlash); + +test.beforeAll(async ({ browser }) => { + const { apiContext } = await getDefaultAdminAPIContext(browser); + await table.create(apiContext); +}); + +test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); +}); + +test('Verify table search with special characters as handled', async ({ + page, +}) => { + const db = table.databaseResponseData.name; + + await sidebarClick(page, SidebarItem.LINEAGE); + + await page.getByTestId('search-entity-select').waitFor(); + await page.click('[data-testid="search-entity-select"]'); + + await page.fill( + '[data-testid="search-entity-select"] .ant-select-selection-search-input', + table.entity.name + ); + + await page.waitForRequest( + (req) => + req.url().includes('/api/v1/search/query') && + req.url().includes('deleted=false') + ); + + await page.locator('.ant-select-dropdown').waitFor(); + + const nodeFqn = get(table, 'entityResponseData.fullyQualifiedName'); + const dbFqn = get( + table, + 'entityResponseData.database.fullyQualifiedName', + '' + ); + await page + .locator(`[data-testid="node-suggestion-${nodeFqn}"]`) + .dispatchEvent('click'); + + await page.waitForResponse('/api/v1/lineage/getLineage?*'); + + await expect(page.locator('[data-testid="lineage-details"]')).toBeVisible(); + + await expect( + page.locator(`[data-testid="lineage-node-${nodeFqn}"]`) + ).toBeVisible(); + + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.LINEAGE); + await page.getByTestId('search-entity-select').waitFor(); + await page.click('[data-testid="search-entity-select"]'); + + await page.fill( + '[data-testid="search-entity-select"] .ant-select-selection-search-input', + db + ); + await page.getByTestId(`node-suggestion-${dbFqn}`).waitFor(); + await page.getByTestId(`node-suggestion-${dbFqn}`).dispatchEvent('click'); + await page.waitForResponse('/api/v1/lineage/getLineage?*'); + + await expect(page.getByTestId('lineage-details')).toBeVisible(); + + await clickLineageNode(page, dbFqn); + + await expect( + page.locator('.lineage-entity-panel').getByTestId('entity-header-title') + ).toBeVisible(); +}); + +test('Verify service platform view', async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await page.getByTestId('lineage-layer-btn').click(); + + const serviceBtn = page.getByTestId('lineage-layer-service-btn'); + await expect(serviceBtn).toBeVisible(); + + await serviceBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(serviceBtn).toHaveClass(/Mui-selected/); +}); + +test('Verify domain platform view', async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await page.getByTestId('lineage-layer-btn').click(); + + const domainBtn = page.getByTestId('lineage-layer-domain-btn'); + await expect(domainBtn).toBeVisible(); + + await domainBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(domainBtn).toHaveClass(/Mui-selected/); + + await page.keyboard.press('Escape'); + + await waitForAllLoadersToDisappear(page); +}); + +test('Verify platform view switching', async ({ page }) => { + await table.visitEntityPage(page); + await visitLineageTab(page); + await performZoomOut(page); + + await page.getByTestId('lineage-layer-btn').click(); + + const serviceBtn = page.getByTestId('lineage-layer-service-btn'); + const domainBtn = page.getByTestId('lineage-layer-domain-btn'); + + await serviceBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(serviceBtn).toHaveClass(/Mui-selected/); + await expect(domainBtn).not.toHaveClass(/Mui-selected/); + + await domainBtn.click(); + await page.keyboard.press('Escape'); + + await page.getByTestId('lineage-layer-btn').click(); + await expect(domainBtn).toHaveClass(/Mui-selected/); + await expect(serviceBtn).not.toHaveClass(/Mui-selected/); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MetricClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MetricClass.ts index fc45e11fe00c..af5306a3ae7d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MetricClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/MetricClass.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { APIRequestContext, Page } from '@playwright/test'; +import { Operation } from 'fast-json-patch'; import { uuid } from '../../utils/common'; import { visitEntityPage } from '../../utils/entity'; import { EntityTypeEndpoint, ResponseDataType } from './Entity.interface'; @@ -77,6 +78,30 @@ export class MetricClass extends EntityClass { this.entityResponseData = data.entity; } + async patch({ + apiContext, + patchData, + }: { + apiContext: APIRequestContext; + patchData: Operation[]; + }) { + const response = await apiContext.patch( + `/api/v1/metrics/name/${this.entityResponseData?.['fullyQualifiedName']}`, + { + data: patchData, + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + + this.entityResponseData = await response.json(); + + return { + entity: this.entityResponseData, + }; + } + async visitEntityPage(page: Page) { const metricName = this.entityResponseData.name ?? this.entity.name; const searchTerm = diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index 377a8b92391a..983b20312c6a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -116,6 +116,17 @@ export const createNewPage = async (browser: Browser) => { return { page, apiContext, afterAction }; }; +export const getDefaultAdminAPIContext = async (browser: Browser) => { + const context = await browser.newContext({ + storageState: 'playwright/.auth/admin.json', + }); + + const page = await context.newPage(); + await redirectToHomePage(page); + + return getApiContext(page); +}; + /** * Retrieves the API context for the given page. * @param page The Playwright page object. @@ -142,6 +153,11 @@ export const getEntityTypeSearchIndexMapping = (entityType: string) => { SearchIndex: 'searchIndex', ApiEndpoint: 'apiEndpoint', Metric: 'metric', + ['Store Procedure']: 'storedProcedure', + Directory: 'directory', + File: 'file', + Spreadsheet: 'spreadsheet', + Worksheet: 'worksheet', [DASHBOARD_DATA_MODEL]: 'dashboardDataModel', }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts index 5068e46d8782..2a535920d1cb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/lineage.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { APIRequestContext, expect, Page } from '@playwright/test'; -import { get } from 'lodash'; +import { get, isEmpty } from 'lodash'; import { SidebarItem } from '../constant/sidebar'; import { ApiEndpointClass } from '../support/entity/ApiEndpointClass'; import { ContainerClass } from '../support/entity/ContainerClass'; @@ -215,8 +215,8 @@ export const dragConnection = async ( const selector = isColumnLineage ? '.lineage-column-node-handle' : '.lineage-node-handle'; - const sourceNode = page.locator(`[data-testid="${sourceId}"]`); - const targetNode = page.locator(`[data-testid="${targetId}"]`); + const sourceNode = page.getByTestId(sourceId); + const targetNode = page.getByTestId(targetId); const sourceHandle = sourceNode.locator( `${selector}.react-flow__handle-right` ); @@ -224,18 +224,13 @@ export const dragConnection = async ( `${selector}.react-flow__handle-left` ); - const lineageRes = page.waitForResponse('/api/v1/lineage'); await sourceHandle.dispatchEvent('click'); await targetHandle.dispatchEvent('click'); - - await lineageRes; }; export const rearrangeNodes = async (page: Page) => { await page.getByTestId('fit-screen').click(); await page.getByRole('menuitem', { name: 'Rearrange Nodes' }).click(); - // eslint-disable-next-line playwright/no-wait-for-timeout -- node rearrange animation settling time - await page.waitForTimeout(500); }; export const connectEdgeBetweenNodes = async ( @@ -272,11 +267,18 @@ export const connectEdgeBetweenNodes = async ( `lineage-node-${fromNodeFqn}`, `lineage-node-${toNodeFqn}` ); + + await expect( + page.getByTestId(`edge-${fromNodeFqn}-${toNodeFqn}`) + ).toBeVisible(); }; export const verifyNodePresent = async (page: Page, node: EntityClass) => { const nodeFqn = get(node, 'entityResponseData.fullyQualifiedName'); - const name = get(node, 'entityResponseData.displayName') ?? ''; + const name = + get(node, 'entityResponseData.displayName') ?? + get(node, 'entityResponseData.name') ?? + ''; const lineageNode = page.locator(`[data-testid="lineage-node-${nodeFqn}"]`); await lineageNode.waitFor({ state: 'attached' }); @@ -532,14 +534,12 @@ export const addColumnLineage = async ( toColumnNode: string, exitEditMode = true ) => { - const lineageRes = page.waitForResponse('/api/v1/lineage'); await dragConnection( page, `column-${fromColumnNode}`, `column-${toColumnNode}`, true ); - await lineageRes; await page.getByTestId(`column-${toColumnNode}`).click(); @@ -585,6 +585,41 @@ export const visitLineageTab = async (page: Page) => { await pane.click({ position: { x: 0, y: 0 } }); }; +export const getEntityColumns = ( + entity: EntityClass, + entityName: string +): Array<{ name: string; fullyQualifiedName?: string }> => { + if (entityName === 'table' || entityName === 'dashboardDataModel') { + return get(entity, 'entityResponseData.columns', []); + } else if (entityName === 'topic') { + return get(entity, 'entityResponseData.messageSchema.schemaFields', []); + } else if (entityName === 'dashboard') { + return get(entity, 'entityResponseData.charts', []); + } else if (entityName === 'container') { + return get(entity, 'entityResponseData.dataModel.columns', []); + } else if (entityName === 'apiEndpoint') { + const requestSchema = get( + entity, + 'entityResponseData.requestSchema.schemaFields', + [] + ); + const responseSchema = get( + entity, + 'entityResponseData.responseSchema.schemaFields', + [] + ); + const schema = responseSchema.length > 0 ? responseSchema : requestSchema; + + return isEmpty(schema) ? [] : schema; + } else if (entityName === 'mlModel') { + return get(entity, 'entityResponseData.mlFeatures', []); + } else if (entityName === 'searchIndex') { + return get(entity, 'entityResponseData.fields', []); + } + + return []; +}; + export const addPipelineBetweenNodes = async ( page: Page, sourceEntity: EntityClass, @@ -695,17 +730,7 @@ export const getLineageCSVData = async (page: Page) => { export const verifyExportLineageCSV = async ( page: Page, currentEntity: EntityClass, - entities: readonly [ - TableClass, - DashboardClass, - TopicClass, - MlModelClass, - ContainerClass, - SearchIndexClass, - ApiEndpointClass, - MetricClass, - DashboardDataModelClass - ], + entities: EntityClass[], pipeline: PipelineClass ) => { const parsedData = await getLineageCSVData(page);