From ebe42dcb923db3003d64ea3f2401db94d1bda1af Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Wed, 19 Aug 2026 16:54:04 +0530 Subject: [PATCH 01/60] fix(playwright): fixed AgentLogStream flakiness --- .../e2e/Features/AgentLogStreamHandover.spec.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts index 606a718279f7..b4b91b090661 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts @@ -59,15 +59,22 @@ const STREAM_CURSOR = '20'; /** * Enough lines to overflow the log body at any viewport the suite runs at, so - * the scroll-driven follow behaviour has something to scroll. + * the scroll-driven follow behaviour has something to scroll. Kept well below + * the virtualiser's overscan (100 rows each side of the viewport): past that, + * a wrap toggle re-measures enough mounted rows in one go that the resulting + * burst of offset corrections can itself look like a manual scrollbar drag — + * the very thing `dragLogViewerUpWithoutGesture` deliberately provokes later + * in this test. */ -const SCROLLABLE_LOG_LINE_COUNT = 400; +const SCROLLABLE_LOG_LINE_COUNT = 60; /** - * Wide enough that every line wraps in the viewer, which is what makes the wrap - * toggle re-measure rows and move the scroll position. + * Wide enough that every line still wraps to more than one visual row in the + * viewer — which is what makes the wrap toggle re-measure rows and move the + * scroll position — without making each row's height correction so large that + * settling the relayout takes an unrealistic number of frames. */ -const WRAPPABLE_LOG_LINE_LENGTH = 400; +const WRAPPABLE_LOG_LINE_LENGTH = 120; /** * What each reconnect appends after the first. Small on purpose: a run that adds From 526fa79828900ce24819ea1c4fe0a3aba648c412 Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Wed, 19 Aug 2026 18:23:00 +0530 Subject: [PATCH 02/60] Fixes #31768: keep live log auto-follow on after a hand-made resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-follow could resume two ways — the toolbar toggle or the user scrolling back to the tail — and the two left different internal state. `pauseFollow()` zeroes `viewerScrollAtRef`, and only `resumeFollowingTail()` re-armed it, so a pause followed by a scroll back to the tail resumed following with no viewer-scroll grace window. The pause decision then keyed on the direction-agnostic `userMovedTheView`, so the next report in which the library's own follow-scroll landed short of the tail — which a virtualised list emits when a second append arrives mid-scroll — was read as the user leaving the tail, and following paused itself. Extract `beginFollowingFromTail()` and use it from both resume paths so they cannot drift apart again, and gate the pause on a new direction-aware `movedTowardsTail` fact: a view that travelled towards the tail and stopped short of it is the viewer landing approximately, not the user leaving. `movedTowardsTail` is positive evidence of direction rather than the absence of `movedAwayFromTail`, so a first report — which has no previous offset to compare against — is neither, preserving the existing "log opens already scrolled up" behaviour. Also mark the covering Playwright test slow: it contains four 60s polls against a 60s budget and ran 55-57s locally, so it timed out on retry rather than reporting the real failure. Co-Authored-By: Claude Opus 5 (1M context) --- .../Features/AgentLogStreamHandover.spec.ts | 5 ++ .../LogViewerModal.interface.ts | 1 + .../LogViewerModal/LogViewerModal.test.tsx | 84 +++++++++++++++++++ .../LogViewerModal/LogViewerModal.utils.tsx | 8 ++ .../common/LogViewerModal/useLogAutoFollow.ts | 35 ++++++-- 5 files changed, 125 insertions(+), 8 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts index 606a718279f7..3e66b0f04d0b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts @@ -327,6 +327,11 @@ test.describe('Agent log stream handover to the paginated endpoint', () => { test('Scrolling a live log pauses auto-follow and the toolbar toggle resumes it', async ({ page, }) => { + // Every step below waits for the stream to append again, and a reconnect can + // take seconds on a loaded runner. The default budget is one such wait, not + // the four this test needs, so it timed out on retry rather than failing. + test.slow(); + await openAgentLogs(page, { terminal: false, lineCount: SCROLLABLE_LOG_LINE_COUNT, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.interface.ts index a5e83abef603..2a63d0273d44 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.interface.ts @@ -74,6 +74,7 @@ export interface ScrollFacts { fillsViewport: boolean; userMovedTheView: boolean; movedAwayFromTail: boolean; + movedTowardsTail: boolean; } export interface UseLogAutoFollowParams { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.test.tsx index fbe8893b92dc..fe4a5892d9b2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.test.tsx @@ -821,6 +821,90 @@ describe('LogViewerModal — auto-follow', () => { ); }); + it('keeps following after a hand-made resume when an append lands short of the tail', () => { + render(); + + act(() => mockLazyLog.onScroll?.(atTail)); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: -120 }); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: 120 }); + act(() => mockLazyLog.onScroll?.(scrolledBackToTail)); + + expect(screen.getByTestId('log-viewer-follow')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + // The library scrolls itself on every append and lands approximately: the + // offset moved a long way *towards* the tail and still stopped short of it. + // Nobody scrolls down in order to leave the tail, so this cannot be the user. + act(() => + mockLazyLog.onScroll?.({ + scrollTop: 800, + scrollHeight: 1500, + clientHeight: 400, + }) + ); + + expect(screen.getByTestId('log-viewer-follow')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('grants a hand-made resume the same catch-up grace as the toggle', () => { + render(); + + act(() => mockLazyLog.onScroll?.(atTail)); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: -120 }); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: 120 }); + act(() => mockLazyLog.onScroll?.(scrolledBackToTail)); + mockLazyLog.scrollToIndex.mockClear(); + + // One gestureless report pulling away from the tail is what a relayout does + // on its way to re-pinning it, so it is caught up rather than obeyed — the + // same answer the toolbar toggle's resume gets. + act(() => + mockLazyLog.onScroll?.({ + scrollTop: 500, + scrollHeight: 1100, + clientHeight: 400, + }) + ); + + expect(screen.getByTestId('log-viewer-follow')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + expect(mockLazyLog.scrollToIndex).toHaveBeenCalledWith(2); + }); + + it('still lets a gestureless drag take back a hand-made resume', () => { + render(); + + act(() => mockLazyLog.onScroll?.(atTail)); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: -120 }); + fireEvent.wheel(screen.getByTestId('log-viewer-body'), { deltaY: 120 }); + act(() => mockLazyLog.onScroll?.(scrolledBackToTail)); + + // A native scrollbar drag reports no wheel and no key. Pulling away from the + // tail twice in a row is something the catch-up never does, so the grace the + // resume granted has to be outrun rather than being indefinite. + for (const scrollTop of [500, 300]) { + act(() => + mockLazyLog.onScroll?.({ + scrollTop, + scrollHeight: 1100, + clientHeight: 400, + }) + ); + } + + expect(screen.getByTestId('log-viewer-follow')).toHaveAttribute( + 'aria-pressed', + 'false' + ); + }); + it('keeps following when its own catch-up reports an offset short of the tail', () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.utils.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.utils.tsx index d6716c5934ba..94ec5b8bc859 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.utils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/LogViewerModal.utils.tsx @@ -99,6 +99,12 @@ export const SCROLL_MOVED_THRESHOLD_PX = 4; * view also leaving the tail — a relayout emits long runs of offset corrections * (12 in a row on one measured wrap toggle) that track the tail as the content * shrinks, and those are the viewer keeping up, not the user leaving. + * + * `movedTowardsTail` is its mirror: the offset travelled in the direction of the + * tail and still stopped short of it, which is what the library's own scroll on + * an append looks like when the content grew again on the way. Positive evidence + * of the direction, so a first report — which has no previous offset to compare + * against — is neither. */ export const readScrollFacts = ( { scrollTop, scrollHeight, clientHeight }: LogViewerScrollValues, @@ -117,5 +123,7 @@ export const readScrollFacts = ( isFirstReport || Math.abs(movedBy) > SCROLL_MOVED_THRESHOLD_PX, movedAwayFromTail: !isBottom && !isFirstReport && movedBy > SCROLL_MOVED_THRESHOLD_PX, + movedTowardsTail: + !isBottom && !isFirstReport && movedBy < -SCROLL_MOVED_THRESHOLD_PX, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/useLogAutoFollow.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/useLogAutoFollow.ts index 9a6584a081e3..d6a43fa4a671 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/useLogAutoFollow.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/LogViewerModal/useLogAutoFollow.ts @@ -103,14 +103,24 @@ export const useLogAutoFollow = ({ setFollow(isLive || follow); }, [open, isLive, follow, setFollow]); - const resumeFollowingTail = useCallback(() => { - forwardGestureAtRef.current = Date.now(); + /** + * Starts following from a view that is already at the tail. Shared by every + * resume path: whoever gets back to the tail — the toolbar toggle or the user + * scrolling there — has to grant the catch-up the same grace, or the first + * append that reports an offset short of the tail reads as the user leaving. + */ + const beginFollowingFromTail = useCallback(() => { upwardMovesRef.current = 0; caughtUpRef.current = false; viewerScrollAtRef.current = Date.now(); setFollow(true); + }, [setFollow]); + + const resumeFollowingTail = useCallback(() => { + forwardGestureAtRef.current = Date.now(); + beginFollowingFromTail(); scrollToEnd(); - }, [scrollToEnd, setFollow]); + }, [beginFollowingFromTail, scrollToEnd]); const toggleFollow = useCallback(() => { if (followTailRef.current) { @@ -163,8 +173,17 @@ export const useLogAutoFollow = ({ * it resumes following. */ const applyUserScrollIntent = useCallback( - (isBottom: boolean) => { - if (!isBottom) { + (facts: ScrollFacts) => { + if (!facts.isBottom) { + // A followed log whose offset moved *towards* the tail and stopped short + // of it is the viewer landing approximately — the library scrolls itself + // on every append and a virtualised list re-estimates its height as rows + // are measured. Nobody scrolls down in order to leave the tail, so only a + // move pulling away from it hands control over. + if (followTailRef.current && facts.movedTowardsTail) { + return; + } + // Leaving the tail withdraws any earlier request to be at it. forwardGestureAtRef.current = 0; setFollow(false); @@ -179,10 +198,10 @@ export const useLogAutoFollow = ({ Date.now() - forwardGestureAtRef.current < FORWARD_GESTURE_GRACE_MS; if (askedToFollow) { - setFollow(true); + beginFollowingFromTail(); } }, - [setFollow] + [beginFollowingFromTail, setFollow] ); const trackScroll = useCallback( @@ -210,7 +229,7 @@ export const useLogAutoFollow = ({ facts.userMovedTheView && !viewerOwnsThisScroll ) { - applyUserScrollIntent(facts.isBottom); + applyUserScrollIntent(facts); } return facts; From c1feeb5919a87f4835b84f758f38feaf35741427 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 10:06:46 -0700 Subject: [PATCH 03/60] test(playwright): stabilize auth and glossary asset checks --- .../playwright/e2e/Flow/IngestionBot.spec.ts | 5 ++++- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts index 5e9b0c8519e4..7ddd74760077 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts @@ -42,7 +42,10 @@ const test = base.extend<{ const { apiContext, afterAction } = await performAdminLogin(browser); const page = await browser.newPage(); - await page.goto('/'); + // Establish the application origin without booting the SPA. Navigating to + // `/` starts the unauthenticated redirect to `/signin`; that redirect can + // destroy the execution context while setToken writes to IndexedDB. + await page.goto('/manifest.json'); const bot = await apiContext .get('/api/v1/bots/name/ingestion-bot') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index c3bb1771cb47..24c86b3a1994 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1194,7 +1194,23 @@ test.describe('Glossary tests', () => { ); await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, glossary1.data.displayName); - await goToAssetsTab(page, glossaryTerm1.data.displayName, 1); + await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); + const assetsSearchResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + const pageSize = Number(url.searchParams.get('size')); + + return ( + url.pathname.endsWith('/api/v1/search/query') && + url.searchParams.get('index') === 'all' && + pageSize > 0 && + url.searchParams + .get('query_filter') + ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true + ); + }); + await page.getByTestId('assets').click(); + await assetsSearchResponse; + await page.locator('.ant-tabs-tab-active:has-text("Assets")').waitFor(); const entityFqn = get(table, 'entityResponseData.fullyQualifiedName'); await expect( From 492b5715ecb76b8e2216285604d2249f85138359 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 10:12:33 -0700 Subject: [PATCH 04/60] test(playwright): isolate shared AUT state --- .../ColumnBulkOperationsTagsGlossary.spec.ts | 5 +++ .../e2e/Flow/ExploreDiscovery.spec.ts | 27 ++++++-------- .../Pages/Lineage/LineageInteraction.spec.ts | 36 ++++++++++++------- .../e2e/Pages/Lineage/PlatformLineage.spec.ts | 10 ++++++ .../ui/playwright/utils/entityPanel.ts | 9 +++-- 5 files changed, 57 insertions(+), 30 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts index 937ca5c81541..0f5de5fae783 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ColumnBulkOperationsTagsGlossary.spec.ts @@ -136,6 +136,11 @@ function getColumnRowCheckbox(page: Page, rowId: string) { } test.describe('Column Bulk Operations - Tags & Glossary Select in Drawer', () => { + // This suite deliberately shares one table and glossary term from beforeAll. + // Opt out of fully-parallel execution so Playwright does not run beforeAll + // once per test and race two creates for the same glossary name. + test.describe.configure({ mode: 'default' }); + const CLASSIFICATION_TAG_FQN = 'PII.Sensitive'; const table = new TableClass(); const glossaryTerm = new GlossaryTerm(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts index a0fd93cf84cd..824f233f5652 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts @@ -344,10 +344,9 @@ test.describe('Explore Assets Discovery', () => { // Close the Owners dropdown before opening the next — immediate-apply keeps // it open after selection, and a stale open menu has its own search-input await page.keyboard.press('Escape'); - await page - .getByTestId('drop-down-menu') - .getByTestId(ownerSearchText) - .waitFor({ state: 'detached' }); + await expect( + page.locator('[data-testid="drop-down-menu"]:visible') + ).toHaveCount(0); // The domain should be visible in the domains filter when the deleted switch is on const domainSearchText = domain.responseData.displayName.toLowerCase(); @@ -384,22 +383,18 @@ test.describe('Explore Assets Discovery', () => { // Close the Domains dropdown before opening the Data Assets one await page.keyboard.press('Escape'); - - await page - .getByTestId('drop-down-menu') - .getByTestId(domainSearchText) - .waitFor({ state: 'detached' }); + await expect( + page.locator('[data-testid="drop-down-menu"]:visible') + ).toHaveCount(0); // Only the table option should be visible for the data assets filter when the deleted switch is on // with the owner and domain filter applied await page.click('[data-testid="search-dropdown-Data Assets"]'); - await page - .getByTestId('drop-down-menu') - .getByTestId('loader') - .waitFor({ state: 'detached' }); + const dataAssetMenu = page.locator( + '[data-testid="drop-down-menu"]:visible' + ); + await dataAssetMenu.getByTestId('loader').waitFor({ state: 'detached' }); - await expect( - page.getByTestId('drop-down-menu').getByTestId('table') - ).toBeAttached(); + await expect(dataAssetMenu.getByTestId('table')).toBeAttached(); }); }); 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 index 658d4a6e7e95..e05cba0bdd0f 100644 --- 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 @@ -166,27 +166,39 @@ test.describe('Lineage Interactions', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { test('Verify edge delete button in drawer', async ({ page }) => { const table1Fqn = get(table1, 'entityResponseData.fullyQualifiedName'); const topicFqn = get(topic, 'entityResponseData.fullyQualifiedName'); + const { apiContext, afterAction } = await getApiContext(page); - await editLineage(page); + try { + await editLineage(page); - await clickEdgeBetweenNodes(page, table1, topic, false); + await clickEdgeBetweenNodes(page, table1, topic, false); - const deleteBtn = page.getByTestId('add-pipeline'); - await expect(deleteBtn).toBeVisible(); + const deleteBtn = page.getByTestId('add-pipeline'); + await expect(deleteBtn).toBeVisible(); - await deleteBtn.click(); + await deleteBtn.click(); - await page.getByTestId('remove-edge-button').click(); + await page.getByTestId('remove-edge-button').click(); - await page.getByRole('button', { name: /confirm/i }).waitFor(); - await page.getByRole('button', { name: /confirm/i }).click(); + await page.getByRole('button', { name: /confirm/i }).waitFor(); + await page.getByRole('button', { name: /confirm/i }).click(); - await waitForAllLoadersToDisappear(page); + await waitForAllLoadersToDisappear(page); - await editLineageClick(page); + await editLineageClick(page); - const edgeDiv = page.getByTestId(`edge-${table1Fqn}-${topicFqn}`); - await expect(edgeDiv).not.toBeVisible(); + const edgeDiv = page.getByTestId(`edge-${table1Fqn}-${topicFqn}`); + await expect(edgeDiv).not.toBeVisible(); + } finally { + // The outer suite shares this edge. Restore it even when an assertion + // fails so later node-interaction tests do not inherit a one-node graph. + await connectEdgeBetweenNodesViaAPI( + apiContext, + { id: table1.entityResponseData.id, type: 'table' }, + { id: topic.entityResponseData.id, type: 'topic' } + ); + await afterAction(); + } }); test('Verify function data in edge drawer', async ({ page }) => { 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 index a32c7515ccc0..fa91fa6ebcb8 100644 --- 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 @@ -34,6 +34,10 @@ import { test } from '../../fixtures/pages'; const tableNameWithSlash = `pw-table-with/slash-${uuid()}`; const table = new TableClass(tableNameWithSlash); +// All tests use the same table created by the file-level beforeAll. Running +// them fully parallel repeats that hook for the same generated table name. +test.describe.configure({ mode: 'default' }); + test.beforeAll(async ({ browser }) => { const { apiContext, afterAction } = await getDefaultAdminAPIContext(browser); await table.create(apiContext); @@ -55,6 +59,12 @@ test.beforeAll(async ({ browser }) => { await afterAction(); }); +test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext(browser); + await table.delete(apiContext); + await afterAction(); +}); + test.beforeEach(async ({ page }) => { await table.visitEntityPage(page); await visitLineageTab(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts index de9d59aac02e..3b1e6564b5e0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts @@ -186,7 +186,12 @@ export const openEntitySummaryPanel = async ({ // Since the directly clicking on the card can sometimes click on title element which is link, // we need to click on description container to open the summary panel. - await entityResultCard.getByTestId('description-text').click(); + // Rich descriptions can contain links, images, and attachment controls. + // A coordinate click on the container may hit one of those children and + // open its popover instead of the entity summary panel. + await entityResultCard + .getByTestId('description-text') + .dispatchEvent('click'); return; } @@ -197,7 +202,7 @@ export const openEntitySummaryPanel = async ({ await knowledgeCenterItem.click(); } - await entityResultCard.getByTestId('description-text').click(); + await entityResultCard.getByTestId('description-text').dispatchEvent('click'); }; // ... (lines 48-468 unchanged) export async function navigateToExploreAndSelectTable( From 45fb5f8aa96055a4e778668facba4bca4c92ef47 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 10:19:11 -0700 Subject: [PATCH 05/60] test(playwright): apply AUT handover timeout budget --- .../e2e/Features/AgentLogStreamHandover.spec.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts index cab4087c181b..cb716ddc823a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AgentLogStreamHandover.spec.ts @@ -334,10 +334,19 @@ test.describe('Agent log stream handover to the paginated endpoint', () => { test('Scrolling a live log pauses auto-follow and the toolbar toggle resumes it', async ({ page, }) => { - // Every step below waits for the stream to append again, and a reconnect can - // take seconds on a loaded runner. The default budget is one such wait, not - // the four this test needs, so it timed out on retry rather than failing. - test.slow(); + // This scenario's declared waits cannot fit the 60s project default: the + // steps below carry expect.poll budgets of 60s (wrap relayout) + 30s + // (gestureless drag) + 60s (append while paused) + 60s (append while + // followed), plus ~15s attribute expects between them. Those budgets are + // deliberate — the mock closes every connection, so appends arrive on the + // client's reconnect-backoff cadence, which stretches under shard load. + // With the default ceiling the test killed itself mid-poll while the + // stream was legitimately still backing off (merge-queue runs + // 32238830063, 32244090565, 32248621065, 32249698790: "Test timeout of + // 60000ms exceeded" with the line count about to grow). Budget = sum of + // declared polls + interaction slack; a genuine assertion failure still + // fails fast via the per-expect 15s timeouts. + test.setTimeout(240_000); await openAgentLogs(page, { terminal: false, From cfededec136a2b230da60136853f0ac2ae3d1c62 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 12:18:17 -0700 Subject: [PATCH 06/60] test(playwright): format IPv6 webhook receiver URLs --- .../main/resources/ui/playwright/utils/webhook.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts index 74808bed57e9..e97a339b120b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts @@ -39,8 +39,19 @@ export const getWebhookReceiverHost = () => { return webhookHost; }; +const getWebhookReceiverUrlHost = () => { + const host = getWebhookReceiverHost(); + + // Docker can advertise the Playwright container's IPv6 address first. An + // IPv6 literal must be bracketed when it is used as the host portion of a + // URL; without brackets the alert API rejects the destination as malformed. + return host.includes(':') && !(host.startsWith('[') && host.endsWith(']')) + ? `[${host}]` + : host; +}; + export const startWebhookReceiver = async () => { - const webhookReceiverHost = getWebhookReceiverHost(); + const webhookReceiverHost = getWebhookReceiverUrlHost(); const server = createServer((request, response) => { let body = ''; From c4d198da95e3020d4000d134d8c73a8242b2ece1 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 12:18:17 -0700 Subject: [PATCH 07/60] test(playwright): format IPv6 webhook receiver URLs --- .../main/resources/ui/playwright/utils/webhook.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts index 74808bed57e9..e97a339b120b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/webhook.ts @@ -39,8 +39,19 @@ export const getWebhookReceiverHost = () => { return webhookHost; }; +const getWebhookReceiverUrlHost = () => { + const host = getWebhookReceiverHost(); + + // Docker can advertise the Playwright container's IPv6 address first. An + // IPv6 literal must be bracketed when it is used as the host portion of a + // URL; without brackets the alert API rejects the destination as malformed. + return host.includes(':') && !(host.startsWith('[') && host.endsWith(']')) + ? `[${host}]` + : host; +}; + export const startWebhookReceiver = async () => { - const webhookReceiverHost = getWebhookReceiverHost(); + const webhookReceiverHost = getWebhookReceiverUrlHost(); const server = createServer((request, response) => { let body = ''; From 45b0a6f455e93e9c3ff6f73698bded1f1d02311c Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 12:30:31 -0700 Subject: [PATCH 08/60] test(ui): seed optional following widget --- .../ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts | 11 ++++++++++- .../ui/playwright/utils/customizeLandingPage.ts | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts index a89ae28378c9..d912f811e485 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts @@ -29,6 +29,7 @@ import { } from '../../utils/common'; import { addAndVerifyWidget, + isLandingPageWidgetConfigured, removeAndVerifyWidget, verifyWidgetEntityNavigation, verifyWidgetFooterViewMore, @@ -564,7 +565,15 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { // Wait for the widgets data to appear await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); - await waitForLandingPageWidget(page, widgetKey); + // A product-specific landing-page fallback is used when a newly-created + // persona has no docStore layout yet. That fallback does not always include + // Following, so make the widget an explicit test prerequisite instead of + // relying on whichever default layout the AUT ships. + if (await isLandingPageWidgetConfigured(page, widgetKey)) { + await waitForLandingPageWidget(page, widgetKey); + } else { + await addAndVerifyWidget(page, widgetKey, persona.responseData.name); + } await test.step('Test widget header and navigation', async () => { await waitForAllLoadersToDisappear(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 0db748a1c445..229cad1ae0bc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -53,6 +53,11 @@ const getLandingPageWidgetSlot = (page: Page, widgetKey: string) => ) .first(); +export const isLandingPageWidgetConfigured = async ( + page: Page, + widgetKey: string +) => (await getLandingPageWidgetSlot(page, widgetKey).count()) > 0; + const revealLandingPageWidget = async (page: Page, widgetKey: string) => { const slot = getLandingPageWidgetSlot(page, widgetKey); From 1394f621e1f9fe5152f110c54b08770455398bbe Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 12:30:31 -0700 Subject: [PATCH 09/60] test(ui): seed optional following widget --- .../ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts | 11 ++++++++++- .../ui/playwright/utils/customizeLandingPage.ts | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts index a89ae28378c9..d912f811e485 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts @@ -29,6 +29,7 @@ import { } from '../../utils/common'; import { addAndVerifyWidget, + isLandingPageWidgetConfigured, removeAndVerifyWidget, verifyWidgetEntityNavigation, verifyWidgetFooterViewMore, @@ -564,7 +565,15 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { // Wait for the widgets data to appear await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); - await waitForLandingPageWidget(page, widgetKey); + // A product-specific landing-page fallback is used when a newly-created + // persona has no docStore layout yet. That fallback does not always include + // Following, so make the widget an explicit test prerequisite instead of + // relying on whichever default layout the AUT ships. + if (await isLandingPageWidgetConfigured(page, widgetKey)) { + await waitForLandingPageWidget(page, widgetKey); + } else { + await addAndVerifyWidget(page, widgetKey, persona.responseData.name); + } await test.step('Test widget header and navigation', async () => { await waitForAllLoadersToDisappear(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 0db748a1c445..229cad1ae0bc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -53,6 +53,11 @@ const getLandingPageWidgetSlot = (page: Page, widgetKey: string) => ) .first(); +export const isLandingPageWidgetConfigured = async ( + page: Page, + widgetKey: string +) => (await getLandingPageWidgetSlot(page, widgetKey).count()) > 0; + const revealLandingPageWidget = async (page: Page, widgetKey: string) => { const slot = getLandingPageWidgetSlot(page, widgetKey); From 91c0009e42ef79d5c65dc9fb2e77c24b3fa780ab Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 17:12:41 -0700 Subject: [PATCH 10/60] test(playwright): stabilize async UI state --- .../Features/ContextCenterDocument.spec.ts | 7 ++ .../Features/ContextCenterPermission.spec.ts | 64 +++++++------------ .../ui/playwright/e2e/Pages/Glossary.spec.ts | 5 +- .../e2e/Pages/SearchSettings.spec.ts | 7 +- .../ui/playwright/support/team/TeamClass.ts | 8 +-- .../playwright/utils/customizeLandingPage.ts | 9 ++- .../resources/ui/playwright/utils/entity.ts | 37 ++++++++--- 7 files changed, 77 insertions(+), 60 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts index c5089497248d..d5592d4d36f9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts @@ -569,7 +569,14 @@ test.describe('Context Center - Documents Page', () => { view.locator(`[data-testid="document-row-${outsideDoc.id}"]`) ).toBeVisible(); + const browseResPromise = page.waitForResponse( + (res) => + res.url().includes('/api/v1/contextCenter/drive/files') && + !res.url().includes('search') + ); await searchInput.clear(); + await browseResPromise; + await expect(searchInput).toHaveValue(''); await waitForAllLoadersToDisappear(page); await selectFolderInSidebar(page, folderName); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts index 8088e31c10d6..feb01e12b913 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts @@ -41,6 +41,7 @@ import { searchAndGetDocumentRow, searchAndGetMemoryRow, uploadDisposableDocument, + verifyArticleSearch, waitForDocumentInArchive, waitForDocumentProcessingComplete, } from '../../utils/ContextCenterUtil'; @@ -159,6 +160,23 @@ let viewOnlyOwnMemoryId = ''; let viewOnlyOwnMemoryTitle = ''; let earlyAlphabetMemoryId = ''; +const openPermissionArticle = async (page: Page) => { + const articleFqn = articleEntity.responseData.fullyQualifiedName ?? ''; + const articleResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/contextCenter/pages/name/') && + response.request().method() === 'GET' && + response.ok() + ); + + await page.goto( + `/context-center/articles/${encodeURIComponent(articleFqn)}`, + { waitUntil: 'domcontentloaded' } + ); + await articleResponse; + await waitForAllLoadersToDisappear(page); +}; + test.describe('Context Center Permissions', () => { test.slow(true); @@ -873,6 +891,7 @@ test.describe('Context Center Permissions', () => { await test.step('quick link card shows edit button but not delete button', async () => { await navigateToArticles(editAllPage); + await verifyArticleSearch(editAllPage, quickLinkDisplayName); const qlCard = await scrollListingToCard( editAllPage, @@ -2091,20 +2110,7 @@ test.describe('Context Center Permissions', () => { viewOnlyPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = viewOnlyPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await viewOnlyPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(viewOnlyPage); + await openPermissionArticle(viewOnlyPage); await expect( viewOnlyPage.getByTestId('entity-header-display-name') @@ -2146,20 +2152,7 @@ test.describe('Context Center Permissions', () => { dataConsumerPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = dataConsumerPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await dataConsumerPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(dataConsumerPage); + await openPermissionArticle(dataConsumerPage); await expect( dataConsumerPage.getByTestId('entity-header-display-name') @@ -2201,20 +2194,7 @@ test.describe('Context Center Permissions', () => { dataStewardPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = dataStewardPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await dataStewardPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(dataStewardPage); + await openPermissionArticle(dataStewardPage); const titleInput = dataStewardPage.getByTestId( 'entity-header-display-name' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index 24c86b3a1994..86d89dcec80b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1194,7 +1194,9 @@ test.describe('Glossary tests', () => { ); await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, glossary1.data.displayName); - await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); + // AssetsTabs can mount and fetch as soon as the active term changes, + // before the user clicks the Assets tab. Arm the response waiter first + // so we observe both eager and click-triggered fetches. const assetsSearchResponse = page.waitForResponse((response) => { const url = new URL(response.url()); const pageSize = Number(url.searchParams.get('size')); @@ -1208,6 +1210,7 @@ test.describe('Glossary tests', () => { ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true ); }); + await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); await assetsSearchResponse; await page.locator('.ant-tabs-tab-active:has-text("Assets")').waitFor(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index f90ace6b9e76..9904ec8abe4f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -475,8 +475,11 @@ test.describe('Search Settings', () => { ); await ngramPanel.click(); - // Change n-gram weight to 5 and save. - await setSliderValue(page, 'field-weight-slider', 5); + // Always choose a value that differs from the current setting. A prior + // interrupted run may already have persisted 5, in which case the Save + // button correctly remains disabled and a hard-coded value deadlocks. + const changedNgramBoost = initialNgramBoost === 5 ? 6 : 5; + await setSliderValue(page, 'field-weight-slider', changedNgramBoost); const saveResponse = page.waitForResponse( (r) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts index 49616520ed36..f9e8f8e76cab 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts @@ -73,12 +73,12 @@ export class TeamClass { return; } - const fetchOrganizationResponse = page.waitForResponse( - `/api/v1/teams/name/Organization?fields=users%2CuserCount%2CdefaultRoles%2CdefaultPersona%2Cpolicies%2CchildrenCount%2Cdomains&include=all` - ); + // The organization record is cached after the first team test, so a later + // visit is not guaranteed to issue this GET. The rendered listing is the + // contract we need; waiting for an optional cache miss causes a 60s stall. await redirectToHomePage(page); await settingClick(page, GlobalSettingOptions.TEAMS); - await fetchOrganizationResponse; + await waitForAllLoadersToDisappear(page).catch(() => undefined); await searchTeam(page, expectedDisplayName); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 229cad1ae0bc..4b0259726136 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -321,7 +321,11 @@ export const setUserDefaultPersona = async ( page.locator('[data-testid="default-persona-select-list"]') ).toBeVisible(); - const setDefaultPersona = page.waitForResponse('/api/v1/users/*'); + const setDefaultPersona = page.waitForResponse( + (response) => + response.url().includes('/api/v1/users/') && + response.request().method() === 'PATCH' + ); // Click on the persona option by text within the dropdown await page.click(`.ant-select-dropdown:visible [title="${personaName}"]`); @@ -330,7 +334,8 @@ export const setUserDefaultPersona = async ( .locator('[data-testid="user-profile-default-persona-edit-save"]') .click(); - await setDefaultPersona; + const setDefaultPersonaResponse = await setDefaultPersona; + expect(setDefaultPersonaResponse.ok()).toBeTruthy(); await expect( page.locator('[data-testid="persona-details-card"]') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 8dc4b4b8396c..3efa5ced56e0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -655,17 +655,36 @@ export const assignCertification = async ( .waitFor({ state: 'visible' }); await waitForAllLoadersToDisappear(page); - await readElementInListWithScroll( - page, - page.getByTestId( - `radio-btn-${certification.responseData.fullyQualifiedName}` - ), - page.locator('[data-testid="certification-cards"] .ant-radio-group') + const certificationRadio = page.getByTestId( + `radio-btn-${certification.responseData.fullyQualifiedName}` + ); + const certificationCards = page.locator( + '[data-testid="certification-cards"] .ant-radio-group' ); - await page - .getByTestId(`radio-btn-${certification.responseData.fullyQualifiedName}`) - .click(); + await expect(async () => { + // The tag GET can finish just as an entity refresh remounts the controlled + // popover. In that race the shell stays visible but its certifications are + // reset to [], leaving no Radio.Group for the scroll helper to hover. Close + // and reopen to issue a fresh fetch, then retry the complete find operation. + if (!(await certificationCards.isVisible())) { + const closeButton = page.getByTestId('close-certification'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + await page.getByTestId('edit-certification').click(); + await expect(certificationCards).toBeVisible({ timeout: 5_000 }); + } + + await readElementInListWithScroll( + page, + certificationRadio, + certificationCards + ); + await expect(certificationRadio).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 25_000, intervals: [250, 500, 1000] }); + + await certificationRadio.click(); const patchRequest = page.waitForResponse( (response) => response.url().includes(`/api/v1/${endpoint}`) && From f4ece72b48f32f7959f9f50e5b932a01956b4f21 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 17:12:41 -0700 Subject: [PATCH 11/60] test(playwright): stabilize async UI state --- .../Features/ContextCenterDocument.spec.ts | 7 ++ .../Features/ContextCenterPermission.spec.ts | 64 +++++++------------ .../ui/playwright/e2e/Pages/Glossary.spec.ts | 5 +- .../e2e/Pages/SearchSettings.spec.ts | 7 +- .../ui/playwright/support/team/TeamClass.ts | 8 +-- .../playwright/utils/customizeLandingPage.ts | 9 ++- .../resources/ui/playwright/utils/entity.ts | 37 ++++++++--- 7 files changed, 77 insertions(+), 60 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts index c5089497248d..d5592d4d36f9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterDocument.spec.ts @@ -569,7 +569,14 @@ test.describe('Context Center - Documents Page', () => { view.locator(`[data-testid="document-row-${outsideDoc.id}"]`) ).toBeVisible(); + const browseResPromise = page.waitForResponse( + (res) => + res.url().includes('/api/v1/contextCenter/drive/files') && + !res.url().includes('search') + ); await searchInput.clear(); + await browseResPromise; + await expect(searchInput).toHaveValue(''); await waitForAllLoadersToDisappear(page); await selectFolderInSidebar(page, folderName); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts index 8088e31c10d6..feb01e12b913 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterPermission.spec.ts @@ -41,6 +41,7 @@ import { searchAndGetDocumentRow, searchAndGetMemoryRow, uploadDisposableDocument, + verifyArticleSearch, waitForDocumentInArchive, waitForDocumentProcessingComplete, } from '../../utils/ContextCenterUtil'; @@ -159,6 +160,23 @@ let viewOnlyOwnMemoryId = ''; let viewOnlyOwnMemoryTitle = ''; let earlyAlphabetMemoryId = ''; +const openPermissionArticle = async (page: Page) => { + const articleFqn = articleEntity.responseData.fullyQualifiedName ?? ''; + const articleResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/contextCenter/pages/name/') && + response.request().method() === 'GET' && + response.ok() + ); + + await page.goto( + `/context-center/articles/${encodeURIComponent(articleFqn)}`, + { waitUntil: 'domcontentloaded' } + ); + await articleResponse; + await waitForAllLoadersToDisappear(page); +}; + test.describe('Context Center Permissions', () => { test.slow(true); @@ -873,6 +891,7 @@ test.describe('Context Center Permissions', () => { await test.step('quick link card shows edit button but not delete button', async () => { await navigateToArticles(editAllPage); + await verifyArticleSearch(editAllPage, quickLinkDisplayName); const qlCard = await scrollListingToCard( editAllPage, @@ -2091,20 +2110,7 @@ test.describe('Context Center Permissions', () => { viewOnlyPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = viewOnlyPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await viewOnlyPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(viewOnlyPage); + await openPermissionArticle(viewOnlyPage); await expect( viewOnlyPage.getByTestId('entity-header-display-name') @@ -2146,20 +2152,7 @@ test.describe('Context Center Permissions', () => { dataConsumerPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = dataConsumerPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await dataConsumerPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(dataConsumerPage); + await openPermissionArticle(dataConsumerPage); await expect( dataConsumerPage.getByTestId('entity-header-display-name') @@ -2201,20 +2194,7 @@ test.describe('Context Center Permissions', () => { dataStewardPage.getByTestId('create-knowledge-page-btn') ).not.toBeVisible(); - const articleResponse = dataStewardPage.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages/') && - response.request().method() === 'GET' - ); - - await dataStewardPage - .getByTestId('knowledge-pages-hierarchy') - .getByRole('link') - .first() - .click(); - - await articleResponse; - await waitForAllLoadersToDisappear(dataStewardPage); + await openPermissionArticle(dataStewardPage); const titleInput = dataStewardPage.getByTestId( 'entity-header-display-name' diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index 24c86b3a1994..86d89dcec80b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1194,7 +1194,9 @@ test.describe('Glossary tests', () => { ); await sidebarClick(page, SidebarItem.GLOSSARY); await selectActiveGlossary(page, glossary1.data.displayName); - await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); + // AssetsTabs can mount and fetch as soon as the active term changes, + // before the user clicks the Assets tab. Arm the response waiter first + // so we observe both eager and click-triggered fetches. const assetsSearchResponse = page.waitForResponse((response) => { const url = new URL(response.url()); const pageSize = Number(url.searchParams.get('size')); @@ -1208,6 +1210,7 @@ test.describe('Glossary tests', () => { ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true ); }); + await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); await assetsSearchResponse; await page.locator('.ant-tabs-tab-active:has-text("Assets")').waitFor(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index f90ace6b9e76..9904ec8abe4f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -475,8 +475,11 @@ test.describe('Search Settings', () => { ); await ngramPanel.click(); - // Change n-gram weight to 5 and save. - await setSliderValue(page, 'field-weight-slider', 5); + // Always choose a value that differs from the current setting. A prior + // interrupted run may already have persisted 5, in which case the Save + // button correctly remains disabled and a hard-coded value deadlocks. + const changedNgramBoost = initialNgramBoost === 5 ? 6 : 5; + await setSliderValue(page, 'field-weight-slider', changedNgramBoost); const saveResponse = page.waitForResponse( (r) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts index 49616520ed36..f9e8f8e76cab 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/team/TeamClass.ts @@ -73,12 +73,12 @@ export class TeamClass { return; } - const fetchOrganizationResponse = page.waitForResponse( - `/api/v1/teams/name/Organization?fields=users%2CuserCount%2CdefaultRoles%2CdefaultPersona%2Cpolicies%2CchildrenCount%2Cdomains&include=all` - ); + // The organization record is cached after the first team test, so a later + // visit is not guaranteed to issue this GET. The rendered listing is the + // contract we need; waiting for an optional cache miss causes a 60s stall. await redirectToHomePage(page); await settingClick(page, GlobalSettingOptions.TEAMS); - await fetchOrganizationResponse; + await waitForAllLoadersToDisappear(page).catch(() => undefined); await searchTeam(page, expectedDisplayName); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 229cad1ae0bc..4b0259726136 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -321,7 +321,11 @@ export const setUserDefaultPersona = async ( page.locator('[data-testid="default-persona-select-list"]') ).toBeVisible(); - const setDefaultPersona = page.waitForResponse('/api/v1/users/*'); + const setDefaultPersona = page.waitForResponse( + (response) => + response.url().includes('/api/v1/users/') && + response.request().method() === 'PATCH' + ); // Click on the persona option by text within the dropdown await page.click(`.ant-select-dropdown:visible [title="${personaName}"]`); @@ -330,7 +334,8 @@ export const setUserDefaultPersona = async ( .locator('[data-testid="user-profile-default-persona-edit-save"]') .click(); - await setDefaultPersona; + const setDefaultPersonaResponse = await setDefaultPersona; + expect(setDefaultPersonaResponse.ok()).toBeTruthy(); await expect( page.locator('[data-testid="persona-details-card"]') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 8dc4b4b8396c..3efa5ced56e0 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -655,17 +655,36 @@ export const assignCertification = async ( .waitFor({ state: 'visible' }); await waitForAllLoadersToDisappear(page); - await readElementInListWithScroll( - page, - page.getByTestId( - `radio-btn-${certification.responseData.fullyQualifiedName}` - ), - page.locator('[data-testid="certification-cards"] .ant-radio-group') + const certificationRadio = page.getByTestId( + `radio-btn-${certification.responseData.fullyQualifiedName}` + ); + const certificationCards = page.locator( + '[data-testid="certification-cards"] .ant-radio-group' ); - await page - .getByTestId(`radio-btn-${certification.responseData.fullyQualifiedName}`) - .click(); + await expect(async () => { + // The tag GET can finish just as an entity refresh remounts the controlled + // popover. In that race the shell stays visible but its certifications are + // reset to [], leaving no Radio.Group for the scroll helper to hover. Close + // and reopen to issue a fresh fetch, then retry the complete find operation. + if (!(await certificationCards.isVisible())) { + const closeButton = page.getByTestId('close-certification'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } + await page.getByTestId('edit-certification').click(); + await expect(certificationCards).toBeVisible({ timeout: 5_000 }); + } + + await readElementInListWithScroll( + page, + certificationRadio, + certificationCards + ); + await expect(certificationRadio).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 25_000, intervals: [250, 500, 1000] }); + + await certificationRadio.click(); const patchRequest = page.waitForResponse( (response) => response.url().includes(`/api/v1/${endpoint}`) && From 6d57d0ffc463b871f2a5bc3fcd762e0662abea21 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 17:48:48 -0700 Subject: [PATCH 12/60] test(playwright): observe eager glossary asset fetch --- .../resources/ui/playwright/e2e/Pages/Glossary.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index 86d89dcec80b..fbae694f6cc7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1193,10 +1193,9 @@ test.describe('Glossary tests', () => { EntityTypeEndpoint.Table ); await sidebarClick(page, SidebarItem.GLOSSARY); - await selectActiveGlossary(page, glossary1.data.displayName); - // AssetsTabs can mount and fetch as soon as the active term changes, - // before the user clicks the Assets tab. Arm the response waiter first - // so we observe both eager and click-triggered fetches. + // Selecting a glossary with one term auto-selects that term. AssetsTabs + // can therefore mount and fetch during selectActiveGlossary, before an + // explicit term or tab click. Arm the exact waiter before that action. const assetsSearchResponse = page.waitForResponse((response) => { const url = new URL(response.url()); const pageSize = Number(url.searchParams.get('size')); @@ -1210,6 +1209,7 @@ test.describe('Glossary tests', () => { ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true ); }); + await selectActiveGlossary(page, glossary1.data.displayName); await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); await assetsSearchResponse; From 3ffde4c7673f6e93c3f49c3d0dd342a689512dad Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 17:48:48 -0700 Subject: [PATCH 13/60] test(playwright): observe eager glossary asset fetch --- .../resources/ui/playwright/e2e/Pages/Glossary.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index 86d89dcec80b..fbae694f6cc7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1193,10 +1193,9 @@ test.describe('Glossary tests', () => { EntityTypeEndpoint.Table ); await sidebarClick(page, SidebarItem.GLOSSARY); - await selectActiveGlossary(page, glossary1.data.displayName); - // AssetsTabs can mount and fetch as soon as the active term changes, - // before the user clicks the Assets tab. Arm the response waiter first - // so we observe both eager and click-triggered fetches. + // Selecting a glossary with one term auto-selects that term. AssetsTabs + // can therefore mount and fetch during selectActiveGlossary, before an + // explicit term or tab click. Arm the exact waiter before that action. const assetsSearchResponse = page.waitForResponse((response) => { const url = new URL(response.url()); const pageSize = Number(url.searchParams.get('size')); @@ -1210,6 +1209,7 @@ test.describe('Glossary tests', () => { ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true ); }); + await selectActiveGlossary(page, glossary1.data.displayName); await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); await assetsSearchResponse; From 341083496a455b2dc899d3244960e186f90f97ca Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 18:19:49 -0700 Subject: [PATCH 14/60] test(playwright): wait for glossary search index --- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index fbae694f6cc7..ef2d6d359373 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1192,29 +1192,73 @@ test.describe('Glossary tests', () => { 'Add', EntityTypeEndpoint.Table ); + const entityFqn = get( + table, + 'entityResponseData.fullyQualifiedName' + ) as string; + const queryFilter = { + query: { + bool: { + must: [ + { + term: { + 'tags.tagFQN': glossaryTerm1.responseData.fullyQualifiedName, + }, + }, + ], + }, + }, + }; + + // The glossary Assets tab reads from the search index, which is updated + // asynchronously after the entity PATCH. Wait for the exact entity to be + // searchable before mounting the tab; waiting for a browser response is + // racy because a one-term glossary can fetch before the explicit click. + await expect + .poll( + async () => { + const response = await apiContext.get('/api/v1/search/query', { + params: { + q: '*', + index: 'all', + from: 0, + size: 10, + deleted: false, + query_filter: JSON.stringify(queryFilter), + }, + }); + + if (!response.ok()) { + return false; + } + + const result = (await response.json()) as { + hits?: { + hits?: Array<{ + _source?: { fullyQualifiedName?: string }; + }>; + }; + }; + + return ( + result.hits?.hits?.some( + (hit) => hit._source?.fullyQualifiedName === entityFqn + ) ?? false + ); + }, + { + message: `Wait for ${entityFqn} to be indexed with glossary term`, + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + } + ) + .toBe(true); + await sidebarClick(page, SidebarItem.GLOSSARY); - // Selecting a glossary with one term auto-selects that term. AssetsTabs - // can therefore mount and fetch during selectActiveGlossary, before an - // explicit term or tab click. Arm the exact waiter before that action. - const assetsSearchResponse = page.waitForResponse((response) => { - const url = new URL(response.url()); - const pageSize = Number(url.searchParams.get('size')); - - return ( - url.pathname.endsWith('/api/v1/search/query') && - url.searchParams.get('index') === 'all' && - pageSize > 0 && - url.searchParams - .get('query_filter') - ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true - ); - }); await selectActiveGlossary(page, glossary1.data.displayName); await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); - await assetsSearchResponse; await page.locator('.ant-tabs-tab-active:has-text("Assets")').waitFor(); - const entityFqn = get(table, 'entityResponseData.fullyQualifiedName'); await expect( page.getByTestId(`table-data-card_${entityFqn}`) From a5e0c2dafabb4f7a64feb8aa295f5d437ba13e71 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 18:19:49 -0700 Subject: [PATCH 15/60] test(playwright): wait for glossary search index --- .../ui/playwright/e2e/Pages/Glossary.spec.ts | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts index fbae694f6cc7..ef2d6d359373 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Glossary.spec.ts @@ -1192,29 +1192,73 @@ test.describe('Glossary tests', () => { 'Add', EntityTypeEndpoint.Table ); + const entityFqn = get( + table, + 'entityResponseData.fullyQualifiedName' + ) as string; + const queryFilter = { + query: { + bool: { + must: [ + { + term: { + 'tags.tagFQN': glossaryTerm1.responseData.fullyQualifiedName, + }, + }, + ], + }, + }, + }; + + // The glossary Assets tab reads from the search index, which is updated + // asynchronously after the entity PATCH. Wait for the exact entity to be + // searchable before mounting the tab; waiting for a browser response is + // racy because a one-term glossary can fetch before the explicit click. + await expect + .poll( + async () => { + const response = await apiContext.get('/api/v1/search/query', { + params: { + q: '*', + index: 'all', + from: 0, + size: 10, + deleted: false, + query_filter: JSON.stringify(queryFilter), + }, + }); + + if (!response.ok()) { + return false; + } + + const result = (await response.json()) as { + hits?: { + hits?: Array<{ + _source?: { fullyQualifiedName?: string }; + }>; + }; + }; + + return ( + result.hits?.hits?.some( + (hit) => hit._source?.fullyQualifiedName === entityFqn + ) ?? false + ); + }, + { + message: `Wait for ${entityFqn} to be indexed with glossary term`, + timeout: 60_000, + intervals: [1_000, 2_000, 5_000], + } + ) + .toBe(true); + await sidebarClick(page, SidebarItem.GLOSSARY); - // Selecting a glossary with one term auto-selects that term. AssetsTabs - // can therefore mount and fetch during selectActiveGlossary, before an - // explicit term or tab click. Arm the exact waiter before that action. - const assetsSearchResponse = page.waitForResponse((response) => { - const url = new URL(response.url()); - const pageSize = Number(url.searchParams.get('size')); - - return ( - url.pathname.endsWith('/api/v1/search/query') && - url.searchParams.get('index') === 'all' && - pageSize > 0 && - url.searchParams - .get('query_filter') - ?.includes(glossaryTerm1.responseData.fullyQualifiedName) === true - ); - }); await selectActiveGlossary(page, glossary1.data.displayName); await selectActiveGlossaryTerm(page, glossaryTerm1.data.displayName); await page.getByTestId('assets').click(); - await assetsSearchResponse; await page.locator('.ant-tabs-tab-active:has-text("Assets")').waitFor(); - const entityFqn = get(table, 'entityResponseData.fullyQualifiedName'); await expect( page.getByTestId(`table-data-card_${entityFqn}`) From e2e1d8339919d3cb198114c1a9b7a136bd80b454 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 18:32:50 -0700 Subject: [PATCH 16/60] test(playwright): persist following widget before navigation --- .../playwright/e2e/Flow/CustomizeWidgets.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts index d912f811e485..d5ff60554784 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts @@ -575,6 +575,15 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { await addAndVerifyWidget(page, widgetKey, persona.responseData.name); } + // Persist the widget before any navigation assertions. A fallback layout can + // render Following before the persona's docStore layout exists, then lose it + // when a later home navigation reads the authoritative saved layout. + await test.step('Test widget customization', async () => { + await waitForAllLoadersToDisappear(page); + await removeAndVerifyWidget(page, widgetKey, persona.responseData.name); + await addAndVerifyWidget(page, widgetKey, persona.responseData.name); + }); + await test.step('Test widget header and navigation', async () => { await waitForAllLoadersToDisappear(page); await verifyWidgetHeaderNavigation( @@ -615,13 +624,6 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { await redirectToHomePage(page); }); - - await test.step('Test widget customization', async () => { - await waitForAllLoadersToDisappear(page); - await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); - await removeAndVerifyWidget(page, widgetKey, persona.responseData.name); - await addAndVerifyWidget(page, widgetKey, persona.responseData.name); - }); }); test('Domains Widget', async ({ page, persona }) => { From cf3a48967fa7a2641871067682749faf431ff76b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 18:32:50 -0700 Subject: [PATCH 17/60] test(playwright): persist following widget before navigation --- .../playwright/e2e/Flow/CustomizeWidgets.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts index d912f811e485..d5ff60554784 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeWidgets.spec.ts @@ -575,6 +575,15 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { await addAndVerifyWidget(page, widgetKey, persona.responseData.name); } + // Persist the widget before any navigation assertions. A fallback layout can + // render Following before the persona's docStore layout exists, then lose it + // when a later home navigation reads the authoritative saved layout. + await test.step('Test widget customization', async () => { + await waitForAllLoadersToDisappear(page); + await removeAndVerifyWidget(page, widgetKey, persona.responseData.name); + await addAndVerifyWidget(page, widgetKey, persona.responseData.name); + }); + await test.step('Test widget header and navigation', async () => { await waitForAllLoadersToDisappear(page); await verifyWidgetHeaderNavigation( @@ -615,13 +624,6 @@ test('Following Assets Widget', async ({ page, persona, testUser }) => { await redirectToHomePage(page); }); - - await test.step('Test widget customization', async () => { - await waitForAllLoadersToDisappear(page); - await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); - await removeAndVerifyWidget(page, widgetKey, persona.responseData.name); - await addAndVerifyWidget(page, widgetKey, persona.responseData.name); - }); }); test('Domains Widget', async ({ page, persona }) => { From 74a08a6cf6249301229ea9f7d60d353c47eeb363 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 20:52:33 -0700 Subject: [PATCH 18/60] test(playwright): fix strict AUT failures --- .../Features/DataQuality/DataQuality.spec.ts | 32 +++++++++---------- .../Features/Glossary/GlossaryP3Tests.spec.ts | 6 ++++ .../Features/OntologyExplorerFilters.spec.ts | 4 +++ .../OntologyExplorerIntegration.spec.ts | 6 ++-- .../e2e/Flow/MetricListSearch.spec.ts | 9 ++---- .../VersionPages/EntityVersionPages.spec.ts | 4 ++- .../ui/playwright/utils/dataQuality.ts | 17 ++++++++-- .../resources/ui/playwright/utils/entity.ts | 31 ++++++++++++++---- .../resources/ui/playwright/utils/glossary.ts | 17 ++++++++-- 9 files changed, 88 insertions(+), 38 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts index 6b962e02f67c..e6079a9e6812 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts @@ -1076,6 +1076,14 @@ test.describe( } }; + const toggleAdvancedFilter = async (page: Page, value: string) => { + await page.getByTestId('advanced-filter').click(); + + const visibleDropdown = page.locator('.ant-dropdown:visible'); + await expect(visibleDropdown).toBeVisible(); + await visibleDropdown.locator(`[value="${value}"]`).click(); + }; + try { await sidebarClick(page, SidebarItem.DATA_QUALITY); @@ -1083,14 +1091,10 @@ test.describe( await waitForAllLoadersToDisappear(page); // get all the filters - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="testPlatforms"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="lastRunRange"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="serviceName"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="tier"]'); + await toggleAdvancedFilter(page, 'testPlatforms'); + await toggleAdvancedFilter(page, 'lastRunRange'); + await toggleAdvancedFilter(page, 'serviceName'); + await toggleAdvancedFilter(page, 'tier'); // Test case search filter const searchTestCaseResponse = page.waitForResponse( @@ -1137,11 +1141,10 @@ test.describe( await verifyFilter2TestCase(page); // remove service filter - await page.click('[data-testid="advanced-filter"]'); const getTestCase = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="serviceName"]'); + await toggleAdvancedFilter(page, 'serviceName'); await getTestCase; // Test case filter by Tags @@ -1171,11 +1174,10 @@ test.describe( await verifyFilter2TestCase(page, true); // remove tags filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTag = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="tags"]'); + await toggleAdvancedFilter(page, 'tags'); await getTestCaseWithoutTag; // Test case filter by Tier @@ -1192,11 +1194,10 @@ test.describe( await verifyFilter2TestCase(page, true); // remove tier filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTier = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="tier"]'); + await toggleAdvancedFilter(page, 'tier'); await getTestCaseWithoutTier; // Test case filter by table name @@ -1310,8 +1311,7 @@ test.describe( expect(page.url()).toBe(url); - await page.getByTestId('advanced-filter').click(); - await page.click('[value="testPlatforms"]'); + await toggleAdvancedFilter(page, 'testPlatforms'); await expect( page.getByTestId('platform-select-filter') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 68d0bec065d1..50f506281722 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -803,6 +803,9 @@ test.describe('Glossary P3 Tests', () => { // Check for glossary page elements (redirect behavior) const glossaryHeader = page.getByTestId('entity-header-name'); const addGlossaryButton = page.getByTestId('add-glossary'); + const addGlossaryListButton = page + .getByRole('button', { name: 'Add', exact: true }) + .first(); const glossarySidebar = page.locator('.left-panel-card'); // Any of these states is acceptable for error handling @@ -824,6 +827,9 @@ test.describe('Glossary P3 Tests', () => { (await addGlossaryButton .isVisible({ timeout: 2000 }) .catch(() => false)) || + (await addGlossaryListButton + .isVisible({ timeout: 2000 }) + .catch(() => false)) || (await glossarySidebar.isVisible({ timeout: 2000 }).catch(() => false)); // Verify the app handled the invalid URL (either error page or redirect) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts index c968914ff8d9..d5059522c969 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts @@ -14,6 +14,7 @@ import { expect, test } from '@playwright/test'; import { Glossary } from '../../support/glossary/Glossary'; import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; +import { closeFirstPopupAlert } from '../../utils/common'; import { addTermRelation, applyGlossaryFilter, @@ -266,6 +267,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await expect(page.getByRole('tab', { name: 'Model' })).toHaveAttribute( 'aria-selected', @@ -300,6 +302,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await expect(page.getByTestId('ontology-clear-all-btn')).toBeVisible(); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await waitForGraphLoaded(page); await expect(stats).toContainText('2 Terms'); @@ -400,6 +403,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await expect(page.getByTestId('view-mode-select')).not.toHaveAttribute( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts index 7ed9162369d6..b1f27b19af20 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts @@ -65,8 +65,7 @@ test.describe('Relation Sync with OntologyExplorer', () => { await addTermRelation(apiContext, syncTerm1, syncTerm2, 'synonym'); await apiContext.dispose(); - await page.getByTestId('refresh').click(); - await waitForGraphLoaded(page); + await navigateAndFilterByGlossary(page, syncGlossary.responseData.id); await expect(page.getByTestId('ontology-explorer-stats')).toContainText( /1\s*Relations?/i @@ -78,8 +77,7 @@ test.describe('Relation Sync with OntologyExplorer', () => { ]); await apiContext2.dispose(); - await page.getByTestId('refresh').click(); - await waitForGraphLoaded(page); + await navigateAndFilterByGlossary(page, syncGlossary.responseData.id); await expect(page.getByTestId('ontology-explorer-stats')).toContainText( /0\s*Relations?/i diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts index 2070f1edcd7b..ba364804a868 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts @@ -125,12 +125,9 @@ test.describe('Metric List Page - Search', { tag: ['@Discovery'] }, () => { await waitForAllLoadersToDisappear(page); - // matchName is globally unique, so the server-side search settles to - // exactly one row. Asserting the settled count first avoids racing React - // Query's keepPreviousData, which briefly keeps the full (pre-search) list - // rendered during the refetch — the source of the flake on the negative - // otherName assertion below. - await expect(page.getByTestId('metric-name')).toHaveCount(1); + // Metric search is fuzzy and may legitimately return additional ranked + // matches. Verify that the requested unique metric is present and that + // the known control metric was filtered out without assuming one row. await expect( page.getByTestId('metric-name').filter({ hasText: matchName }) ).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts index 06cb72194694..ab71f9d83173 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts @@ -152,7 +152,9 @@ test.describe('Entity Version pages', () => { entityClasses.forEach((EntityClass) => { test(`${new EntityClass().getType()}`, async ({ page }) => { - test.slow(); + // Async deletion alone is allowed up to five minutes. Keep the enclosing + // test alive long enough for setup, version checks, and that full poll. + test.setTimeout(BIG_ENTITY_DELETE_TIMEOUT + 180_000); const entity = entities.find( (e) => e instanceof EntityClass diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts index eb4b4054da5e..1654fb8e07bd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -373,9 +373,22 @@ export const fillAndSubmitBundleSuiteForm = async ( name: string ) => { await page.getByTestId('test-suite-name').locator('input').fill(name); - const createResponse = page.waitForResponse('/api/v1/dataQuality/testSuites'); + const createResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/dataQuality/testSuites') && + response.request().method() === 'POST' + ); + const bulkResponse = page.waitForResponse( + (response) => + response + .url() + .includes('/api/v1/dataQuality/testCases/logicalTestCases/bulk') && + response.request().method() === 'POST' + ); await page.getByTestId('submit-button').click(); - await createResponse; + + expect((await createResponse).ok()).toBeTruthy(); + expect((await bulkResponse).ok()).toBeTruthy(); }; export const openAddToExistingBundleSuiteModal = async (page: Page) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 3efa5ced56e0..f2d9cd91dc94 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -635,11 +635,32 @@ export const removeTier = async (page: Page, endpoint: string) => { await expect(page.getByTestId('Tier')).toContainText('--'); }; +const closeCertificationPopover = async (page: Page) => { + const popover = page.locator('.certification-card-popover'); + + if (!(await popover.isVisible())) { + return; + } + + const closeButton = page.getByTestId('close-certification'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } else { + await clickOutside(page); + } + + await expect(popover).toBeHidden(); +}; + export const assignCertification = async ( page: Page, certification: TagClass, endpoint: string ) => { + // A previous entity update can leave the controlled popover mounted over the + // next entity's edit button. Normalize that state before opening it again. + await closeCertificationPopover(page); + const certificationResponse = page.waitForResponse( (response) => response.url().includes('/api/v1/tags') && @@ -668,10 +689,7 @@ export const assignCertification = async ( // reset to [], leaving no Radio.Group for the scroll helper to hover. Close // and reopen to issue a fresh fetch, then retry the complete find operation. if (!(await certificationCards.isVisible())) { - const closeButton = page.getByTestId('close-certification'); - if (await closeButton.isVisible()) { - await closeButton.click(); - } + await closeCertificationPopover(page); await page.getByTestId('edit-certification').click(); await expect(certificationCards).toBeVisible({ timeout: 5_000 }); } @@ -696,7 +714,7 @@ export const assignCertification = async ( expect(patchResponse.status()).toBe(200); await waitForAllLoadersToDisappear(page); - await clickOutside(page); + await closeCertificationPopover(page); await expect(page.getByTestId('certification-label')).toContainText( certification.responseData.displayName @@ -704,6 +722,7 @@ export const assignCertification = async ( }; export const removeCertification = async (page: Page, endpoint: string) => { + await closeCertificationPopover(page); await page.getByTestId('edit-certification').click(); await page .locator('.certification-card-popover') @@ -720,7 +739,7 @@ export const removeCertification = async (page: Page, endpoint: string) => { expect(response.status()).toBe(200); await waitForAllLoadersToDisappear(page); - await clickOutside(page); + await closeCertificationPopover(page); await expect(page.getByTestId('certification-label')).toContainText('--'); }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 022359ac3663..e1b3a366f2db 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1184,9 +1184,20 @@ export const changeTermHierarchyFromModal = async ( }); if (isGlossaryTerm) { - const searchRes = page.waitForResponse(`/api/v1/search/query?q=*`); - await page.getByLabel('Select Parent').fill(entityDisplayName); - await searchRes; + const parentInput = page.getByLabel('Select Parent'); + const targetParent = page.getByTestId(`tag-${entityFqn}`); + + // Newly-created terms reach the search index asynchronously. Repeat the + // real search request until the requested parent is rendered instead of + // assuming the first response already contains it. + await expect(async () => { + await parentInput.clear(); + const searchRes = page.waitForResponse(`/api/v1/search/query?q=*`); + await parentInput.fill(entityDisplayName); + const response = await searchRes; + expect(response.ok()).toBeTruthy(); + await expect(targetParent).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 60_000, intervals: [1_000, 2_000, 5_000] }); } await page.getByTestId(`tag-${entityFqn}`).click(); From aec44455faa5415c4038a25260320b71f75657a6 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 20:52:33 -0700 Subject: [PATCH 19/60] test(playwright): fix strict AUT failures --- .../Features/DataQuality/DataQuality.spec.ts | 32 +++++++++---------- .../Features/Glossary/GlossaryP3Tests.spec.ts | 6 ++++ .../Features/OntologyExplorerFilters.spec.ts | 4 +++ .../OntologyExplorerIntegration.spec.ts | 6 ++-- .../e2e/Flow/MetricListSearch.spec.ts | 9 ++---- .../VersionPages/EntityVersionPages.spec.ts | 4 ++- .../ui/playwright/utils/dataQuality.ts | 17 ++++++++-- .../resources/ui/playwright/utils/entity.ts | 31 ++++++++++++++---- .../resources/ui/playwright/utils/glossary.ts | 17 ++++++++-- 9 files changed, 88 insertions(+), 38 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts index 6b962e02f67c..e6079a9e6812 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts @@ -1076,6 +1076,14 @@ test.describe( } }; + const toggleAdvancedFilter = async (page: Page, value: string) => { + await page.getByTestId('advanced-filter').click(); + + const visibleDropdown = page.locator('.ant-dropdown:visible'); + await expect(visibleDropdown).toBeVisible(); + await visibleDropdown.locator(`[value="${value}"]`).click(); + }; + try { await sidebarClick(page, SidebarItem.DATA_QUALITY); @@ -1083,14 +1091,10 @@ test.describe( await waitForAllLoadersToDisappear(page); // get all the filters - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="testPlatforms"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="lastRunRange"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="serviceName"]'); - await page.click('[data-testid="advanced-filter"]'); - await page.click('[value="tier"]'); + await toggleAdvancedFilter(page, 'testPlatforms'); + await toggleAdvancedFilter(page, 'lastRunRange'); + await toggleAdvancedFilter(page, 'serviceName'); + await toggleAdvancedFilter(page, 'tier'); // Test case search filter const searchTestCaseResponse = page.waitForResponse( @@ -1137,11 +1141,10 @@ test.describe( await verifyFilter2TestCase(page); // remove service filter - await page.click('[data-testid="advanced-filter"]'); const getTestCase = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="serviceName"]'); + await toggleAdvancedFilter(page, 'serviceName'); await getTestCase; // Test case filter by Tags @@ -1171,11 +1174,10 @@ test.describe( await verifyFilter2TestCase(page, true); // remove tags filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTag = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="tags"]'); + await toggleAdvancedFilter(page, 'tags'); await getTestCaseWithoutTag; // Test case filter by Tier @@ -1192,11 +1194,10 @@ test.describe( await verifyFilter2TestCase(page, true); // remove tier filter - await page.click('[data-testid="advanced-filter"]'); const getTestCaseWithoutTier = page.waitForResponse( '/api/v1/dataQuality/testCases/search/list?*' ); - await page.click('[value="tier"]'); + await toggleAdvancedFilter(page, 'tier'); await getTestCaseWithoutTier; // Test case filter by table name @@ -1310,8 +1311,7 @@ test.describe( expect(page.url()).toBe(url); - await page.getByTestId('advanced-filter').click(); - await page.click('[value="testPlatforms"]'); + await toggleAdvancedFilter(page, 'testPlatforms'); await expect( page.getByTestId('platform-select-filter') diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 68d0bec065d1..50f506281722 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -803,6 +803,9 @@ test.describe('Glossary P3 Tests', () => { // Check for glossary page elements (redirect behavior) const glossaryHeader = page.getByTestId('entity-header-name'); const addGlossaryButton = page.getByTestId('add-glossary'); + const addGlossaryListButton = page + .getByRole('button', { name: 'Add', exact: true }) + .first(); const glossarySidebar = page.locator('.left-panel-card'); // Any of these states is acceptable for error handling @@ -824,6 +827,9 @@ test.describe('Glossary P3 Tests', () => { (await addGlossaryButton .isVisible({ timeout: 2000 }) .catch(() => false)) || + (await addGlossaryListButton + .isVisible({ timeout: 2000 }) + .catch(() => false)) || (await glossarySidebar.isVisible({ timeout: 2000 }).catch(() => false)); // Verify the app handled the invalid URL (either error page or redirect) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts index c968914ff8d9..d5059522c969 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts @@ -14,6 +14,7 @@ import { expect, test } from '@playwright/test'; import { Glossary } from '../../support/glossary/Glossary'; import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; +import { closeFirstPopupAlert } from '../../utils/common'; import { addTermRelation, applyGlossaryFilter, @@ -266,6 +267,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await expect(page.getByRole('tab', { name: 'Model' })).toHaveAttribute( 'aria-selected', @@ -300,6 +302,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await expect(page.getByTestId('ontology-clear-all-btn')).toBeVisible(); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await waitForGraphLoaded(page); await expect(stats).toContainText('2 Terms'); @@ -400,6 +403,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); + await closeFirstPopupAlert(page); await page.getByRole('tab', { name: 'Model' }).click(); await expect(page.getByTestId('view-mode-select')).not.toHaveAttribute( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts index 7ed9162369d6..b1f27b19af20 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerIntegration.spec.ts @@ -65,8 +65,7 @@ test.describe('Relation Sync with OntologyExplorer', () => { await addTermRelation(apiContext, syncTerm1, syncTerm2, 'synonym'); await apiContext.dispose(); - await page.getByTestId('refresh').click(); - await waitForGraphLoaded(page); + await navigateAndFilterByGlossary(page, syncGlossary.responseData.id); await expect(page.getByTestId('ontology-explorer-stats')).toContainText( /1\s*Relations?/i @@ -78,8 +77,7 @@ test.describe('Relation Sync with OntologyExplorer', () => { ]); await apiContext2.dispose(); - await page.getByTestId('refresh').click(); - await waitForGraphLoaded(page); + await navigateAndFilterByGlossary(page, syncGlossary.responseData.id); await expect(page.getByTestId('ontology-explorer-stats')).toContainText( /0\s*Relations?/i diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts index 2070f1edcd7b..ba364804a868 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/MetricListSearch.spec.ts @@ -125,12 +125,9 @@ test.describe('Metric List Page - Search', { tag: ['@Discovery'] }, () => { await waitForAllLoadersToDisappear(page); - // matchName is globally unique, so the server-side search settles to - // exactly one row. Asserting the settled count first avoids racing React - // Query's keepPreviousData, which briefly keeps the full (pre-search) list - // rendered during the refetch — the source of the flake on the negative - // otherName assertion below. - await expect(page.getByTestId('metric-name')).toHaveCount(1); + // Metric search is fuzzy and may legitimately return additional ranked + // matches. Verify that the requested unique metric is present and that + // the known control metric was filtered out without assuming one row. await expect( page.getByTestId('metric-name').filter({ hasText: matchName }) ).toBeVisible(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts index 06cb72194694..ab71f9d83173 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/EntityVersionPages.spec.ts @@ -152,7 +152,9 @@ test.describe('Entity Version pages', () => { entityClasses.forEach((EntityClass) => { test(`${new EntityClass().getType()}`, async ({ page }) => { - test.slow(); + // Async deletion alone is allowed up to five minutes. Keep the enclosing + // test alive long enough for setup, version checks, and that full poll. + test.setTimeout(BIG_ENTITY_DELETE_TIMEOUT + 180_000); const entity = entities.find( (e) => e instanceof EntityClass diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts index eb4b4054da5e..1654fb8e07bd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -373,9 +373,22 @@ export const fillAndSubmitBundleSuiteForm = async ( name: string ) => { await page.getByTestId('test-suite-name').locator('input').fill(name); - const createResponse = page.waitForResponse('/api/v1/dataQuality/testSuites'); + const createResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/dataQuality/testSuites') && + response.request().method() === 'POST' + ); + const bulkResponse = page.waitForResponse( + (response) => + response + .url() + .includes('/api/v1/dataQuality/testCases/logicalTestCases/bulk') && + response.request().method() === 'POST' + ); await page.getByTestId('submit-button').click(); - await createResponse; + + expect((await createResponse).ok()).toBeTruthy(); + expect((await bulkResponse).ok()).toBeTruthy(); }; export const openAddToExistingBundleSuiteModal = async (page: Page) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 3efa5ced56e0..f2d9cd91dc94 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -635,11 +635,32 @@ export const removeTier = async (page: Page, endpoint: string) => { await expect(page.getByTestId('Tier')).toContainText('--'); }; +const closeCertificationPopover = async (page: Page) => { + const popover = page.locator('.certification-card-popover'); + + if (!(await popover.isVisible())) { + return; + } + + const closeButton = page.getByTestId('close-certification'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } else { + await clickOutside(page); + } + + await expect(popover).toBeHidden(); +}; + export const assignCertification = async ( page: Page, certification: TagClass, endpoint: string ) => { + // A previous entity update can leave the controlled popover mounted over the + // next entity's edit button. Normalize that state before opening it again. + await closeCertificationPopover(page); + const certificationResponse = page.waitForResponse( (response) => response.url().includes('/api/v1/tags') && @@ -668,10 +689,7 @@ export const assignCertification = async ( // reset to [], leaving no Radio.Group for the scroll helper to hover. Close // and reopen to issue a fresh fetch, then retry the complete find operation. if (!(await certificationCards.isVisible())) { - const closeButton = page.getByTestId('close-certification'); - if (await closeButton.isVisible()) { - await closeButton.click(); - } + await closeCertificationPopover(page); await page.getByTestId('edit-certification').click(); await expect(certificationCards).toBeVisible({ timeout: 5_000 }); } @@ -696,7 +714,7 @@ export const assignCertification = async ( expect(patchResponse.status()).toBe(200); await waitForAllLoadersToDisappear(page); - await clickOutside(page); + await closeCertificationPopover(page); await expect(page.getByTestId('certification-label')).toContainText( certification.responseData.displayName @@ -704,6 +722,7 @@ export const assignCertification = async ( }; export const removeCertification = async (page: Page, endpoint: string) => { + await closeCertificationPopover(page); await page.getByTestId('edit-certification').click(); await page .locator('.certification-card-popover') @@ -720,7 +739,7 @@ export const removeCertification = async (page: Page, endpoint: string) => { expect(response.status()).toBe(200); await waitForAllLoadersToDisappear(page); - await clickOutside(page); + await closeCertificationPopover(page); await expect(page.getByTestId('certification-label')).toContainText('--'); }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 022359ac3663..e1b3a366f2db 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1184,9 +1184,20 @@ export const changeTermHierarchyFromModal = async ( }); if (isGlossaryTerm) { - const searchRes = page.waitForResponse(`/api/v1/search/query?q=*`); - await page.getByLabel('Select Parent').fill(entityDisplayName); - await searchRes; + const parentInput = page.getByLabel('Select Parent'); + const targetParent = page.getByTestId(`tag-${entityFqn}`); + + // Newly-created terms reach the search index asynchronously. Repeat the + // real search request until the requested parent is rendered instead of + // assuming the first response already contains it. + await expect(async () => { + await parentInput.clear(); + const searchRes = page.waitForResponse(`/api/v1/search/query?q=*`); + await parentInput.fill(entityDisplayName); + const response = await searchRes; + expect(response.ok()).toBeTruthy(); + await expect(targetParent).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 60_000, intervals: [1_000, 2_000, 5_000] }); } await page.getByTestId(`tag-${entityFqn}`).click(); From 5bc06b54eec3e511096098a394ddaa14e8b9dc04 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:04:23 -0700 Subject: [PATCH 20/60] fix(data-access): resolve current assignee server-side --- .../service/resources/tasks/TaskResource.java | 33 +++++++++++++++---- .../resources/ui/src/rest/tasksAPI.test.ts | 24 +++++++++++++- .../main/resources/ui/src/rest/tasksAPI.ts | 1 + 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java index ff4e3ada97dc..2cfde603038b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java @@ -444,6 +444,13 @@ public ResultList listDataAccessRequests( @Parameter(description = "Filter by assignee user/team id (single UUID).") @QueryParam("assigneeId") UUID assigneeId, + @Parameter( + description = + "Filter to tasks assigned to the authenticated user or any of their teams. " + + "When true, this takes precedence over assignee and assigneeId.") + @QueryParam("assignedToMe") + @DefaultValue("false") + boolean assignedToMe, @Parameter(description = "Filter by domain FQN") @QueryParam("domain") String domain, @Parameter( description = @@ -507,12 +514,7 @@ public ResultList listDataAccessRequests( validateCsvAgainstAccessType(accessType); filter.addQueryParam("accessType", accessType); } - if (!nullOrEmpty(assignee)) { - filter.addQueryParam("assignee", assignee); - } - if (assigneeId != null) { - filter.addQueryParam("assigneeId", assigneeId.toString()); - } + addDataAccessRequestAssigneeFilter(filter, securityContext, assignee, assigneeId, assignedToMe); if (!nullOrEmpty(q)) { filter.addQueryParam("darSearch", q); } @@ -1375,6 +1377,25 @@ private void addCurrentUserVisibleFilters( filter.addQueryParam("visibleOwnedByIds", getCurrentUserOwnedIds(uriInfo, securityContext)); } + private void addDataAccessRequestAssigneeFilter( + ListFilter filter, + SecurityContext securityContext, + String assignee, + UUID assigneeId, + boolean assignedToMe) { + if (assignedToMe) { + // Resolve memberships to immutable IDs so clients cannot omit teams or misquote FQNs. + filter.addQueryParam("assigneeIds", getCurrentUserAssigneeIds(securityContext)); + } else { + if (!nullOrEmpty(assignee)) { + filter.addQueryParam("assignee", assignee); + } + if (assigneeId != null) { + filter.addQueryParam("assigneeId", assigneeId.toString()); + } + } + } + private String getCurrentUserAssigneeIds(SecurityContext securityContext) { String userName = securityContext.getUserPrincipal().getName(); User user = Entity.getEntityByName(Entity.USER, userName, "teams", Include.NON_DELETED); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.test.ts b/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.test.ts index c2beb8e09e52..9c6c0327352d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.test.ts @@ -13,7 +13,12 @@ import { AxiosHeaders, InternalAxiosRequestConfig } from 'axios'; import { Task } from '../generated/entity/tasks/task'; -import { addTaskComment, deleteTaskComment, editTaskComment } from './tasksAPI'; +import { + addTaskComment, + deleteTaskComment, + editTaskComment, + listDataAccessRequests, +} from './tasksAPI'; let mockCapturedRequest: InternalAxiosRequestConfig | undefined; @@ -95,3 +100,20 @@ describe('tasksAPI comments', () => { }); }); }); + +describe('tasksAPI data access requests', () => { + beforeEach(() => { + mockCapturedRequest = undefined; + }); + + it('passes the server-resolved current-assignee filter', async () => { + await listDataAccessRequests({ assignedToMe: true, status: ['Approved'] }); + + expect(mockCapturedRequest?.method).toBe('get'); + expect(mockCapturedRequest?.url).toBe('/tasks/dataAccessRequests'); + expect(mockCapturedRequest?.params).toEqual({ + assignedToMe: true, + status: ['Approved'], + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.ts index 4dfa3fd299cb..d206c0651d15 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/tasksAPI.ts @@ -118,6 +118,7 @@ export interface ListDataAccessRequestsParams { approver?: string; approverId?: string; assignee?: string; + assignedToMe?: boolean; accessType?: DataAccessType | DataAccessType[]; domain?: string; q?: string; From 3db3b2b0beec669fdd939b50729dba55686d17ee Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:10:01 -0700 Subject: [PATCH 21/60] test(data-access): cover current assignee filter --- .../tests/DataAccessRequestValidationIT.java | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestValidationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestValidationIT.java index a4b1d20cbcc1..98cf0fea7200 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestValidationIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestValidationIT.java @@ -14,6 +14,7 @@ package org.openmetadata.it.tests; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -25,6 +26,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.bootstrap.SharedEntities; import org.openmetadata.it.factories.DatabaseSchemaTestFactory; import org.openmetadata.it.factories.TableTestFactory; import org.openmetadata.it.util.SdkClients; @@ -140,14 +142,46 @@ private static Task createDataAccessRequest( String entityType, String entityFqn, Map payload) { - CreateTask request = - new CreateTask() - .withName(ns.prefix("dar_" + entityType + "_" + UUID.randomUUID())) - .withCategory(TaskCategory.DataAccess) - .withType(TaskEntityType.DataAccessRequest) - .withAbout(entityLink(entityType, entityFqn)) - .withPayload(payload); - return client.tasks().create(request); + return client.tasks().create(dataAccessRequest(ns, entityType, entityFqn, payload)); + } + + private static CreateTask dataAccessRequest( + TestNamespace ns, String entityType, String entityFqn, Map payload) { + return new CreateTask() + .withName(ns.prefix("dar_" + entityType + "_" + UUID.randomUUID())) + .withCategory(TaskCategory.DataAccess) + .withType(TaskEntityType.DataAccessRequest) + .withAbout(entityLink(entityType, entityFqn)) + .withPayload(payload); + } + + @Test + void testDarAssignedToCurrentUser_filtersByAuthenticatedPrincipal(TestNamespace ns) { + Table table = createTableOnSnowflakeService(ns, baseSnowflakeConnection()); + String tableFqn = table.getFullyQualifiedName(); + SharedEntities shared = SharedEntities.get(); + + Task user1Task = + SdkClients.adminClient() + .tasks() + .create( + dataAccessRequest(ns, "table", tableFqn, dataAccessPayload("FullAccess")) + .withAssignees(List.of(shared.USER1.getFullyQualifiedName()))); + Task user2Task = + SdkClients.user3Client() + .tasks() + .create( + dataAccessRequest(ns, "table", tableFqn, dataAccessPayload("FullAccess")) + .withAssignees(List.of(shared.USER2.getFullyQualifiedName()))); + + var assignedToUser1 = + SdkClients.user1Client() + .tasks() + .listDataAccessRequests( + Map.of("dataset", tableFqn, "assignedToMe", "true", "limit", "50")); + List taskIds = assignedToUser1.getData().stream().map(Task::getId).toList(); + assertTrue(taskIds.contains(user1Task.getId())); + assertFalse(taskIds.contains(user2Task.getId())); } @Test From f8044a5c81d569feec3065f97f04a5217e7009eb Mon Sep 17 00:00:00 2001 From: Aniket Katkar Date: Thu, 20 Aug 2026 09:22:44 +0530 Subject: [PATCH 22/60] Stop IngestionLogStreamLive racing the connector's quiet phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "log content keeps growing" assertion budgeted 30s for the next batch of lines, on the assumption that a live run writes at a steady rate. It does not: the connector logs a burst, then goes silent for as long as its next phase takes. The longest gap is between the connection test and the first topic being ingested, where the Kafka consumer joins its group and blocks on an empty poll without logging anything. A measured CI run sat silent for 29.8s in that gap and the assertion gave up 0.2s before the next burst arrived — the budget was calibrated on top of the gap it had to clear, so which side it landed on was a coin flip. Widen the window to 90s. The test already carries test.slow() (180s) and ran in 33s, so there is ample room. A stream that connects but delivers nothing still fails: a healthy run does not go silent for 90s. Co-Authored-By: Claude Opus 5 (1M context) --- .../e2e/Pages/IngestionLogStreamLive.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/IngestionLogStreamLive.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/IngestionLogStreamLive.spec.ts index bffe936288b4..91862420380a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/IngestionLogStreamLive.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/IngestionLogStreamLive.spec.ts @@ -290,13 +290,22 @@ test.describe( ).toBeGreaterThan(0); // The server reads the run's log every 2s (LogStreamSettings.pollSeconds), - // so a still-running agent must push more lines within a few ticks. A - // stream that connects but delivers nothing fails here. + // but the connector does not write at a steady rate: it logs a burst, then + // goes quiet for however long its next phase takes. The longest gap is + // between the connection test and the first topic being ingested, where the + // Kafka consumer joins its group and blocks on an empty poll — nothing is + // logged for the whole of it. A measured CI run sat silent for 29.8s there + // and this assertion, then budgeted 30s, gave up 0.2s before the next burst + // landed. The window has to clear that gap with margin rather than race it; + // `test.slow()` above leaves ample room (the whole test ran in 33s). + // + // A stream that connects but delivers nothing still fails here — 90s of + // silence is not something a healthy run produces. await expect .poll(() => getLogViewerLineCount(page), { message: 'the log viewer should keep receiving lines while the run is live', - timeout: 30_000, + timeout: 90_000, intervals: [2_000], }) .toBeGreaterThan(initialLineCount); From aed42249a2dbace423d3727dd723be269e53b2d6 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:34:28 -0700 Subject: [PATCH 23/60] test(playwright): bound certification popover close race --- .../src/main/resources/ui/playwright/utils/entity.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index f2d9cd91dc94..ddada253f168 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -644,12 +644,18 @@ const closeCertificationPopover = async (page: Page) => { const closeButton = page.getByTestId('close-certification'); if (await closeButton.isVisible()) { - await closeButton.click(); - } else { + // A successful PATCH closes this controlled popover asynchronously. The + // button can therefore be visible for this check and detach before + // Playwright finishes its actionability checks. Keep that race bounded; + // the hidden-state assertion below is the actual close contract. + await closeButton.click({ timeout: 2_000 }).catch(() => undefined); + } + + if (await popover.isVisible()) { await clickOutside(page); } - await expect(popover).toBeHidden(); + await expect(popover).toBeHidden({ timeout: 5_000 }); }; export const assignCertification = async ( From 2fe87f972fd69bcff16c6dcd33d33c5623a24002 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:34:28 -0700 Subject: [PATCH 24/60] test(playwright): bound certification popover close race --- .../src/main/resources/ui/playwright/utils/entity.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index f2d9cd91dc94..ddada253f168 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -644,12 +644,18 @@ const closeCertificationPopover = async (page: Page) => { const closeButton = page.getByTestId('close-certification'); if (await closeButton.isVisible()) { - await closeButton.click(); - } else { + // A successful PATCH closes this controlled popover asynchronously. The + // button can therefore be visible for this check and detach before + // Playwright finishes its actionability checks. Keep that race bounded; + // the hidden-state assertion below is the actual close contract. + await closeButton.click({ timeout: 2_000 }).catch(() => undefined); + } + + if (await popover.isVisible()) { await clickOutside(page); } - await expect(popover).toBeHidden(); + await expect(popover).toBeHidden({ timeout: 5_000 }); }; export const assignCertification = async ( From 701ba2d56e08bc6dd3253ca92f178cf781e19cd0 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:40:18 -0700 Subject: [PATCH 25/60] test(playwright): match traced application contracts --- .../e2e/Features/Glossary/GlossaryP3Tests.spec.ts | 6 +++++- .../e2e/Pages/SearchIndexApplication.spec.ts | 10 +--------- .../main/resources/ui/playwright/utils/dataQuality.ts | 2 +- .../resources/ui/playwright/utils/ontologyExplorer.ts | 9 --------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 50f506281722..84536086e555 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -791,7 +791,8 @@ test.describe('Glossary P3 Tests', () => { try { // Navigate directly to a non-existent glossary (without redirectToHomePage) - await page.goto(`/glossary/NonExistentGlossary_${Date.now()}`); + const invalidGlossaryPath = `/glossary/NonExistentGlossary_${Date.now()}`; + await page.goto(invalidGlossaryPath); await page.waitForLoadState('domcontentloaded'); await waitForAllLoadersToDisappear(page).catch(() => {}); @@ -807,9 +808,12 @@ test.describe('Glossary P3 Tests', () => { .getByRole('button', { name: 'Add', exact: true }) .first(); const glossarySidebar = page.locator('.left-panel-card'); + const wasRedirected = + new URL(page.url()).pathname !== invalidGlossaryPath; // Any of these states is acceptable for error handling const hasValidResponse = + wasRedirected || (await badMessage .first() .isVisible({ timeout: 10000 }) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 26b5dff06457..1d1e6ff2a18d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -12,14 +12,12 @@ */ import test, { expect, Page, Response } from '@playwright/test'; import { PLAYWRIGHT_BASIC_TEST_TAG_OBJ } from '../../constant/config'; -import { GlobalSettingOptions } from '../../constant/settings'; import { clickOutside, getApiContext, redirectToHomePage, toastNotification, } from '../../utils/common'; -import { settingClick } from '../../utils/sidebar'; // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); @@ -283,19 +281,13 @@ test.describe('Search Index Application', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { }, }); } - - await settingClick(page, GlobalSettingOptions.APPLICATIONS); }); await test.step('Verify last execution run', async () => { const statusAPI = page.waitForResponse( '/api/v1/apps/name/SearchIndexingApplication/status?offset=0&limit=1' ); - await page - .locator( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ) - .click(); + await page.goto('/settings/apps/SearchIndexingApplication'); const statusResponse = await statusAPI; expect(statusResponse.status()).toBe(200); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts index 1654fb8e07bd..9a4fc33bf016 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -383,7 +383,7 @@ export const fillAndSubmitBundleSuiteForm = async ( response .url() .includes('/api/v1/dataQuality/testCases/logicalTestCases/bulk') && - response.request().method() === 'POST' + response.request().method() === 'PUT' ); await page.getByTestId('submit-button').click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts index 36dc40b9dc18..d3aa8d8e2caf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts @@ -28,16 +28,7 @@ export const DANGLING_GRAPH_NODE_ID = '00000000-0000-0000-0000-000000000000'; export async function applyGlossaryFilter(page: Page, glossaryId: string) { await page.getByTestId('search-dropdown-Glossary').click(); await page.getByTestId(glossaryId).click(); - const termsResponse = page - .waitForResponse( - (response) => - response.url().includes('/api/v1/glossaryTerms') && - response.status() === 200, - { timeout: 30000 } - ) - .catch(() => null); await page.getByTestId('update-btn').click(); - await termsResponse; } export async function navigateToOntologyExplorer(page: Page) { From 646e22d9d189b0dd09314bf8b2a17cf8c483857a Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:40:18 -0700 Subject: [PATCH 26/60] test(playwright): match traced application contracts --- .../e2e/Features/Glossary/GlossaryP3Tests.spec.ts | 6 +++++- .../e2e/Pages/SearchIndexApplication.spec.ts | 10 +--------- .../main/resources/ui/playwright/utils/dataQuality.ts | 2 +- .../resources/ui/playwright/utils/ontologyExplorer.ts | 9 --------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 50f506281722..84536086e555 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -791,7 +791,8 @@ test.describe('Glossary P3 Tests', () => { try { // Navigate directly to a non-existent glossary (without redirectToHomePage) - await page.goto(`/glossary/NonExistentGlossary_${Date.now()}`); + const invalidGlossaryPath = `/glossary/NonExistentGlossary_${Date.now()}`; + await page.goto(invalidGlossaryPath); await page.waitForLoadState('domcontentloaded'); await waitForAllLoadersToDisappear(page).catch(() => {}); @@ -807,9 +808,12 @@ test.describe('Glossary P3 Tests', () => { .getByRole('button', { name: 'Add', exact: true }) .first(); const glossarySidebar = page.locator('.left-panel-card'); + const wasRedirected = + new URL(page.url()).pathname !== invalidGlossaryPath; // Any of these states is acceptable for error handling const hasValidResponse = + wasRedirected || (await badMessage .first() .isVisible({ timeout: 10000 }) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 26b5dff06457..1d1e6ff2a18d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -12,14 +12,12 @@ */ import test, { expect, Page, Response } from '@playwright/test'; import { PLAYWRIGHT_BASIC_TEST_TAG_OBJ } from '../../constant/config'; -import { GlobalSettingOptions } from '../../constant/settings'; import { clickOutside, getApiContext, redirectToHomePage, toastNotification, } from '../../utils/common'; -import { settingClick } from '../../utils/sidebar'; // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); @@ -283,19 +281,13 @@ test.describe('Search Index Application', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { }, }); } - - await settingClick(page, GlobalSettingOptions.APPLICATIONS); }); await test.step('Verify last execution run', async () => { const statusAPI = page.waitForResponse( '/api/v1/apps/name/SearchIndexingApplication/status?offset=0&limit=1' ); - await page - .locator( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ) - .click(); + await page.goto('/settings/apps/SearchIndexingApplication'); const statusResponse = await statusAPI; expect(statusResponse.status()).toBe(200); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts index 1654fb8e07bd..9a4fc33bf016 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/dataQuality.ts @@ -383,7 +383,7 @@ export const fillAndSubmitBundleSuiteForm = async ( response .url() .includes('/api/v1/dataQuality/testCases/logicalTestCases/bulk') && - response.request().method() === 'POST' + response.request().method() === 'PUT' ); await page.getByTestId('submit-button').click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts index 36dc40b9dc18..d3aa8d8e2caf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/ontologyExplorer.ts @@ -28,16 +28,7 @@ export const DANGLING_GRAPH_NODE_ID = '00000000-0000-0000-0000-000000000000'; export async function applyGlossaryFilter(page: Page, glossaryId: string) { await page.getByTestId('search-dropdown-Glossary').click(); await page.getByTestId(glossaryId).click(); - const termsResponse = page - .waitForResponse( - (response) => - response.url().includes('/api/v1/glossaryTerms') && - response.status() === 200, - { timeout: 30000 } - ) - .catch(() => null); await page.getByTestId('update-btn').click(); - await termsResponse; } export async function navigateToOntologyExplorer(page: Page) { From 7796fff35f9046a845e53ed199175fdf10ed3e4d Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:56:53 -0700 Subject: [PATCH 27/60] test(playwright): wait for routed fallback state --- .../Features/Glossary/GlossaryP3Tests.spec.ts | 48 +++++++------------ .../e2e/Pages/SearchIndexApplication.spec.ts | 21 +++++--- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 84536086e555..5c63d67b8c80 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -808,36 +808,24 @@ test.describe('Glossary P3 Tests', () => { .getByRole('button', { name: 'Add', exact: true }) .first(); const glossarySidebar = page.locator('.left-panel-card'); - const wasRedirected = - new URL(page.url()).pathname !== invalidGlossaryPath; - - // Any of these states is acceptable for error handling - const hasValidResponse = - wasRedirected || - (await badMessage - .first() - .isVisible({ timeout: 10000 }) - .catch(() => false)) || - (await errorState - .first() - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await noDataPlaceholder - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await glossaryHeader - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await addGlossaryButton - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await addGlossaryListButton - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await glossarySidebar.isVisible({ timeout: 2000 }).catch(() => false)); - - // Verify the app handled the invalid URL (either error page or redirect) - expect(hasValidResponse).toBeTruthy(); + // locator.isVisible() is an immediate probe; its timeout option does not + // wait for a lazy route to finish mounting. Poll the complete set of + // accepted error/fallback states so the assertion observes the rendered + // route instead of the transient full-screen loader. + await expect + .poll( + async () => + new URL(page.url()).pathname !== invalidGlossaryPath || + (await badMessage.first().isVisible()) || + (await errorState.first().isVisible()) || + (await noDataPlaceholder.isVisible()) || + (await glossaryHeader.isVisible()) || + (await addGlossaryButton.isVisible()) || + (await addGlossaryListButton.isVisible()) || + (await glossarySidebar.isVisible()), + { timeout: 15_000 } + ) + .toBeTruthy(); } finally { await afterAction(); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 1d1e6ff2a18d..5feb668e1fc9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -115,9 +115,20 @@ const installSearchIndexApplication = async (page: Page) => { await getApplications; - await expect( - page.getByTestId('search-indexing-application-card') - ).toBeVisible(); + // Installed applications share the same pagination as the marketplace, so + // migration fixtures can move this card off the first page. Verify the + // installed application through its stable detail route instead. + const appResponse = page.waitForResponse( + (response) => + response + .url() + .includes('/api/v1/apps/name/SearchIndexingApplication') && + !response.url().includes('/status') && + response.request().method() === 'GET' + ); + await page.goto('/settings/apps/SearchIndexingApplication'); + expect((await appResponse).status()).toBe(200); + await expect(page.getByTestId('manage-button')).toBeVisible(); }; const verifyLastExecutionStatus = async (page: Page) => { @@ -438,10 +449,6 @@ test.describe('Search Index Application', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await test.step('Run application and rerun with table-only config', async () => { test.slow(true); // Test time shouldn't exceed while re-fetching the history API. - await page.click( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ); - const previousRunStartTime = await getLatestRunStartTime(page); const triggerPipelineResponse = page.waitForResponse( '/api/v1/apps/trigger/SearchIndexingApplication' From 514f22b9b4deadff7412e65daf37643a335c32e5 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 21:56:53 -0700 Subject: [PATCH 28/60] test(playwright): wait for routed fallback state --- .../Features/Glossary/GlossaryP3Tests.spec.ts | 48 +++++++------------ .../e2e/Pages/SearchIndexApplication.spec.ts | 21 +++++--- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts index 84536086e555..5c63d67b8c80 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Glossary/GlossaryP3Tests.spec.ts @@ -808,36 +808,24 @@ test.describe('Glossary P3 Tests', () => { .getByRole('button', { name: 'Add', exact: true }) .first(); const glossarySidebar = page.locator('.left-panel-card'); - const wasRedirected = - new URL(page.url()).pathname !== invalidGlossaryPath; - - // Any of these states is acceptable for error handling - const hasValidResponse = - wasRedirected || - (await badMessage - .first() - .isVisible({ timeout: 10000 }) - .catch(() => false)) || - (await errorState - .first() - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await noDataPlaceholder - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await glossaryHeader - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await addGlossaryButton - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await addGlossaryListButton - .isVisible({ timeout: 2000 }) - .catch(() => false)) || - (await glossarySidebar.isVisible({ timeout: 2000 }).catch(() => false)); - - // Verify the app handled the invalid URL (either error page or redirect) - expect(hasValidResponse).toBeTruthy(); + // locator.isVisible() is an immediate probe; its timeout option does not + // wait for a lazy route to finish mounting. Poll the complete set of + // accepted error/fallback states so the assertion observes the rendered + // route instead of the transient full-screen loader. + await expect + .poll( + async () => + new URL(page.url()).pathname !== invalidGlossaryPath || + (await badMessage.first().isVisible()) || + (await errorState.first().isVisible()) || + (await noDataPlaceholder.isVisible()) || + (await glossaryHeader.isVisible()) || + (await addGlossaryButton.isVisible()) || + (await addGlossaryListButton.isVisible()) || + (await glossarySidebar.isVisible()), + { timeout: 15_000 } + ) + .toBeTruthy(); } finally { await afterAction(); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 1d1e6ff2a18d..5feb668e1fc9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -115,9 +115,20 @@ const installSearchIndexApplication = async (page: Page) => { await getApplications; - await expect( - page.getByTestId('search-indexing-application-card') - ).toBeVisible(); + // Installed applications share the same pagination as the marketplace, so + // migration fixtures can move this card off the first page. Verify the + // installed application through its stable detail route instead. + const appResponse = page.waitForResponse( + (response) => + response + .url() + .includes('/api/v1/apps/name/SearchIndexingApplication') && + !response.url().includes('/status') && + response.request().method() === 'GET' + ); + await page.goto('/settings/apps/SearchIndexingApplication'); + expect((await appResponse).status()).toBe(200); + await expect(page.getByTestId('manage-button')).toBeVisible(); }; const verifyLastExecutionStatus = async (page: Page) => { @@ -438,10 +449,6 @@ test.describe('Search Index Application', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await test.step('Run application and rerun with table-only config', async () => { test.slow(true); // Test time shouldn't exceed while re-fetching the history API. - await page.click( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ); - const previousRunStartTime = await getLatestRunStartTime(page); const triggerPipelineResponse = page.waitForResponse( '/api/v1/apps/trigger/SearchIndexingApplication' From cb416b6edf7bdc5573bd4d0d0f4c5f043ca9ed0e Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 22:05:38 -0700 Subject: [PATCH 29/60] test(playwright): wait for certification controls --- .../main/resources/ui/playwright/utils/entity.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index ddada253f168..2192d8faddf4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -690,15 +690,10 @@ export const assignCertification = async ( ); await expect(async () => { - // The tag GET can finish just as an entity refresh remounts the controlled - // popover. In that race the shell stays visible but its certifications are - // reset to [], leaving no Radio.Group for the scroll helper to hover. Close - // and reopen to issue a fresh fetch, then retry the complete find operation. - if (!(await certificationCards.isVisible())) { - await closeCertificationPopover(page); - await page.getByTestId('edit-certification').click(); - await expect(certificationCards).toBeVisible({ timeout: 5_000 }); - } + // The popover shell becomes visible before its opening animation exposes + // the Radio.Group. Wait for that stable child instead of treating the + // transient hidden state as missing and toggling the popover closed again. + await expect(certificationCards).toBeVisible({ timeout: 5_000 }); await readElementInListWithScroll( page, From 13ec310c51c6b53a3205c497be73ca3191a6eabe Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 22:05:38 -0700 Subject: [PATCH 30/60] test(playwright): wait for certification controls --- .../main/resources/ui/playwright/utils/entity.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index ddada253f168..2192d8faddf4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -690,15 +690,10 @@ export const assignCertification = async ( ); await expect(async () => { - // The tag GET can finish just as an entity refresh remounts the controlled - // popover. In that race the shell stays visible but its certifications are - // reset to [], leaving no Radio.Group for the scroll helper to hover. Close - // and reopen to issue a fresh fetch, then retry the complete find operation. - if (!(await certificationCards.isVisible())) { - await closeCertificationPopover(page); - await page.getByTestId('edit-certification').click(); - await expect(certificationCards).toBeVisible({ timeout: 5_000 }); - } + // The popover shell becomes visible before its opening animation exposes + // the Radio.Group. Wait for that stable child instead of treating the + // transient hidden state as missing and toggling the popover closed again. + await expect(certificationCards).toBeVisible({ timeout: 5_000 }); await readElementInListWithScroll( page, From ef43d64984be70ece55596076b252a9d30a6f589 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 22:27:12 -0700 Subject: [PATCH 31/60] style(playwright): format search indexing route check --- .../ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 5feb668e1fc9..cb2a8cc135b9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -120,9 +120,7 @@ const installSearchIndexApplication = async (page: Page) => { // installed application through its stable detail route instead. const appResponse = page.waitForResponse( (response) => - response - .url() - .includes('/api/v1/apps/name/SearchIndexingApplication') && + response.url().includes('/api/v1/apps/name/SearchIndexingApplication') && !response.url().includes('/status') && response.request().method() === 'GET' ); From 3b935bc153fa82a712fdfb93f21ff57be7bf15e5 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 19 Aug 2026 22:27:12 -0700 Subject: [PATCH 32/60] style(playwright): format search indexing route check --- .../ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts index 5feb668e1fc9..cb2a8cc135b9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchIndexApplication.spec.ts @@ -120,9 +120,7 @@ const installSearchIndexApplication = async (page: Page) => { // installed application through its stable detail route instead. const appResponse = page.waitForResponse( (response) => - response - .url() - .includes('/api/v1/apps/name/SearchIndexingApplication') && + response.url().includes('/api/v1/apps/name/SearchIndexingApplication') && !response.url().includes('/status') && response.request().method() === 'GET' ); From 76cc03b8707926dfdc18cdba00feda06ecc0f45e Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 00:54:05 -0700 Subject: [PATCH 33/60] test(playwright): remove full-lane timing races --- .../Features/ContextCenterArticles.spec.ts | 35 ++++++++----- .../DomainWidgetFilter.spec.ts | 19 ++++--- .../e2e/Features/Tasks/TaskNavigation.spec.ts | 16 ++---- .../e2e/Pages/DomainUIInteractions.spec.ts | 49 +++++++------------ .../e2e/Pages/InputOutputPorts.spec.ts | 3 ++ .../e2e/Pages/SearchSettings.spec.ts | 8 ++- .../support/entity/DashboardDataModelClass.ts | 10 ++++ .../entity/ingestion/ServiceBaseClass.ts | 19 +++++-- .../playwright/support/glossary/Glossary.ts | 10 +++- .../playwright/utils/customizeLandingPage.ts | 6 ++- .../ui/playwright/utils/entityPanel.ts | 38 ++++++++++++-- .../resources/ui/playwright/utils/service.ts | 8 +-- 12 files changed, 144 insertions(+), 77 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts index 82c952ce0823..4b1491fd52c9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts @@ -88,6 +88,7 @@ import { const RELATED_QUICK_LINK_URL = 'https://docs.open-metadata.org'; const UPDATED_QUICK_LINK_URL = 'https://docs.open-metadata.org/quick-link'; const MIN_CARDS = 10; +const ARTICLE_LIST_PAGE_SIZE = 25; let DRAFT_ARTICLE_A_DISPLAY_NAME: string; let DRAFT_ARTICLE_B_DISPLAY_NAME: string; @@ -661,18 +662,30 @@ test.describe('Context Center Articles', () => { const cards = listing.locator('[data-testid^="knowledge-card-"]'); const initialCardCount = await cards.count(); - const observerElement = page.getByTestId('observer-element'); - const paginationResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages') && - response.url().includes('offset=') - ); - - await observerElement.scrollIntoViewIfNeeded(); - await paginationResponse; - await waitForAllLoadersToDisappear(page); + if (initialCardCount > ARTICLE_LIST_PAGE_SIZE) { + // Returning from the recently-viewed article can preserve the list's + // scroll position. In that case the observer has already fetched one or + // more additional pages, which itself proves pagination occurred. + expect(initialCardCount).toBeGreaterThan(ARTICLE_LIST_PAGE_SIZE); + } else { + const observerElement = page.getByTestId('observer-element'); + const paginationResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'GET' && + url.pathname === '/api/v1/contextCenter/pages' && + url.searchParams.get('sortBy') === 'updatedAt' && + Number(url.searchParams.get('offset')) > 0 + ); + }); - expect(await cards.count()).toBeGreaterThan(initialCardCount); + await observerElement.scrollIntoViewIfNeeded(); + const response = await paginationResponse; + expect(response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + expect(await cards.count()).toBeGreaterThan(initialCardCount); + } }); test('Left hierarchy pagination and expand collapse actions work', async ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts index 189e78d24378..75c1390c31b2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts @@ -14,6 +14,7 @@ import { expect, test } from '@playwright/test'; import { Domain } from '../../../support/domain/Domain'; import { PersonaClass } from '../../../support/persona/PersonaClass'; +import { UserClass } from '../../../support/user/UserClass'; import { createNewPage, redirectToExplorePage, @@ -32,19 +33,16 @@ test.use({ storageState: 'playwright/.auth/admin.json' }); const domainA = new Domain(); const domainB = new Domain(); const persona = new PersonaClass(); +const personaUser = new UserClass(); test.beforeAll('Setup pre-requests', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); await domainA.create(apiContext); await domainB.create(apiContext); - - const adminResponse = await apiContext.get( - '/api/v1/users/name/admin?fields=id' - ); - const adminData = await adminResponse.json(); - - await persona.create(apiContext, [adminData.id]); + await personaUser.create(apiContext); + await personaUser.setAdminRole(apiContext); + await persona.create(apiContext, [personaUser.responseData.id]); await afterAction(); }); @@ -53,10 +51,17 @@ test.afterAll('Cleanup', async ({ browser }) => { await domainA.delete(apiContext); await domainB.delete(apiContext); await persona.delete(apiContext); + await personaUser.delete(apiContext); await afterAction(); }); test.describe.serial('Domain Widget Filter', () => { + test.beforeEach(async ({ page }) => { + // The shared admin's default persona is mutated by several parallel widget + // specs. A dedicated admin user keeps this serial pair on its own layout. + await personaUser.login(page); + }); + test('Setup Domains widget on landing page', async ({ page }) => { test.slow(); await redirectToHomePage(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts index 879a7f93c600..79a577478185 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts @@ -474,18 +474,10 @@ test.describe('Task Navigation - URL Validation', () => { await page.goto('/table/TASK-00001'); await waitForPageLoaded(page); - // Should show 404 or "No data available" - const noData = page.getByText('No data available'); - const notFound = page.getByText('404'); - const pageNotFound = page.getByText('Page not found', { exact: false }); - - const isError = - (await noData.isVisible()) || - (await notFound.isVisible()) || - (await pageNotFound.isVisible()); - - // This URL pattern should result in an error/404 - expect(isError).toBe(true); + // PageNotFound has a stable root test id. Text matching was case-sensitive + // (the product renders "Page Not Found") and falsely rejected the correct + // 404 page. + await expect(page.getByTestId('no-page-found')).toBeVisible(); }); test('task detail page with valid task ID should work', async ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts index f3c4d6537021..b01352e879dd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts @@ -56,6 +56,12 @@ test.describe('Domain Owner Management', () => { try { await domain.create(apiContext); await user.create(apiContext); + await waitForSearchIndexed( + apiContext, + user.getUserName(), + 'user_search_index', + { timeout: 60_000, intervals: [2_000] } + ); await sidebarClick(page, SidebarItem.DOMAIN); await selectDomain(page, domain.data); @@ -72,43 +78,26 @@ test.describe('Domain Owner Management', () => { await page.getByRole('tab', { name: 'Users' }).click(); await waitForAllLoadersToDisappear(page); - // Search for user with retry mechanism (ES indexing can take time) + // The exact user is indexed before opening the picker, so one scoped + // request is sufficient and cannot be satisfied by the empty-query + // request emitted by clear(). const searchBar = page.getByTestId('owner-select-users-search-bar'); // Use displayName for selecting from list (UI shows displayName) const ownerItem = page.getByRole('listitem', { name: user.getUserDisplayName(), exact: true, }); - const maxRetries = 5; - - for (let retry = 0; retry < maxRetries; retry++) { - const searchResponse = page.waitForResponse( - (res) => - res.url().includes('/api/v1/search/query') && - res.url().includes('user') - ); - await searchBar.clear(); - // Search using name field - await searchBar.fill(user.getUserName()); - await searchResponse; - await waitForAllLoadersToDisappear(page); - - const isVisible = await ownerItem.isVisible().catch(() => false); - if (isVisible) { - break; - } - - if (retry < maxRetries - 1) { - await waitForSearchIndexed( - apiContext, - user.getUserName(), - 'user_search_index', - { timeout: 3000 } - ).catch(() => undefined); - } - } + const searchResponse = page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && + res.url().includes('user') && + decodeURIComponent(res.url()).includes(user.getUserName()) + ); + await searchBar.fill(user.getUserName()); + expect((await searchResponse).status()).toBe(200); + await waitForAllLoadersToDisappear(page); - await ownerItem.waitFor({ state: 'visible', timeout: 5000 }); + await ownerItem.waitFor({ state: 'visible', timeout: 30_000 }); await ownerItem.click(); // Click update button and wait for patch diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts index 3c9f411f1ebd..9d543e6c7481 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts @@ -1005,6 +1005,9 @@ test.describe('Input Output Ports', () => { }); test('Remove last port shows empty state', async ({ page }) => { + // waitForPortRow has its own 60-second eventual-consistency budget; keep + // the enclosing test alive long enough for that contract plus removal. + test.slow(); const dataProduct = new DataProduct([domain]); await test.step('Create data product with single input port via API', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index 9904ec8abe4f..e6829b558f8a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -478,8 +478,14 @@ test.describe('Search Settings', () => { // Always choose a value that differs from the current setting. A prior // interrupted run may already have persisted 5, in which case the Save // button correctly remains disabled and a hard-coded value deadlocks. - const changedNgramBoost = initialNgramBoost === 5 ? 6 : 5; + // A one-point delta can map to the same physical slider pixel and leave + // Save disabled. Move by a material amount while staying in range. + const changedNgramBoost = + initialNgramBoost <= 50 + ? Math.min(100, initialNgramBoost + 25) + : Math.max(0, initialNgramBoost - 25); await setSliderValue(page, 'field-weight-slider', changedNgramBoost); + await expect(page.getByTestId('save-btn')).toBeEnabled(); const saveResponse = page.waitForResponse( (r) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts index 2dfa33a46376..27d5d6529f50 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts @@ -195,6 +195,16 @@ export class DashboardDataModelClass extends EntityClass { data: this.entity, }); } + // A transient 5xx can be returned after the create transaction committed. + // The retry then correctly answers 409; reconcile that outcome with the + // exact entity instead of reporting a duplicate fixture as a test failure. + if (entityResponse.status() === 409) { + entityResponse = await apiContext.get( + `/api/v1/dashboard/datamodels/name/${encodeURIComponent( + `${this.service.name}.${this.entity.name}` + )}` + ); + } if (!entityResponse.ok()) { throw new Error( `Dashboard data model create failed (${entityResponse.status()}): ${await entityResponse.text()}` diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts index 4a8603cae938..6a7450a0616b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts @@ -21,6 +21,7 @@ import { import { startCase } from 'lodash'; import { MAX_CONSECUTIVE_ERRORS } from '../../../constant/service'; import { + closeFirstPopupAlert, descriptionBox, executeWithRetry, getApiContext, @@ -197,6 +198,18 @@ class ServiceBaseClass { } async addIngestionPipeline(page: Page) { + const clickWizardButton = async (testId: string) => { + await expect(async () => { + // Async job notifications are broadcast to every admin socket. During + // the full AUT lane several can stack over the wizard controls, so + // dismiss one per retry before attempting the click. + await closeFirstPopupAlert(page); + const button = page.getByTestId(testId); + await expect(button).toBeEnabled({ timeout: 2_000 }); + await button.click({ timeout: 2_000 }); + }).toPass({ timeout: 30_000, intervals: [250, 500, 1_000] }); + }; + await page.click('[role="tab"] [data-testid="agents"]'); const metadataTab = page.locator('[data-testid="metadata-sub-tab"]'); @@ -218,15 +231,15 @@ class ServiceBaseClass { await waitForIngestionWorkflowForm(page); await this.fillIngestionDetails(page); - await page.click('[data-testid="next-button"]'); + await clickWizardButton('next-button'); // Go back and data should persist - await page.click('[data-testid="previous-button"]'); + await clickWizardButton('previous-button'); await waitForIngestionWorkflowForm(page); await this.validateIngestionDetails(page); // Go Next - await page.click('[data-testid="next-button"]'); + await clickWizardButton('next-button'); await this.scheduleIngestion(page); await page.click('[data-testid="view-service-button"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts index 92c653471422..c5a45bb1a20b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts @@ -14,9 +14,10 @@ import { APIRequestContext, expect, Page } from '@playwright/test'; import { omit } from 'lodash'; import { getRandomFirstName, + redirectToHomePage, uuid, - visitGlossaryPage, } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { EntityReference, EntityTypeEndpoint, @@ -52,7 +53,12 @@ export class Glossary extends EntityClass { } async visitPage(page: Page) { - await visitGlossaryPage(page, this.responseData.displayName); + await redirectToHomePage(page); + await page.goto( + `/glossary/${encodeURIComponent(this.responseData.fullyQualifiedName)}`, + { waitUntil: 'domcontentloaded' } + ); + await waitForAllLoadersToDisappear(page); await expect(page.getByTestId('entity-header-display-name')).toHaveText( this.responseData.displayName diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 4b0259726136..c1dbc01b8122 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -272,9 +272,11 @@ export const waitForLandingPageWidget = async ( await revealLandingPageWidget(page, widgetKey); - await expect(widget).toBeVisible(); + await expect(widget).toBeVisible({ timeout: 60_000 }); - await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden(); + await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden({ + timeout: 60_000, + }); return widget; }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts index 3b1e6564b5e0..d354cd7d81c7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts @@ -95,10 +95,23 @@ export const openEntitySummaryPanel = async ({ dataAssetTypeLeftPanelTestId?: string; }) => { const runSearch = async () => { + const entryControl = + endpoint && ENDPOINT_TO_FILTER_MAP[endpoint] + ? page.getByTestId('global-search-selector') + : page.getByTestId('searchBox'); + const exploreIsReady = await entryControl + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + + // A slow route transition can leave the Explore shell unmounted. Return a + // retryable result instead of spending the page's full 60-second action + // timeout on a control that does not exist yet. + if (!exploreIsReady) { + return false; + } + if (endpoint && ENDPOINT_TO_FILTER_MAP[endpoint]) { - await page.getByTestId('global-search-selector').waitFor({ - state: 'visible', - }); await page.getByTestId('global-search-selector').click(); await page.getByTestId('global-search-select-dropdown').waitFor({ state: 'visible', @@ -107,6 +120,14 @@ export const openEntitySummaryPanel = async ({ return false; } } + const searchBoxReady = await page + .getByTestId('searchBox') + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!searchBoxReady) { + return false; + } const searchResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/query') ); @@ -158,8 +179,15 @@ export const openEntitySummaryPanel = async ({ .poll( async () => { if (hasSearched) { - await page.reload(); - await waitForAllLoadersToDisappear(page); + // Re-enter the canonical route on every attempt. A plain reload + // preserves an intermediate/failed route and can never restore the + // Explore controls that this helper needs. + const navigated = await redirectToExplorePage(page) + .then(() => true) + .catch(() => false); + if (!navigated) { + return false; + } } hasSearched = true; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts index a80c9c721cfc..9e992797fbce 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts @@ -37,12 +37,12 @@ export const visitServiceDetailsPage = async ( verifyHeader = false, visitChildrenTab = true ) => { - const serviceResponse = page.waitForResponse( - '/api/v1/services/*?fields=owners*' - ); await settingClick(page, service.type as SettingOptionsType); - await serviceResponse; await waitForAllLoadersToDisappear(page); + // The service list may be satisfied from the query cache, in which case no + // owners request is emitted. Gate on the rendered search control instead of + // an optional network round-trip. + await expect(page.getByTestId('searchbar')).toBeVisible({ timeout: 60_000 }); await searchServiceFromSettingPage(page, service.name); From 6da9f16d63b77f23e50b31b9f62cc0c1703fd3d7 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 00:57:28 -0700 Subject: [PATCH 34/60] test(playwright): verify exact indexed entity --- .../resources/ui/playwright/utils/polling.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts index 4e695b7cd492..eceafac8d13d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts @@ -42,14 +42,32 @@ export const waitForSearchIndexed = async ( const response = await apiContext.get( `/api/v1/search/query?q=${encodeURIComponent( entityFqn - )}&index=${index}&from=0&size=1` + )}&index=${index}&from=0&size=25` ); if (response.ok()) { const data = await response.json(); - const totalHits = data?.hits?.total?.value ?? data?.hits?.total ?? 0; + const hits = (data?.hits?.hits ?? []) as Array<{ + _id?: string; + _source?: { + fullyQualifiedName?: string; + id?: string; + name?: string; + }; + }>; - if (totalHits > 0) { + // Search is analyzed/fuzzy, so totalHits > 0 only proves that something + // matched the query. Under a populated AUT index that can be an older, + // similarly named entity while the new entity is still unindexed. + if ( + hits.some( + (hit) => + hit._id === entityFqn || + hit._source?.id === entityFqn || + hit._source?.name === entityFqn || + hit._source?.fullyQualifiedName === entityFqn + ) + ) { return; } } From 59d23f5992c201f21ab5571598d36c1f7bf361ec Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 00:54:05 -0700 Subject: [PATCH 35/60] test(playwright): remove full-lane timing races --- .../Features/ContextCenterArticles.spec.ts | 35 ++++++++----- .../DomainWidgetFilter.spec.ts | 19 ++++--- .../e2e/Features/Tasks/TaskNavigation.spec.ts | 16 ++---- .../e2e/Pages/DomainUIInteractions.spec.ts | 49 +++++++------------ .../e2e/Pages/InputOutputPorts.spec.ts | 3 ++ .../e2e/Pages/SearchSettings.spec.ts | 8 ++- .../support/entity/DashboardDataModelClass.ts | 10 ++++ .../entity/ingestion/ServiceBaseClass.ts | 19 +++++-- .../playwright/support/glossary/Glossary.ts | 10 +++- .../playwright/utils/customizeLandingPage.ts | 6 ++- .../ui/playwright/utils/entityPanel.ts | 38 ++++++++++++-- .../resources/ui/playwright/utils/service.ts | 8 +-- 12 files changed, 144 insertions(+), 77 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts index 82c952ce0823..4b1491fd52c9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArticles.spec.ts @@ -88,6 +88,7 @@ import { const RELATED_QUICK_LINK_URL = 'https://docs.open-metadata.org'; const UPDATED_QUICK_LINK_URL = 'https://docs.open-metadata.org/quick-link'; const MIN_CARDS = 10; +const ARTICLE_LIST_PAGE_SIZE = 25; let DRAFT_ARTICLE_A_DISPLAY_NAME: string; let DRAFT_ARTICLE_B_DISPLAY_NAME: string; @@ -661,18 +662,30 @@ test.describe('Context Center Articles', () => { const cards = listing.locator('[data-testid^="knowledge-card-"]'); const initialCardCount = await cards.count(); - const observerElement = page.getByTestId('observer-element'); - const paginationResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/contextCenter/pages') && - response.url().includes('offset=') - ); - - await observerElement.scrollIntoViewIfNeeded(); - await paginationResponse; - await waitForAllLoadersToDisappear(page); + if (initialCardCount > ARTICLE_LIST_PAGE_SIZE) { + // Returning from the recently-viewed article can preserve the list's + // scroll position. In that case the observer has already fetched one or + // more additional pages, which itself proves pagination occurred. + expect(initialCardCount).toBeGreaterThan(ARTICLE_LIST_PAGE_SIZE); + } else { + const observerElement = page.getByTestId('observer-element'); + const paginationResponse = page.waitForResponse((response) => { + const url = new URL(response.url()); + + return ( + response.request().method() === 'GET' && + url.pathname === '/api/v1/contextCenter/pages' && + url.searchParams.get('sortBy') === 'updatedAt' && + Number(url.searchParams.get('offset')) > 0 + ); + }); - expect(await cards.count()).toBeGreaterThan(initialCardCount); + await observerElement.scrollIntoViewIfNeeded(); + const response = await paginationResponse; + expect(response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + expect(await cards.count()).toBeGreaterThan(initialCardCount); + } }); test('Left hierarchy pagination and expand collapse actions work', async ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts index 189e78d24378..75c1390c31b2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts @@ -14,6 +14,7 @@ import { expect, test } from '@playwright/test'; import { Domain } from '../../../support/domain/Domain'; import { PersonaClass } from '../../../support/persona/PersonaClass'; +import { UserClass } from '../../../support/user/UserClass'; import { createNewPage, redirectToExplorePage, @@ -32,19 +33,16 @@ test.use({ storageState: 'playwright/.auth/admin.json' }); const domainA = new Domain(); const domainB = new Domain(); const persona = new PersonaClass(); +const personaUser = new UserClass(); test.beforeAll('Setup pre-requests', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); await domainA.create(apiContext); await domainB.create(apiContext); - - const adminResponse = await apiContext.get( - '/api/v1/users/name/admin?fields=id' - ); - const adminData = await adminResponse.json(); - - await persona.create(apiContext, [adminData.id]); + await personaUser.create(apiContext); + await personaUser.setAdminRole(apiContext); + await persona.create(apiContext, [personaUser.responseData.id]); await afterAction(); }); @@ -53,10 +51,17 @@ test.afterAll('Cleanup', async ({ browser }) => { await domainA.delete(apiContext); await domainB.delete(apiContext); await persona.delete(apiContext); + await personaUser.delete(apiContext); await afterAction(); }); test.describe.serial('Domain Widget Filter', () => { + test.beforeEach(async ({ page }) => { + // The shared admin's default persona is mutated by several parallel widget + // specs. A dedicated admin user keeps this serial pair on its own layout. + await personaUser.login(page); + }); + test('Setup Domains widget on landing page', async ({ page }) => { test.slow(); await redirectToHomePage(page); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts index 879a7f93c600..79a577478185 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts @@ -474,18 +474,10 @@ test.describe('Task Navigation - URL Validation', () => { await page.goto('/table/TASK-00001'); await waitForPageLoaded(page); - // Should show 404 or "No data available" - const noData = page.getByText('No data available'); - const notFound = page.getByText('404'); - const pageNotFound = page.getByText('Page not found', { exact: false }); - - const isError = - (await noData.isVisible()) || - (await notFound.isVisible()) || - (await pageNotFound.isVisible()); - - // This URL pattern should result in an error/404 - expect(isError).toBe(true); + // PageNotFound has a stable root test id. Text matching was case-sensitive + // (the product renders "Page Not Found") and falsely rejected the correct + // 404 page. + await expect(page.getByTestId('no-page-found')).toBeVisible(); }); test('task detail page with valid task ID should work', async ({ diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts index f3c4d6537021..b01352e879dd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DomainUIInteractions.spec.ts @@ -56,6 +56,12 @@ test.describe('Domain Owner Management', () => { try { await domain.create(apiContext); await user.create(apiContext); + await waitForSearchIndexed( + apiContext, + user.getUserName(), + 'user_search_index', + { timeout: 60_000, intervals: [2_000] } + ); await sidebarClick(page, SidebarItem.DOMAIN); await selectDomain(page, domain.data); @@ -72,43 +78,26 @@ test.describe('Domain Owner Management', () => { await page.getByRole('tab', { name: 'Users' }).click(); await waitForAllLoadersToDisappear(page); - // Search for user with retry mechanism (ES indexing can take time) + // The exact user is indexed before opening the picker, so one scoped + // request is sufficient and cannot be satisfied by the empty-query + // request emitted by clear(). const searchBar = page.getByTestId('owner-select-users-search-bar'); // Use displayName for selecting from list (UI shows displayName) const ownerItem = page.getByRole('listitem', { name: user.getUserDisplayName(), exact: true, }); - const maxRetries = 5; - - for (let retry = 0; retry < maxRetries; retry++) { - const searchResponse = page.waitForResponse( - (res) => - res.url().includes('/api/v1/search/query') && - res.url().includes('user') - ); - await searchBar.clear(); - // Search using name field - await searchBar.fill(user.getUserName()); - await searchResponse; - await waitForAllLoadersToDisappear(page); - - const isVisible = await ownerItem.isVisible().catch(() => false); - if (isVisible) { - break; - } - - if (retry < maxRetries - 1) { - await waitForSearchIndexed( - apiContext, - user.getUserName(), - 'user_search_index', - { timeout: 3000 } - ).catch(() => undefined); - } - } + const searchResponse = page.waitForResponse( + (res) => + res.url().includes('/api/v1/search/query') && + res.url().includes('user') && + decodeURIComponent(res.url()).includes(user.getUserName()) + ); + await searchBar.fill(user.getUserName()); + expect((await searchResponse).status()).toBe(200); + await waitForAllLoadersToDisappear(page); - await ownerItem.waitFor({ state: 'visible', timeout: 5000 }); + await ownerItem.waitFor({ state: 'visible', timeout: 30_000 }); await ownerItem.click(); // Click update button and wait for patch diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts index 3c9f411f1ebd..9d543e6c7481 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/InputOutputPorts.spec.ts @@ -1005,6 +1005,9 @@ test.describe('Input Output Ports', () => { }); test('Remove last port shows empty state', async ({ page }) => { + // waitForPortRow has its own 60-second eventual-consistency budget; keep + // the enclosing test alive long enough for that contract plus removal. + test.slow(); const dataProduct = new DataProduct([domain]); await test.step('Create data product with single input port via API', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index 9904ec8abe4f..e6829b558f8a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -478,8 +478,14 @@ test.describe('Search Settings', () => { // Always choose a value that differs from the current setting. A prior // interrupted run may already have persisted 5, in which case the Save // button correctly remains disabled and a hard-coded value deadlocks. - const changedNgramBoost = initialNgramBoost === 5 ? 6 : 5; + // A one-point delta can map to the same physical slider pixel and leave + // Save disabled. Move by a material amount while staying in range. + const changedNgramBoost = + initialNgramBoost <= 50 + ? Math.min(100, initialNgramBoost + 25) + : Math.max(0, initialNgramBoost - 25); await setSliderValue(page, 'field-weight-slider', changedNgramBoost); + await expect(page.getByTestId('save-btn')).toBeEnabled(); const saveResponse = page.waitForResponse( (r) => diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts index 2dfa33a46376..27d5d6529f50 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/DashboardDataModelClass.ts @@ -195,6 +195,16 @@ export class DashboardDataModelClass extends EntityClass { data: this.entity, }); } + // A transient 5xx can be returned after the create transaction committed. + // The retry then correctly answers 409; reconcile that outcome with the + // exact entity instead of reporting a duplicate fixture as a test failure. + if (entityResponse.status() === 409) { + entityResponse = await apiContext.get( + `/api/v1/dashboard/datamodels/name/${encodeURIComponent( + `${this.service.name}.${this.entity.name}` + )}` + ); + } if (!entityResponse.ok()) { throw new Error( `Dashboard data model create failed (${entityResponse.status()}): ${await entityResponse.text()}` diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts index 4a8603cae938..6a7450a0616b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ingestion/ServiceBaseClass.ts @@ -21,6 +21,7 @@ import { import { startCase } from 'lodash'; import { MAX_CONSECUTIVE_ERRORS } from '../../../constant/service'; import { + closeFirstPopupAlert, descriptionBox, executeWithRetry, getApiContext, @@ -197,6 +198,18 @@ class ServiceBaseClass { } async addIngestionPipeline(page: Page) { + const clickWizardButton = async (testId: string) => { + await expect(async () => { + // Async job notifications are broadcast to every admin socket. During + // the full AUT lane several can stack over the wizard controls, so + // dismiss one per retry before attempting the click. + await closeFirstPopupAlert(page); + const button = page.getByTestId(testId); + await expect(button).toBeEnabled({ timeout: 2_000 }); + await button.click({ timeout: 2_000 }); + }).toPass({ timeout: 30_000, intervals: [250, 500, 1_000] }); + }; + await page.click('[role="tab"] [data-testid="agents"]'); const metadataTab = page.locator('[data-testid="metadata-sub-tab"]'); @@ -218,15 +231,15 @@ class ServiceBaseClass { await waitForIngestionWorkflowForm(page); await this.fillIngestionDetails(page); - await page.click('[data-testid="next-button"]'); + await clickWizardButton('next-button'); // Go back and data should persist - await page.click('[data-testid="previous-button"]'); + await clickWizardButton('previous-button'); await waitForIngestionWorkflowForm(page); await this.validateIngestionDetails(page); // Go Next - await page.click('[data-testid="next-button"]'); + await clickWizardButton('next-button'); await this.scheduleIngestion(page); await page.click('[data-testid="view-service-button"]'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts index 92c653471422..c5a45bb1a20b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/glossary/Glossary.ts @@ -14,9 +14,10 @@ import { APIRequestContext, expect, Page } from '@playwright/test'; import { omit } from 'lodash'; import { getRandomFirstName, + redirectToHomePage, uuid, - visitGlossaryPage, } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { EntityReference, EntityTypeEndpoint, @@ -52,7 +53,12 @@ export class Glossary extends EntityClass { } async visitPage(page: Page) { - await visitGlossaryPage(page, this.responseData.displayName); + await redirectToHomePage(page); + await page.goto( + `/glossary/${encodeURIComponent(this.responseData.fullyQualifiedName)}`, + { waitUntil: 'domcontentloaded' } + ); + await waitForAllLoadersToDisappear(page); await expect(page.getByTestId('entity-header-display-name')).toHaveText( this.responseData.displayName diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 4b0259726136..c1dbc01b8122 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -272,9 +272,11 @@ export const waitForLandingPageWidget = async ( await revealLandingPageWidget(page, widgetKey); - await expect(widget).toBeVisible(); + await expect(widget).toBeVisible({ timeout: 60_000 }); - await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden(); + await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden({ + timeout: 60_000, + }); return widget; }; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts index 3b1e6564b5e0..d354cd7d81c7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entityPanel.ts @@ -95,10 +95,23 @@ export const openEntitySummaryPanel = async ({ dataAssetTypeLeftPanelTestId?: string; }) => { const runSearch = async () => { + const entryControl = + endpoint && ENDPOINT_TO_FILTER_MAP[endpoint] + ? page.getByTestId('global-search-selector') + : page.getByTestId('searchBox'); + const exploreIsReady = await entryControl + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + + // A slow route transition can leave the Explore shell unmounted. Return a + // retryable result instead of spending the page's full 60-second action + // timeout on a control that does not exist yet. + if (!exploreIsReady) { + return false; + } + if (endpoint && ENDPOINT_TO_FILTER_MAP[endpoint]) { - await page.getByTestId('global-search-selector').waitFor({ - state: 'visible', - }); await page.getByTestId('global-search-selector').click(); await page.getByTestId('global-search-select-dropdown').waitFor({ state: 'visible', @@ -107,6 +120,14 @@ export const openEntitySummaryPanel = async ({ return false; } } + const searchBoxReady = await page + .getByTestId('searchBox') + .waitFor({ state: 'visible', timeout: 10_000 }) + .then(() => true) + .catch(() => false); + if (!searchBoxReady) { + return false; + } const searchResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/query') ); @@ -158,8 +179,15 @@ export const openEntitySummaryPanel = async ({ .poll( async () => { if (hasSearched) { - await page.reload(); - await waitForAllLoadersToDisappear(page); + // Re-enter the canonical route on every attempt. A plain reload + // preserves an intermediate/failed route and can never restore the + // Explore controls that this helper needs. + const navigated = await redirectToExplorePage(page) + .then(() => true) + .catch(() => false); + if (!navigated) { + return false; + } } hasSearched = true; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts index a80c9c721cfc..9e992797fbce 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/service.ts @@ -37,12 +37,12 @@ export const visitServiceDetailsPage = async ( verifyHeader = false, visitChildrenTab = true ) => { - const serviceResponse = page.waitForResponse( - '/api/v1/services/*?fields=owners*' - ); await settingClick(page, service.type as SettingOptionsType); - await serviceResponse; await waitForAllLoadersToDisappear(page); + // The service list may be satisfied from the query cache, in which case no + // owners request is emitted. Gate on the rendered search control instead of + // an optional network round-trip. + await expect(page.getByTestId('searchbar')).toBeVisible({ timeout: 60_000 }); await searchServiceFromSettingPage(page, service.name); From 4a2a438a53abd79e36be8e5da9844807fcc4d406 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 00:57:28 -0700 Subject: [PATCH 36/60] test(playwright): verify exact indexed entity --- .../resources/ui/playwright/utils/polling.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts index 4e695b7cd492..eceafac8d13d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts @@ -42,14 +42,32 @@ export const waitForSearchIndexed = async ( const response = await apiContext.get( `/api/v1/search/query?q=${encodeURIComponent( entityFqn - )}&index=${index}&from=0&size=1` + )}&index=${index}&from=0&size=25` ); if (response.ok()) { const data = await response.json(); - const totalHits = data?.hits?.total?.value ?? data?.hits?.total ?? 0; + const hits = (data?.hits?.hits ?? []) as Array<{ + _id?: string; + _source?: { + fullyQualifiedName?: string; + id?: string; + name?: string; + }; + }>; - if (totalHits > 0) { + // Search is analyzed/fuzzy, so totalHits > 0 only proves that something + // matched the query. Under a populated AUT index that can be an older, + // similarly named entity while the new entity is still unindexed. + if ( + hits.some( + (hit) => + hit._id === entityFqn || + hit._source?.id === entityFqn || + hit._source?.name === entityFqn || + hit._source?.fullyQualifiedName === entityFqn + ) + ) { return; } } From 5b3cac3209ca5a95a3cccd4b616a664a6750744b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:17:21 -0700 Subject: [PATCH 37/60] test(playwright): avoid pipeline status race --- .../Flow/ServiceCreationPermissions.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts index d84452effb53..08f73c80c8bc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts @@ -117,25 +117,24 @@ const openPipelineActions = async (page: Page) => { // AgentOverflowMenu recomputes its item list from the `permissions` prop on // every render, but the async per-FQN permission fetch can still be in - // flight when the menu is first opened — some items (edit-gated: redeploy, - // edit, pause/resume) are briefly absent. Close and reopen until the - // permission-gated items are present instead of polling a single stale - // open instance. + // flight when the menu is first opened. Wait for Edit: unlike Re-deploy, it + // is not conditional on whether the pipeline triggered by the previous test + // is still running. await expect .poll( async () => { await actionButton.click(); await actionsDropdown.waitFor(); - const hasReDeploy = await actionsDropdown - .getByTestId('re-deploy-button') + const hasEdit = await actionsDropdown + .getByTestId('edit-button') .isVisible() .catch(() => false); - if (!hasReDeploy) { + if (!hasEdit) { await page.keyboard.press('Escape'); await actionsDropdown.waitFor({ state: 'hidden' }); } - return hasReDeploy; + return hasEdit; }, { intervals: [1_000, 2_000, 3_000], timeout: 30_000 } ) @@ -672,9 +671,6 @@ test.describe( const actionsDropdown = page.getByTestId('actions-dropdown'); await expect(actionsDropdown.getByTestId('edit-button')).toBeVisible(); - await expect( - actionsDropdown.getByTestId('re-deploy-button') - ).toBeVisible(); await expect( getAgentCard(page, ingestionPipelineName).getByTestId( 'run-agent-button' From 340a1f971bd48831e39263c3d729021fcee701c2 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:17:21 -0700 Subject: [PATCH 38/60] test(playwright): avoid pipeline status race --- .../Flow/ServiceCreationPermissions.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts index d84452effb53..08f73c80c8bc 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ServiceCreationPermissions.spec.ts @@ -117,25 +117,24 @@ const openPipelineActions = async (page: Page) => { // AgentOverflowMenu recomputes its item list from the `permissions` prop on // every render, but the async per-FQN permission fetch can still be in - // flight when the menu is first opened — some items (edit-gated: redeploy, - // edit, pause/resume) are briefly absent. Close and reopen until the - // permission-gated items are present instead of polling a single stale - // open instance. + // flight when the menu is first opened. Wait for Edit: unlike Re-deploy, it + // is not conditional on whether the pipeline triggered by the previous test + // is still running. await expect .poll( async () => { await actionButton.click(); await actionsDropdown.waitFor(); - const hasReDeploy = await actionsDropdown - .getByTestId('re-deploy-button') + const hasEdit = await actionsDropdown + .getByTestId('edit-button') .isVisible() .catch(() => false); - if (!hasReDeploy) { + if (!hasEdit) { await page.keyboard.press('Escape'); await actionsDropdown.waitFor({ state: 'hidden' }); } - return hasReDeploy; + return hasEdit; }, { intervals: [1_000, 2_000, 3_000], timeout: 30_000 } ) @@ -672,9 +671,6 @@ test.describe( const actionsDropdown = page.getByTestId('actions-dropdown'); await expect(actionsDropdown.getByTestId('edit-button')).toBeVisible(); - await expect( - actionsDropdown.getByTestId('re-deploy-button') - ).toBeVisible(); await expect( getAgentCard(page, ingestionPipelineName).getByTestId( 'run-agent-button' From 4b4fb86227a66ab354973935d63c4f9cab587c8b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:22:05 -0700 Subject: [PATCH 39/60] test(playwright): fix focused auth and invalid-route checks --- .../LandingPageWidgets/DomainWidgetFilter.spec.ts | 4 ++-- .../e2e/Features/Tasks/TaskNavigation.spec.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts index 75c1390c31b2..7f2ff1bc4616 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts @@ -28,8 +28,6 @@ import { import { selectDomainFromNavbar } from '../../../utils/domain'; import { waitForAllLoadersToDisappear } from '../../../utils/entity'; -test.use({ storageState: 'playwright/.auth/admin.json' }); - const domainA = new Domain(); const domainB = new Domain(); const persona = new PersonaClass(); @@ -59,6 +57,8 @@ test.describe.serial('Domain Widget Filter', () => { test.beforeEach(async ({ page }) => { // The shared admin's default persona is mutated by several parallel widget // specs. A dedicated admin user keeps this serial pair on its own layout. + // Keep the default context unauthenticated so login does not inherit the + // shared admin storage state and immediately redirect away from /signin. await personaUser.login(page); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts index 79a577478185..89f842347671 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts @@ -474,10 +474,16 @@ test.describe('Task Navigation - URL Validation', () => { await page.goto('/table/TASK-00001'); await waitForPageLoaded(page); - // PageNotFound has a stable root test id. Text matching was case-sensitive - // (the product renders "Page Not Found") and falsely rejected the correct - // 404 page. - await expect(page.getByTestId('no-page-found')).toBeVisible(); + // Depending on the entity-page route, an invalid FQN renders either the + // full PageNotFound view or the entity shell's stable empty placeholder. + // Both are valid error states; the route must not render table data. + await expect( + page + .locator( + '[data-testid="no-page-found"]:visible, [data-testid="no-data-placeholder"]:visible' + ) + .first() + ).toBeVisible(); }); test('task detail page with valid task ID should work', async ({ From 0212e1c3f3f25bf63dc78d0cf12456f5c39e7c74 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:22:05 -0700 Subject: [PATCH 40/60] test(playwright): fix focused auth and invalid-route checks --- .../LandingPageWidgets/DomainWidgetFilter.spec.ts | 4 ++-- .../e2e/Features/Tasks/TaskNavigation.spec.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts index 75c1390c31b2..7f2ff1bc4616 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/LandingPageWidgets/DomainWidgetFilter.spec.ts @@ -28,8 +28,6 @@ import { import { selectDomainFromNavbar } from '../../../utils/domain'; import { waitForAllLoadersToDisappear } from '../../../utils/entity'; -test.use({ storageState: 'playwright/.auth/admin.json' }); - const domainA = new Domain(); const domainB = new Domain(); const persona = new PersonaClass(); @@ -59,6 +57,8 @@ test.describe.serial('Domain Widget Filter', () => { test.beforeEach(async ({ page }) => { // The shared admin's default persona is mutated by several parallel widget // specs. A dedicated admin user keeps this serial pair on its own layout. + // Keep the default context unauthenticated so login does not inherit the + // shared admin storage state and immediately redirect away from /signin. await personaUser.login(page); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts index 79a577478185..89f842347671 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskNavigation.spec.ts @@ -474,10 +474,16 @@ test.describe('Task Navigation - URL Validation', () => { await page.goto('/table/TASK-00001'); await waitForPageLoaded(page); - // PageNotFound has a stable root test id. Text matching was case-sensitive - // (the product renders "Page Not Found") and falsely rejected the correct - // 404 page. - await expect(page.getByTestId('no-page-found')).toBeVisible(); + // Depending on the entity-page route, an invalid FQN renders either the + // full PageNotFound view or the entity shell's stable empty placeholder. + // Both are valid error states; the route must not render table data. + await expect( + page + .locator( + '[data-testid="no-page-found"]:visible, [data-testid="no-data-placeholder"]:visible' + ) + .first() + ).toBeVisible(); }); test('task detail page with valid task ID should work', async ({ From fb516328dac11e92223bbd85b5516a7bbbdcaf91 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:24:49 -0700 Subject: [PATCH 41/60] test(playwright): poll deleted document search exactly --- .../e2e/Features/ContextCenterArchive.spec.ts | 80 +++++++++++++------ 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts index 8f3a39292296..6fcd78241599 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts @@ -342,9 +342,9 @@ test.describe('Context Center - Archive Page', () => { }); }); -// ─── Suite: Folder delete — file absent from search and archive ──────────────── +// ─── Suite: Folder delete — file absent from search, present in archive ─────── -test.describe('Context Center - Folder Delete: file absent from search and archive', () => { +test.describe('Context Center - Folder Delete: file absent from search and present in archive', () => { let folder: ContextCenterFolder; let documentId = ''; const folderName = `folder-delete-test-${uuid()}`; @@ -372,7 +372,7 @@ test.describe('Context Center - Folder Delete: file absent from search and archi await redirectToHomePage(page); }); - test('file in deleted folder is absent from search and not added to archive', async ({ + test('file in deleted folder is absent from search and added to archive', async ({ browser, page, }) => { @@ -440,34 +440,62 @@ test.describe('Context Center - Folder Delete: file absent from search and archi // ── 5. File is absent from documents search ────────────────────────────── await test.step('file is no longer visible in documents search after folder delete', async () => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); const searchInput = getDocumentSearchInput(page); - await expect - .poll( - async () => { - const searchResPromise = page.waitForResponse( - (res) => - res.url().includes('/api/v1/search/query') && - res.url().includes('index=contextFile') - ); - await searchInput.fill(''); - await searchInput.fill(documentFileName); - await searchResPromise; + try { + // Poll the API directly. Clearing and immediately refilling the debounced UI input with + // the same final value does not issue a second request, so the previous implementation + // only checked the index once immediately after deletion and then waited for a response + // that could never arrive. + await expect + .poll( + async () => { + const response = await apiContext.get('/api/v1/search/query', { + params: { + q: documentFileName, + index: 'contextFile', + from: 0, + size: 10, + deleted: false, + }, + }); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + return (body?.hits?.hits ?? []).some( + (hit: { _id?: string; _source?: { id?: string } }) => + hit._id === documentId || hit._source?.id === documentId + ); + }, + { + intervals: [3000, 5000, 10000], + message: `File ${documentFileName} still visible in search after its folder was deleted`, + timeout: 60000, + } + ) + .toBe(false); + } finally { + await afterAction(); + } - return getDocumentRowByName(page, documentFileName) - .isVisible() - .catch(() => false); - }, - { - intervals: [3000, 5000, 10000], - message: `File ${documentFileName} still visible in search after its folder was deleted`, - timeout: 60000, - } - ) - .toBe(false); + const searchResPromise = page.waitForResponse((res) => { + const url = new URL(res.url()); + + return ( + url.pathname.includes('/api/v1/search/query') && + url.searchParams.get('index') === 'contextFile' && + url.searchParams.get('q') === documentFileName + ); + }); + await searchInput.fill(documentFileName); + await searchResPromise; + await expect(getDocumentRowByName(page, documentFileName)).toBeHidden(); }); - // ── 6. Archive page UI — file row is absent ─────────────────────────────── + // ── 6. Archive page UI — soft-deleted file row is present ──────────────── await test.step('file should be visibile in the archive page', async () => { const { apiContext, afterAction } = await getDefaultAdminAPIContext( From e0a77d176fa8854ba61d3fd747e6c59d63a4ca6f Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:24:49 -0700 Subject: [PATCH 42/60] test(playwright): poll deleted document search exactly --- .../e2e/Features/ContextCenterArchive.spec.ts | 80 +++++++++++++------ 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts index 8f3a39292296..6fcd78241599 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts @@ -342,9 +342,9 @@ test.describe('Context Center - Archive Page', () => { }); }); -// ─── Suite: Folder delete — file absent from search and archive ──────────────── +// ─── Suite: Folder delete — file absent from search, present in archive ─────── -test.describe('Context Center - Folder Delete: file absent from search and archive', () => { +test.describe('Context Center - Folder Delete: file absent from search and present in archive', () => { let folder: ContextCenterFolder; let documentId = ''; const folderName = `folder-delete-test-${uuid()}`; @@ -372,7 +372,7 @@ test.describe('Context Center - Folder Delete: file absent from search and archi await redirectToHomePage(page); }); - test('file in deleted folder is absent from search and not added to archive', async ({ + test('file in deleted folder is absent from search and added to archive', async ({ browser, page, }) => { @@ -440,34 +440,62 @@ test.describe('Context Center - Folder Delete: file absent from search and archi // ── 5. File is absent from documents search ────────────────────────────── await test.step('file is no longer visible in documents search after folder delete', async () => { + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser + ); const searchInput = getDocumentSearchInput(page); - await expect - .poll( - async () => { - const searchResPromise = page.waitForResponse( - (res) => - res.url().includes('/api/v1/search/query') && - res.url().includes('index=contextFile') - ); - await searchInput.fill(''); - await searchInput.fill(documentFileName); - await searchResPromise; + try { + // Poll the API directly. Clearing and immediately refilling the debounced UI input with + // the same final value does not issue a second request, so the previous implementation + // only checked the index once immediately after deletion and then waited for a response + // that could never arrive. + await expect + .poll( + async () => { + const response = await apiContext.get('/api/v1/search/query', { + params: { + q: documentFileName, + index: 'contextFile', + from: 0, + size: 10, + deleted: false, + }, + }); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + + return (body?.hits?.hits ?? []).some( + (hit: { _id?: string; _source?: { id?: string } }) => + hit._id === documentId || hit._source?.id === documentId + ); + }, + { + intervals: [3000, 5000, 10000], + message: `File ${documentFileName} still visible in search after its folder was deleted`, + timeout: 60000, + } + ) + .toBe(false); + } finally { + await afterAction(); + } - return getDocumentRowByName(page, documentFileName) - .isVisible() - .catch(() => false); - }, - { - intervals: [3000, 5000, 10000], - message: `File ${documentFileName} still visible in search after its folder was deleted`, - timeout: 60000, - } - ) - .toBe(false); + const searchResPromise = page.waitForResponse((res) => { + const url = new URL(res.url()); + + return ( + url.pathname.includes('/api/v1/search/query') && + url.searchParams.get('index') === 'contextFile' && + url.searchParams.get('q') === documentFileName + ); + }); + await searchInput.fill(documentFileName); + await searchResPromise; + await expect(getDocumentRowByName(page, documentFileName)).toBeHidden(); }); - // ── 6. Archive page UI — file row is absent ─────────────────────────────── + // ── 6. Archive page UI — soft-deleted file row is present ──────────────── await test.step('file should be visibile in the archive page', async () => { const { apiContext, afterAction } = await getDefaultAdminAPIContext( From f923065d1f629de6c38e3fe0c121dd9e05a5250b Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:41:18 -0700 Subject: [PATCH 43/60] test(playwright): reveal late landing widgets --- .../playwright/utils/customizeLandingPage.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index c1dbc01b8122..463bf403f009 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -270,9 +270,24 @@ export const waitForLandingPageWidget = async ( ): Promise => { const widget = page.getByTestId(widgetKey); - await revealLandingPageWidget(page, widgetKey); + // The persona layout can finish loading immediately after the first slot lookup. If the + // widget is below the fold, a one-shot lookup misses that newly attached slot and the + // DeferredWidget never intersects the viewport, so waiting on the child alone deadlocks. + // Re-run the reveal step until the slot can be scrolled and its child mounts. + await expect + .poll( + async () => { + await revealLandingPageWidget(page, widgetKey); - await expect(widget).toBeVisible({ timeout: 60_000 }); + return widget.isVisible().catch(() => false); + }, + { + intervals: [250, 500, 1_000], + message: `Landing page widget ${widgetKey} did not mount`, + timeout: 60_000, + } + ) + .toBe(true); await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden({ timeout: 60_000, From 13914b10038f5c70672a79b12ac4139fea7c4bf9 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 01:41:18 -0700 Subject: [PATCH 44/60] test(playwright): reveal late landing widgets --- .../playwright/utils/customizeLandingPage.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index c1dbc01b8122..463bf403f009 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -270,9 +270,24 @@ export const waitForLandingPageWidget = async ( ): Promise => { const widget = page.getByTestId(widgetKey); - await revealLandingPageWidget(page, widgetKey); + // The persona layout can finish loading immediately after the first slot lookup. If the + // widget is below the fold, a one-shot lookup misses that newly attached slot and the + // DeferredWidget never intersects the viewport, so waiting on the child alone deadlocks. + // Re-run the reveal step until the slot can be scrolled and its child mounts. + await expect + .poll( + async () => { + await revealLandingPageWidget(page, widgetKey); - await expect(widget).toBeVisible({ timeout: 60_000 }); + return widget.isVisible().catch(() => false); + }, + { + intervals: [250, 500, 1_000], + message: `Landing page widget ${widgetKey} did not mount`, + timeout: 60_000, + } + ) + .toBe(true); await expect(widget.getByTestId('entity-list-skeleton')).toBeHidden({ timeout: 60_000, From 598642d565568fab59e822104ca8dd0ad917c168 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 12:03:23 -0700 Subject: [PATCH 45/60] test(playwright): fix exact search and toast races --- .../Features/OntologyExplorerFilters.spec.ts | 35 +++++++++++++------ .../resources/ui/playwright/utils/polling.ts | 29 ++++++++------- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts index d5059522c969..230d2cf62a00 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyExplorerFilters.spec.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { expect, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import { Glossary } from '../../support/glossary/Glossary'; import { GlossaryTerm } from '../../support/glossary/GlossaryTerm'; import { closeFirstPopupAlert } from '../../utils/common'; @@ -37,6 +37,26 @@ const glossary2 = new Glossary(); const term3 = new GlossaryTerm(glossary2); const term4 = new GlossaryTerm(glossary2); +const switchToModelMode = async (page: Page) => { + const modelTab = page.getByRole('tab', { name: 'Model' }); + + // Async job notifications are broadcast to every admin socket. Full AUT + // runs can therefore stack another worker's toasts over these bottom-aligned + // tabs. Dismiss one per attempt and retry the real user click until the mode + // change is committed, instead of bypassing actionability with a forced click. + await expect(async () => { + if ((await modelTab.getAttribute('aria-selected')) === 'true') { + return; + } + + await closeFirstPopupAlert(page); + await modelTab.click({ timeout: 2_000 }); + await expect(modelTab).toHaveAttribute('aria-selected', 'true', { + timeout: 2_000, + }); + }).toPass({ timeout: 30_000, intervals: [250, 500, 1_000] }); +}; + test.describe('Ontology Explorer - Filters and Tabs', () => { test.beforeAll(async ({ browser }) => { const { page, apiContext } = await createApiContext(browser); @@ -267,12 +287,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); - await closeFirstPopupAlert(page); - await page.getByRole('tab', { name: 'Model' }).click(); - await expect(page.getByRole('tab', { name: 'Model' })).toHaveAttribute( - 'aria-selected', - 'true' - ); + await switchToModelMode(page); }); test('should show graph stats after switching to Data mode', async ({ @@ -302,8 +317,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await expect(page.getByTestId('ontology-clear-all-btn')).toBeVisible(); - await closeFirstPopupAlert(page); - await page.getByRole('tab', { name: 'Model' }).click(); + await switchToModelMode(page); await waitForGraphLoaded(page); await expect(stats).toContainText('2 Terms'); }); @@ -403,8 +417,7 @@ test.describe('Ontology Explorer - Filters and Tabs', () => { await waitForGraphLoaded(page); await page.getByRole('tab', { name: 'Data' }).click(); await waitForGraphLoaded(page); - await closeFirstPopupAlert(page); - await page.getByRole('tab', { name: 'Model' }).click(); + await switchToModelMode(page); await expect(page.getByTestId('view-mode-select')).not.toHaveAttribute( 'data-disabled', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts index eceafac8d13d..bf3d1c0efbdd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/polling.ts @@ -14,34 +14,39 @@ import { APIRequestContext, Page } from '@playwright/test'; import { waitForAllLoadersToDisappear } from './entity'; /** - * Polls the search API until the given entity appears in Elasticsearch. + * Polls the search API until the exact entity appears in Elasticsearch. * Use after creating/updating entities to wait for async ES indexing. + * + * `entityIdentity` is matched exactly against the hit id, name, or FQN. Pass + * `options.query` when the most selective search query differs from that exact + * identity (for example, a short unique token used to find a UUID-backed hit). */ export const waitForSearchIndexed = async ( apiContext: APIRequestContext, - entityFqn: string | undefined, + entityIdentity: string | undefined, index: string, - options?: { timeout?: number; intervals?: number[] } + options?: { timeout?: number; intervals?: number[]; query?: string } ) => { // An empty q= becomes a match-all query in the search API: hits.total>0 // would resolve on the first poll against any non-empty index, silently // bypassing the very race this helper exists to close. Fail fast with a - // clear message so a missing FQN is debuggable at the source. - if (!entityFqn) { + // clear message so a missing identity is debuggable at the source. + if (!entityIdentity) { throw new Error( - `waitForSearchIndexed called with empty FQN for index "${index}"` + `waitForSearchIndexed called with empty identity for index "${index}"` ); } const timeout = options?.timeout ?? 30_000; const intervals = options?.intervals ?? [500, 1_000, 2_000, 5_000]; + const query = options?.query ?? entityIdentity; const start = Date.now(); let intervalIdx = 0; while (Date.now() - start < timeout) { const response = await apiContext.get( `/api/v1/search/query?q=${encodeURIComponent( - entityFqn + query )}&index=${index}&from=0&size=25` ); @@ -62,10 +67,10 @@ export const waitForSearchIndexed = async ( if ( hits.some( (hit) => - hit._id === entityFqn || - hit._source?.id === entityFqn || - hit._source?.name === entityFqn || - hit._source?.fullyQualifiedName === entityFqn + hit._id === entityIdentity || + hit._source?.id === entityIdentity || + hit._source?.name === entityIdentity || + hit._source?.fullyQualifiedName === entityIdentity ) ) { return; @@ -78,7 +83,7 @@ export const waitForSearchIndexed = async ( } throw new Error( - `Entity "${entityFqn}" not found in index "${index}" after ${timeout}ms` + `Entity "${entityIdentity}" not found in index "${index}" after ${timeout}ms` ); }; From 5770cec04d55f75083882049caf61a23c12055bc Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 15:46:01 -0700 Subject: [PATCH 46/60] test(playwright): isolate column search fixtures --- .../ui/playwright/e2e/Features/Table.spec.ts | 73 +++++++++++++++---- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts index df14de9300a6..15e7dbd7ba6b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Table.spec.ts @@ -415,12 +415,19 @@ test.describe('Table & Data Model columns table pagination', () => { }); test.describe('Tags and glossary terms should be consistent for search ', () => { + let isolatedTable: TableClass; + let isolatedTableFqn = ''; + let isolatedColumnFqn = ''; + let isolatedColumnName = ''; + let isolatedTagColumnFqn = ''; + let isolatedTagColumnName = ''; let glossary: Glossary; let glossaryTerm: GlossaryTerm; let testClassification: ClassificationClass; let testTag: TagClass; test.beforeAll(async ({ browser }) => { + isolatedTable = new TableClass(); glossary = new Glossary(); glossaryTerm = new GlossaryTerm(glossary); testClassification = new ClassificationClass(); @@ -431,6 +438,24 @@ test.describe('Tags and glossary terms should be consistent for search ', () => const { apiContext, afterAction } = await performAdminLogin(browser); try { + await isolatedTable.create(apiContext); + isolatedTableFqn = + isolatedTable.entityResponseData.fullyQualifiedName ?? ''; + isolatedColumnFqn = + isolatedTable.entityResponseData.columns?.[0].fullyQualifiedName ?? ''; + isolatedColumnName = + isolatedTable.entityResponseData.columns?.[0].name ?? ''; + isolatedTagColumnFqn = + isolatedTable.entityResponseData.columns?.[1].fullyQualifiedName ?? ''; + isolatedTagColumnName = + isolatedTable.entityResponseData.columns?.[1].name ?? ''; + + expect(isolatedTableFqn).not.toBe(''); + expect(isolatedColumnFqn).not.toBe(''); + expect(isolatedColumnName).not.toBe(''); + expect(isolatedTagColumnFqn).not.toBe(''); + expect(isolatedTagColumnName).not.toBe(''); + await glossary.create(apiContext); await glossaryTerm.create(apiContext); await testClassification.create(apiContext); @@ -440,12 +465,25 @@ test.describe('Tags and glossary terms should be consistent for search ', () => } }); + test.afterAll(async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + try { + await isolatedTable.delete(apiContext); + await glossaryTerm.delete(apiContext); + await glossary.delete(apiContext); + await testTag.delete(apiContext); + await testClassification.delete(apiContext); + } finally { + await afterAction(); + } + }); + test('Glossary term should be consistent for search', async ({ dataConsumerPage: page, }) => { - const tableRoute = '/table/sample_data.ecommerce_db.shopify.dim_customer'; - const glossaryRowSelector = - '[data-row-key="sample_data.ecommerce_db.shopify.dim_customer.customer_id"]'; + const tableRoute = `/table/${encodeURIComponent(isolatedTableFqn)}`; + const glossaryRowSelector = `[data-row-key="${isolatedColumnFqn}"]`; await expect .poll( @@ -469,8 +507,7 @@ test.describe('Tags and glossary terms should be consistent for search ', () => await expect(glossaryTagsCell).toBeVisible({ timeout: 30000 }); // Check if add button exists and is visible - const rowSelector = - '[data-row-key="sample_data.ecommerce_db.shopify.dim_customer.customer_id"] [data-testid*="glossary-tags"]'; + const rowSelector = `${glossaryRowSelector} [data-testid*="glossary-tags"]`; const addButton = glossaryTagsCell.getByTestId('add-tag'); if (await addButton.isVisible().catch(() => false)) { @@ -520,7 +557,7 @@ test.describe('Tags and glossary terms should be consistent for search ', () => await page .getByTestId('search-bar-container') .getByTestId('searchbar') - .fill('customer_id'); + .fill(isolatedColumnName); await page .getByTestId('entity-table') .getByTestId('loader') @@ -576,19 +613,23 @@ test.describe('Tags and glossary terms should be consistent for search ', () => test('Tags term should be consistent for search', async ({ dataConsumerPage: page, }) => { + const tableRoute = `/table/${encodeURIComponent(isolatedTableFqn)}`; const columnsResponse = page.waitForResponse( - '/api/v1/tables/name/sample_data.ecommerce_db.shopify.dim_customer/columns?*fields=tags*&include=all*' + (response) => + response.url().includes('/api/v1/tables/name/') && + response.url().includes('/columns?') && + response.request().method() === 'GET' && + response.ok() ); - await page.goto('/table/sample_data.ecommerce_db.shopify.dim_customer'); + await page.goto(tableRoute); // Wait for page to be fully loaded await columnsResponse; await waitForAllLoadersToDisappear(page); // Check if add button exists and is visible - const rowSelector = - '[data-row-key="sample_data.ecommerce_db.shopify.dim_customer.shop_id"] [data-testid*="classification-tags"]'; + const rowSelector = `[data-row-key="${isolatedTagColumnFqn}"] [data-testid*="classification-tags"]`; const addButton = page.locator(`${rowSelector} [data-testid="add-tag"]`); if (await addButton.isVisible()) { @@ -626,12 +667,16 @@ test.describe('Tags and glossary terms should be consistent for search ', () => // Wait for page to be fully loaded await waitForAllLoadersToDisappear(page); const getRequest = page.waitForResponse( - 'api/v1/tables/name/sample_data.ecommerce_db.shopify.dim_customer/columns/*' + (response) => + response.url().includes('/api/v1/tables/name/') && + response.url().includes('/columns/') && + response.request().method() === 'GET' && + response.ok() ); await page .getByTestId('search-bar-container') .getByTestId('searchbar') - .fill('shop_id'); + .fill(isolatedTagColumnName); await getRequest; @@ -641,9 +686,7 @@ test.describe('Tags and glossary terms should be consistent for search ', () => .getByTestId(`tag-${testTag.responseData.fullyQualifiedName}`) ).toBeVisible(); - await page.click( - `[data-row-key="sample_data.ecommerce_db.shopify.dim_customer.shop_id"] [data-testid="classification-tags-0"] [data-testid="edit-button"]` - ); + await page.locator(rowSelector).getByTestId('edit-button').click(); await page.locator('.ant-select-dropdown').waitFor({ state: 'visible' }); await page From 5570d32cfeb98fd3eca59376d330f753d335d3b5 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:02:11 -0700 Subject: [PATCH 47/60] test(playwright): preserve data product identity --- .../resources/ui/playwright/support/domain/DataProduct.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts index 050f478f7662..26719bf94ed5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts @@ -154,10 +154,10 @@ export class DataProduct extends EntityClass { // A 400 here is a bulk-operation report (numberOfRowsFailed and a // failedRequest list), not a transport failure, so the caller inspects the // body rather than having it raised. - const data = await response.json(); - this.responseData = data; - - return data; + // Do not replace responseData: this endpoint returns a bulk-operation + // result, not a DataProduct. Callers still need the created entity's id and + // fullyQualifiedName after adding assets. + return response.json(); } async addInputPorts( From 6b457aa874600ab99eaf89ec965d20193a281fac Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:03:56 -0700 Subject: [PATCH 48/60] test(playwright): serialize shared service versions --- .../e2e/VersionPages/ServiceEntityVersionPage.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts index 454d14b62627..2601efa105a3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts @@ -94,6 +94,11 @@ const test = base.extend<{ page: Page }>({ }); test.describe('Service Version pages', () => { + // The describe owns one shared set of services. Running its tests in separate + // workers repeats beforeAll while another worker is soft-deleting the same + // entities, so keep the shared fixture lifecycle on a single worker. + test.describe.configure({ mode: 'serial' }); + test.beforeAll('Setup pre-requests', async ({ browser }) => { const { apiContext, afterAction } = await performAdminLogin(browser); await adminUser.create(apiContext); From 828130bebf0ec01a1a66cd97cb5ba64bccc5eea5 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:06:19 -0700 Subject: [PATCH 49/60] test(playwright): seed bot auth on stable origin --- .../resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts index 7ddd74760077..d78296fdb3f2 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/IngestionBot.spec.ts @@ -42,10 +42,10 @@ const test = base.extend<{ const { apiContext, afterAction } = await performAdminLogin(browser); const page = await browser.newPage(); - // Establish the application origin without booting the SPA. Navigating to - // `/` starts the unauthenticated redirect to `/signin`; that redirect can - // destroy the execution context while setToken writes to IndexedDB. - await page.goto('/manifest.json'); + // Establish the application origin on a public JSON endpoint without + // booting the SPA. Static-file misses can fall through to index.html and + // redirect while setToken writes to IndexedDB. + await page.goto('/api/v1/system/config/auth'); const bot = await apiContext .get('/api/v1/bots/name/ingestion-bot') From 7452f93229d7d660ff2e2b16cc5dfeeed3bc83bb Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:20:33 -0700 Subject: [PATCH 50/60] fix(ui): ignore stale explore facet responses --- .../Explore/ExploreQuickFilters.test.tsx | 101 ++++++++++++++++++ .../Explore/ExploreQuickFilters.tsx | 93 ++++++++++------ 2 files changed, 163 insertions(+), 31 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx index b345836e90a5..d7d3cd04f1cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx @@ -133,6 +133,13 @@ jest.mock('../../utils/ToastUtils', () => ({ showErrorToast: jest.fn(), })); +jest.mock('../../utils/SearchClassBase', () => ({ + __esModule: true, + default: { + getEntityIconWithBg: jest.fn(), + }, +})); + const index = SearchIndex.TABLE; const mockFields: ExploreQuickFilterField[] = [ { @@ -351,6 +358,100 @@ describe('ExploreQuickFilters component', () => { }); describe('Options fetching - Aggregations', () => { + it('ignores a stale response from the previously active dropdown', async () => { + let resolveDomainRequest!: (value: unknown) => void; + let resolveEntityTypeRequest!: (value: unknown) => void; + const domainRequest = new Promise((resolve) => { + resolveDomainRequest = resolve; + }); + const entityTypeRequest = new Promise((resolve) => { + resolveEntityTypeRequest = resolve; + }); + + mockGetAggregationOptions.mockImplementation( + (_index: unknown, key: string) => + key === 'domains.displayName.keyword' + ? domainRequest + : entityTypeRequest + ); + + const fields: ExploreQuickFilterField[] = [ + { + label: 'Domain', + key: 'domains.displayName.keyword', + value: undefined, + }, + { + label: 'Data Assets', + key: 'entityType.keyword', + value: undefined, + }, + ]; + + render( + + ); + + fireEvent.click( + screen.getByTestId('onGetInitialOptions-domains.displayName.keyword') + ); + await waitFor(() => + expect(getAggregationOptions).toHaveBeenCalledTimes(1) + ); + expect(mockGetAggregationOptions.mock.calls[0][1]).toBe( + 'domains.displayName.keyword' + ); + + fireEvent.click( + screen.getByTestId('onGetInitialOptions-entityType.keyword') + ); + await waitFor(() => + expect(getAggregationOptions).toHaveBeenCalledTimes(2) + ); + expect(mockGetAggregationOptions.mock.calls[1][1]).toBe( + 'entityType.keyword' + ); + + await act(async () => { + resolveEntityTypeRequest({ + data: { + aggregations: { + 'sterms#entityType.keyword': { + buckets: [{ key: 'table', doc_count: 1 }], + }, + }, + }, + }); + }); + + await waitFor(() => + expect( + screen.getByTestId('option-entityType.keyword-0') + ).toHaveTextContent(/table\s*-\s*1/i) + ); + + await act(async () => { + resolveDomainRequest({ + data: { + aggregations: { + 'sterms#domains.displayName.keyword': { + buckets: [{ key: 'stale-domain', doc_count: 1 }], + }, + }, + }, + }); + }); + + expect( + screen.getByTestId('option-entityType.keyword-0') + ).toHaveTextContent(/table\s*-\s*1/i); + expect(screen.queryByText('stale-domain - 1')).not.toBeInTheDocument(); + }); + it('should use aggregations when available', async () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx index 1bf0f5347b4d..f4e2de38dbe1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx @@ -15,7 +15,7 @@ import { Space } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, isEqual, uniqWith } from 'lodash'; import Qs from 'qs'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { FC, useCallback, useMemo, useRef, useState } from 'react'; import { EntityFields } from '../../enums/AdvancedSearch.enum'; import { SearchIndex } from '../../enums/search.enum'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; @@ -98,6 +98,10 @@ const ExploreQuickFilters: FC = ({ const location = useCustomLocation(); const [options, setOptions] = useState(); const [isOptionsLoading, setIsOptionsLoading] = useState(false); + // Every dropdown shares the options slot because only one can be open. Keep + // an ownership token so a slower request from the previous dropdown cannot + // overwrite the options (or loading state) of the currently active one. + const latestOptionsRequest = useRef(0); const { queryFilter } = useAdvanceSearch(); const { isNLPActive } = useSearchStore(); const getStaticOptions = useCallback( @@ -169,13 +173,16 @@ const ExploreQuickFilters: FC = ({ const fetchDefaultOptions = async ( index: SearchIndex | SearchIndex[], key: string, + requestId: number, fieldSearchIndex?: SearchIndex, fieldSearchKey?: string, sourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { - setOptions(addEntityTypeIcons(key, staticOptions)); + if (latestOptionsRequest.current === requestId) { + setOptions(addEntityTypeIcons(key, staticOptions)); + } return; } @@ -213,19 +220,21 @@ const ExploreQuickFilters: FC = ({ res.data.aggregations[`sterms#${searchKeyToUse}`]?.buckets ?? []; } - setOptions( - addEntityTypeIcons( - key, - uniqWith( - getOptionsFromAggregationBucket( - buckets, - getOptionLabelFormatter(key, untitledDropdown), - sourceFields - ), - isEqual + if (latestOptionsRequest.current === requestId) { + setOptions( + addEntityTypeIcons( + key, + uniqWith( + getOptionsFromAggregationBucket( + buckets, + getOptionLabelFormatter(key, untitledDropdown), + sourceFields + ), + isEqual + ) ) - ) - ); + ); + } }; const getInitialOptions = async ( @@ -234,8 +243,10 @@ const ExploreQuickFilters: FC = ({ fieldSearchKey?: string, sourceFields?: string ) => { + const requestId = ++latestOptionsRequest.current; const staticOptions = getStaticOptions(key); if (staticOptions) { + setIsOptionsLoading(false); setOptions(addEntityTypeIcons(key, staticOptions)); return; @@ -247,14 +258,19 @@ const ExploreQuickFilters: FC = ({ await fetchDefaultOptions( index, key, + requestId, fieldSearchIndex, fieldSearchKey, sourceFields ); } catch (error) { - showErrorToast(error as AxiosError); + if (latestOptionsRequest.current === requestId) { + showErrorToast(error as AxiosError); + } } finally { - setIsOptionsLoading(false); + if (latestOptionsRequest.current === requestId) { + setIsOptionsLoading(false); + } } }; @@ -265,6 +281,7 @@ const ExploreQuickFilters: FC = ({ fieldSearchKey?: string, sourceFields?: string ) => { + const requestId = ++latestOptionsRequest.current; const staticOptions = getStaticOptions(key); if (staticOptions) { const filteredOptions = value @@ -272,6 +289,7 @@ const ExploreQuickFilters: FC = ({ option.label.toLowerCase().includes(value.toLowerCase()) ) : staticOptions; + setIsOptionsLoading(false); setOptions(addEntityTypeIcons(key, filteredOptions)); return; @@ -281,7 +299,14 @@ const ExploreQuickFilters: FC = ({ setOptions([]); try { if (!value) { - getInitialOptions(key, fieldSearchIndex, fieldSearchKey, sourceFields); + await fetchDefaultOptions( + index, + key, + requestId, + fieldSearchIndex, + fieldSearchKey, + sourceFields + ); return; } @@ -304,23 +329,29 @@ const ExploreQuickFilters: FC = ({ const buckets = res.data.aggregations[`sterms#${searchKeyToUse}`]?.buckets ?? []; - setOptions( - addEntityTypeIcons( - key, - uniqWith( - getOptionsFromAggregationBucket( - buckets, - getOptionLabelFormatter(key, untitledDropdown), - sourceFields - ), - isEqual + if (latestOptionsRequest.current === requestId) { + setOptions( + addEntityTypeIcons( + key, + uniqWith( + getOptionsFromAggregationBucket( + buckets, + getOptionLabelFormatter(key, untitledDropdown), + sourceFields + ), + isEqual + ) ) - ) - ); + ); + } } catch (error) { - showErrorToast(error as AxiosError); + if (latestOptionsRequest.current === requestId) { + showErrorToast(error as AxiosError); + } } finally { - setIsOptionsLoading(false); + if (latestOptionsRequest.current === requestId) { + setIsOptionsLoading(false); + } } }; From f78d607dd9c5ba8a502fd83d89e733e9e10773ce Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:20:38 -0700 Subject: [PATCH 51/60] test(playwright): await exact explore facet searches --- .../e2e/Flow/ExploreDiscovery.spec.ts | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts index 853c673dda44..18f4aa0a9934 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/ExploreDiscovery.spec.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect } from '@playwright/test'; +import test, { expect, Page } from '@playwright/test'; import { SidebarItem } from '../../constant/sidebar'; import { Domain } from '../../support/domain/Domain'; import { TableClass } from '../../support/entity/TableClass'; @@ -32,6 +32,27 @@ let table1: TableClass; let user: UserClass; let domain: Domain; +const waitForFacetSearchResponse = ( + page: Page, + field: string, + value: string, + deleted: boolean +) => + page.waitForResponse((response) => { + const url = new URL(response.url()); + + return ( + url.pathname === '/api/v1/search/aggregate' && + url.searchParams.get('index') === 'dataAsset' && + url.searchParams.get('field') === field && + url.searchParams + .get('value') + ?.toLowerCase() + .includes(value.toLowerCase()) === true && + url.searchParams.get('deleted') === String(deleted) + ); + }); + test.describe('Explore Assets Discovery', () => { test.beforeAll(async ({ browser }) => { table = new TableClass(); @@ -257,15 +278,18 @@ test.describe('Explore Assets Discovery', () => { // The user should not be visible in the owners filter when the deleted switch is off await page.click('[data-testid="search-dropdown-Owners"]'); - const searchResOwner = page.waitForResponse( - `/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName*deleted=false*` + const searchResOwner = waitForFacetSearchResponse( + page, + 'ownerDisplayName', + user.responseData.displayName, + false ); await page.fill( '[data-testid="search-input"]', user.responseData.displayName ); - await searchResOwner; + expect((await searchResOwner).ok()).toBeTruthy(); await waitForAllLoadersToDisappear(page); @@ -280,15 +304,18 @@ test.describe('Explore Assets Discovery', () => { // The domain should not be visible in the domains filter when the deleted switch is off await page.click('[data-testid="search-dropdown-Domains"]'); - const searchResDomain = page.waitForResponse( - `/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*deleted=false*` + const searchResDomain = waitForFacetSearchResponse( + page, + 'domains.displayName.keyword', + domain.responseData.displayName, + false ); await page.fill( '[data-testid="search-input"]', domain.responseData.displayName ); - await searchResDomain; + expect((await searchResDomain).ok()).toBeTruthy(); await waitForAllLoadersToDisappear(page); @@ -317,12 +344,15 @@ test.describe('Explore Assets Discovery', () => { const ownerSearchText = user.responseData.displayName.toLowerCase(); await page.click('[data-testid="search-dropdown-Owners"]'); - const searchResOwner = page.waitForResponse( - `/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName*deleted=true*` + const searchResOwner = waitForFacetSearchResponse( + page, + 'ownerDisplayName', + ownerSearchText, + true ); await page.fill('[data-testid="search-input"]', ownerSearchText); - await searchResOwner; + expect((await searchResOwner).ok()).toBeTruthy(); await waitForAllLoadersToDisappear(page); @@ -354,12 +384,15 @@ test.describe('Explore Assets Discovery', () => { const domainSearchText = domain.responseData.displayName.toLowerCase(); await page.click('[data-testid="search-dropdown-Domains"]'); - const searchResDomain = page.waitForResponse( - `/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*deleted=true*` + const searchResDomain = waitForFacetSearchResponse( + page, + 'domains.displayName.keyword', + domainSearchText, + true ); await page.fill('[data-testid="search-input"]', domainSearchText); - await searchResDomain; + expect((await searchResDomain).ok()).toBeTruthy(); await waitForAllLoadersToDisappear(page); @@ -391,7 +424,14 @@ test.describe('Explore Assets Discovery', () => { // Only the table option should be visible for the data assets filter when the deleted switch is on // with the owner and domain filter applied + const dataAssetResponse = waitForFacetSearchResponse( + page, + 'entityType.keyword', + '', + true + ); await page.click('[data-testid="search-dropdown-Data Assets"]'); + expect((await dataAssetResponse).ok()).toBeTruthy(); const dataAssetMenu = page.locator( '[data-testid="drop-down-menu"]:visible' ); From 383d8b9664bb8281258d25e0860083257bc5467a Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:39:27 -0700 Subject: [PATCH 52/60] test(playwright): preserve shared governance fixtures --- .../e2e/Pages/DataProductCertificationFilter.spec.ts | 5 +++++ .../ui/playwright/e2e/Pages/EditClassification.spec.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataProductCertificationFilter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataProductCertificationFilter.spec.ts index 35bc76b262c7..05871f1f8705 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataProductCertificationFilter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataProductCertificationFilter.spec.ts @@ -133,6 +133,11 @@ test.describe( 'Data Products - Certification filter', { tag: '@Governance' }, () => { + // Both tests share the module-scoped tags and data products created by this + // hook. In a fully-parallel project Playwright otherwise runs the hook once + // per test, and the second invocation recreates the same tags with a 409. + test.describe.configure({ mode: 'serial' }); + test.beforeAll('Setup certified data products', async ({ browser }) => { const { apiContext, afterAction } = await createNewPage(browser); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/EditClassification.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/EditClassification.spec.ts index 10dc0ce02572..aeb5f1c75a9f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/EditClassification.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/EditClassification.spec.ts @@ -17,6 +17,11 @@ import { createNewPage, redirectToHomePage, uuid } from '../../utils/common'; // use the admin user to login test.use({ storageState: 'playwright/.auth/admin.json' }); +// These tests share classifications created in beforeAll and removed in +// afterAll. Fully-parallel execution lets one test delete them while the other +// is still navigating to its classification page. +test.describe.configure({ mode: 'serial' }); + const userClassification = new ClassificationClass(); const systemClassification = new ClassificationClass({ provider: 'system', From 9f6c25079905691cb878a2bc7206fb4086fa5925 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 19:56:39 -0700 Subject: [PATCH 53/60] test(playwright): isolate tenant search settings --- .../playwright/e2e/Pages/SearchSettings.spec.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts index e6829b558f8a..b4b1daa61dc5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/SearchSettings.spec.ts @@ -114,6 +114,11 @@ const getDatabaseNgramBoost = (request: { postDataJSON: () => unknown }) => { }; test.describe('Search Settings', () => { + // Every test in this file reads or writes the same tenant-wide search settings document. + // Keep the tests independent, but do not let fullyParallel split them across workers and + // restore the document underneath one another. + test.describe.configure({ mode: 'default' }); + test.beforeAll(async ({ browser }) => { adminUser = new AdminClass(); @@ -124,8 +129,15 @@ test.describe('Search Settings', () => { test.afterAll(async ({ browser }) => { const { apiContext, afterAction } = await performAdminLogin(browser); - await adminUser.delete(apiContext); - await afterAction(); + try { + const resetResponse = await apiContext.put( + '/api/v1/system/settings/reset/searchSettings' + ); + expect(resetResponse.ok()).toBeTruthy(); + await adminUser.delete(apiContext); + } finally { + await afterAction(); + } }); test.describe('Search Settings Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { From 487abc337cee3c5c1e25aac26312698ec27b62e3 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 20:08:28 -0700 Subject: [PATCH 54/60] fix(ui): preserve relevance for typed mentions --- .../resources/ui/src/utils/FeedUtils.test.tsx | 20 +++++++++++++++++++ .../main/resources/ui/src/utils/FeedUtils.tsx | 16 +++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx index 291a12016340..ce7b6e24a0eb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx @@ -14,6 +14,7 @@ import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { EntityType, FqnPart } from '../enums/entity.enum'; import { SearchIndex } from '../enums/search.enum'; import { CardStyle, FieldOperation } from '../generated/entity/feed/thread'; +import { searchQuery } from '../rest/searchAPI'; import { getFeedHeaderTextFromCardStyle, getFieldOperationIcon, @@ -68,6 +69,10 @@ jest.mock('./FqnUtils', () => ({ })); describe('Feed Utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should getEntityType return the correct entity type', () => { expect(getEntityType('<#E::table::db.schema.table>')).toBe('table'); }); @@ -100,6 +105,21 @@ describe('Feed Utils', () => { type: 'team', }, ]); + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + sortField: 'displayName.keyword', + sortOrder: 'asc', + }) + ); + }); + + it('should preserve relevance ordering for a typed mention search', async () => { + await suggestions('Table1', '@'); + + const request = jest.mocked(searchQuery).mock.calls[0][0]; + + expect(request).not.toHaveProperty('sortField'); + expect(request).not.toHaveProperty('sortOrder'); }); it('should return correct backend format for a given message', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx index 067cb983dd09..b519c9444917 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx @@ -44,6 +44,16 @@ export async function suggestions( searchTerm: string, mentionChar: string ): Promise { + // Keep the initial picker deterministic, but let OpenSearch rank a typed query by relevance. + // Alphabetical sorting discards _score and can push the exact match outside the five visible + // suggestions when fuzzy matching admits several candidates. + const sortOptions = searchTerm?.trim() + ? {} + : { + sortField: 'displayName.keyword' as const, + sortOrder: 'asc' as const, + }; + if (mentionChar === '@') { let atValues = []; @@ -52,8 +62,7 @@ export async function suggestions( pageNumber: 1, pageSize: 5, queryFilter: getTermQuery({ isBot: 'false' }), - sortField: 'displayName.keyword', - sortOrder: 'asc', + ...sortOptions, searchIndex: [SearchIndex.USER, SearchIndex.TEAM], }); const hits = data.hits.hits; @@ -88,8 +97,7 @@ export async function suggestions( query: searchTerm ?? '', pageNumber: 1, pageSize: 5, - sortField: 'displayName.keyword', - sortOrder: 'asc', + ...sortOptions, searchIndex: SearchIndex.DATA_ASSET, }); const hits = data.hits.hits; From 1fc9b905f17a912eec2d70295c3d9c3750424bdb Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 20:35:59 -0700 Subject: [PATCH 55/60] test(playwright): stabilize full-suite state --- .../e2e/Features/ContextCenterArchive.spec.ts | 16 ++++++ .../Features/DataQuality/DataQuality.spec.ts | 3 ++ .../PageObject/Explore/OverviewPageObject.ts | 12 +++++ ...lorePageRightPanel_KnowledgeCenter.spec.ts | 4 ++ .../e2e/Pages/LiveIndexingTab.spec.ts | 54 ++++++++----------- .../e2e/Utils/ExplorePageRightPanelUtils.ts | 8 +++ .../ui/playwright/e2e/fixtures/pages.ts | 14 ++++- .../resources/ui/playwright/utils/common.ts | 43 +++++++++++++-- 8 files changed, 118 insertions(+), 36 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts index 707c3742276e..e5b3cc4c95a3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ContextCenterArchive.spec.ts @@ -469,6 +469,22 @@ test.describe('Context Center - Folder Delete: file absent from search and prese await afterAction(); } + // Drive the debounced search state through a distinct value before restoring the + // document name. Clearing and refilling within one debounce window leaves the + // debounced value unchanged, so no request is emitted for the final fill. + const noMatchQuery = `deleted-folder-no-match-${uuid()}`; + const noMatchResPromise = page.waitForResponse((res) => { + const url = new URL(res.url()); + + return ( + url.pathname.includes('/api/v1/search/query') && + url.searchParams.get('index') === 'contextFile' && + url.searchParams.get('q') === noMatchQuery + ); + }); + await searchInput.fill(noMatchQuery); + await noMatchResPromise; + const searchResPromise = page.waitForResponse((res) => { const url = new URL(res.url()); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts index e61b11f2df04..f5efa1f03c2d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQuality.spec.ts @@ -1485,6 +1485,9 @@ test.describe( ); await expect(pageSizeDropdown).toBeVisible(); + // The list response can finish before React clears the pagination loading state. + // Hovering the disabled Ant Dropdown trigger is ignored and is not replayed later. + await expect(pageSizeDropdown).toBeEnabled(); // NextPrevious inherits Ant Dropdown's hover trigger; clicking this // button only runs its preventDefault handler and may not open the menu. await pageSizeDropdown.hover(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/PageObject/Explore/OverviewPageObject.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/PageObject/Explore/OverviewPageObject.ts index 3f32c1a10f31..ec5961b7605d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/PageObject/Explore/OverviewPageObject.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/PageObject/Explore/OverviewPageObject.ts @@ -223,6 +223,12 @@ export class OverviewPageObject extends RightPanelBase { * @returns OverviewPageObject for method chaining */ async editTags(tagName: string): Promise { + // Callers use this method to ensure a tag is assigned. Avoid reopening the selector + // when an earlier test using the same entity has already established that state. + if (await this.tagListContainer.getByText(tagName).isVisible()) { + return this; + } + // Use dispatchEvent to avoid Playwright's internal scroll-into-view on click(). // Scrolling the panel container triggers a React re-render that detaches the icon, // causing Playwright to retry the scroll → re-render → infinite loop under load. @@ -276,6 +282,12 @@ export class OverviewPageObject extends RightPanelBase { * @returns OverviewPageObject for method chaining */ async editGlossaryTerms(termName: string): Promise { + // Callers use this method to ensure a term is assigned. Selecting an active term is a + // toggle, and confirming an unchanged selector may not emit the PATCH awaited below. + if (await this.glossaryTermListContainer.getByText(termName).isVisible()) { + return this; + } + await this.editGlossaryTermsIcon.click(); await this.selectableList.waitFor({ state: 'visible' }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts index bf4d2a7c2b24..59db80d00c56 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts @@ -69,6 +69,10 @@ export const test = baseTest.extend<{ }); test.describe('Knowledge Center Right Panel Test Suite', () => { + // Every test mutates the same Knowledge Center. Keep the tests independent, but do not + // let fullyParallel run conflicting owner/tag/glossary patches at the same time. + test.describe.configure({ mode: 'default' }); + test.beforeAll(async ({ browser }) => { test.slow(true); const { apiContext, afterAction } = await performAdminLogin(browser); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts index a931fd8447d1..bccd0c64d74d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts @@ -10,15 +10,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import test, { expect } from '@playwright/test'; -import { GlobalSettingOptions } from '../../constant/settings'; -import { getApiContext, redirectToHomePage } from '../../utils/common'; -import { settingClick } from '../../utils/sidebar'; +import test, { expect, Page } from '@playwright/test'; +import { getDefaultAdminAPIContext } from '../../utils/common'; test.use({ storageState: 'playwright/.auth/admin.json' }); const RETRY_QUEUE_API = '/api/v1/apps/name/SearchIndexingApplication/live-indexing-queue*'; +const SEARCH_INDEXING_APP_PATH = '/settings/apps/SearchIndexingApplication'; + +const navigateToSearchIndexingApplication = async (page: Page) => { + const appResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + '/api/v1/apps/name/SearchIndexingApplication' && + response.request().method() === 'GET' + ); + + await page.goto(SEARCH_INDEXING_APP_PATH); + expect((await appResponse).status()).toBe(200); +}; const SAMPLE_RETRY_RECORDS = [ { @@ -49,8 +60,8 @@ test.describe( { tag: ['@Platform'] }, () => { test.beforeAll('Seed retry queue records', async ({ browser }) => { - const { apiContext, afterAction } = await getApiContext( - await browser.newPage() + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser ); for (const record of SAMPLE_RETRY_RECORDS) { @@ -67,8 +78,8 @@ test.describe( }); test.afterAll('Clean up retry queue records', async ({ browser }) => { - const { apiContext, afterAction } = await getApiContext( - await browser.newPage() + const { apiContext, afterAction } = await getDefaultAdminAPIContext( + browser ); for (const record of SAMPLE_RETRY_RECORDS) { @@ -90,14 +101,7 @@ test.describe( test.slow(); await test.step('Navigate to SearchIndexingApplication', async () => { - await redirectToHomePage(page); - await settingClick(page, GlobalSettingOptions.APPLICATIONS); - - await page - .locator( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ) - .click(); + await navigateToSearchIndexingApplication(page); }); await test.step('Click Live Indexing tab', async () => { @@ -141,14 +145,7 @@ test.describe( page, }) => { await test.step('Navigate to SearchIndexingApplication', async () => { - await redirectToHomePage(page); - await settingClick(page, GlobalSettingOptions.APPLICATIONS); - - await page - .locator( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ) - .click(); + await navigateToSearchIndexingApplication(page); }); await test.step('Verify empty state message when queue is empty', async () => { @@ -211,14 +208,7 @@ test.describe( ]; await test.step('Navigate to SearchIndexingApplication', async () => { - await redirectToHomePage(page); - await settingClick(page, GlobalSettingOptions.APPLICATIONS); - - await page - .locator( - '[data-testid="search-indexing-application-card"] [data-testid="config-btn"]' - ) - .click(); + await navigateToSearchIndexingApplication(page); }); await test.step('Mock and verify retry queue data', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/ExplorePageRightPanelUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/ExplorePageRightPanelUtils.ts index 4e7121f580ad..da27cc0a77db 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/ExplorePageRightPanelUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Utils/ExplorePageRightPanelUtils.ts @@ -43,6 +43,14 @@ export async function navigateToKCEntity(page: Page, entityName: string) { export const addOwnerInKCPanel = async (page: Page, ownerName: string) => { const panel = page.locator('[data-testid="entity-summary-panel-container"]'); + + // This helper is used as an "ensure owner" precondition by removal and permission + // tests. Selecting an already-active owner toggles it off, so return when the panel + // already reflects the requested state. + if (await panel.getByTestId(ownerName).isVisible()) { + return; + } + await panel.getByTestId('edit-owners').click(); const ownerTabs = page.getByTestId('select-owner-tabs'); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/fixtures/pages.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/fixtures/pages.ts index 7e7136f31391..b79059806e5b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/fixtures/pages.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/fixtures/pages.ts @@ -11,7 +11,11 @@ * limitations under the License. */ import { Browser, Page, test as base } from '@playwright/test'; -import { disableEtagConditionalReads } from '../../utils/common'; +import { + disableEtagConditionalReads, + getSavedAdminToken, +} from '../../utils/common'; +import { setToken } from '../../utils/tokenStorage'; // Define the type for our custom fixtures export type CustomFixtures = { @@ -31,6 +35,14 @@ const openRolePage = async (browser: Browser, storageState: string) => { const page = await browser.newPage({ storageState }); await disableEtagConditionalReads(page); + if (storageState === 'playwright/.auth/admin.json') { + // Establish the application origin before touching IndexedDB, then write the validated + // worker token explicitly. This removes the cold-context restoration race seen at high + // concurrency while leaving every non-admin role fixture unchanged. + await page.goto('/api/v1/system/config/auth'); + await setToken(page, await getSavedAdminToken()); + } + return page; }; 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 dc152265ed00..02d7145cd48f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -27,7 +27,10 @@ import { adjectives, nouns } from '../constant/user'; import { Domain } from '../support/domain/Domain'; import { waitForAllLoadersToDisappear } from './entity'; import { sidebarClick } from './sidebar'; -import { getToken as getTokenFromStorage } from './tokenStorage'; +import { + getToken as getTokenFromStorage, + setToken as setTokenInStorage, +} from './tokenStorage'; export const uuid = () => randomUUID().split('-')[0]; export const fullUuid = () => randomUUID(); @@ -120,6 +123,40 @@ export const redirectToHomePage = async ( if (_waitForLoaders) { await waitForAllLoadersToDisappear(page); } + + // Under the full AUT fan-out Chromium can occasionally restore the cookies/localStorage from + // admin.json before its IndexedDB token record. The app then redirects an otherwise valid + // admin context to /signin. Recover only the known admin fixture; never replace a role user's + // identity. Validate the recovery on the authenticated user request before returning. + if (new URL(page.url()).pathname === '/signin') { + const storedUser = await page.evaluate(() => + localStorage.getItem('loggedInUsers') + ); + + if (storedUser === 'admin') { + await setTokenInStorage(page, await getSavedAdminToken()); + const loggedInUserResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/users/loggedInUser', + { timeout: 30_000 } + ); + await page.goto('/my-data', { + waitUntil: 'domcontentloaded', + }); + const response = await loggedInUserResponse; + if (!response.ok()) { + throw new Error( + `Admin storage-state recovery failed (${response.status()})` + ); + } + await page.waitForURL('**/my-data', { + waitUntil: 'domcontentloaded', + }); + if (_waitForLoaders) { + await waitForAllLoadersToDisappear(page); + } + } + } }; export const redirectToExplorePage = async (page: Page) => { @@ -159,13 +196,13 @@ type CreateNewPageResult = { type NavigatedPageResult = CreateNewPageResult & { page: Page }; type APIOnlyPageResult = CreateNewPageResult & { page?: never }; -export const getSavedAdminToken = async () => { +export async function getSavedAdminToken() { const tokenFile = JSON.parse(await readFile(adminApiTokenFile, 'utf8')) as { token: string; }; return tokenFile.token; -}; +} const createValidatedWorkerAdminAPIContext = async () => { const apiContext = await getAuthContext(await getSavedAdminToken()); From 4bc978ac547e5a3d0e355655bc7ba8c5e3af8413 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 20:45:46 -0700 Subject: [PATCH 56/60] test(playwright): handle early admin signin redirects --- .../src/main/resources/ui/playwright/utils/common.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 02d7145cd48f..1570d7f561fd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -116,9 +116,10 @@ export const redirectToHomePage = async ( await page.goto('/my-data', { waitUntil: 'domcontentloaded', }); - await page.waitForURL('**/my-data', { - waitUntil: 'domcontentloaded', - }); + await page.waitForURL( + (url) => ['/my-data', '/signin'].includes(url.pathname), + { waitUntil: 'domcontentloaded' } + ); if (_waitForLoaders) { await waitForAllLoadersToDisappear(page); @@ -155,6 +156,10 @@ export const redirectToHomePage = async ( if (_waitForLoaders) { await waitForAllLoadersToDisappear(page); } + } else { + throw new Error( + `Stored user ${storedUser ?? ''} was redirected to /signin` + ); } } }; From f8ac4308584990eab684623892fed9f9bdf200c0 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 21:42:30 -0700 Subject: [PATCH 57/60] test(playwright): initialize fresh auth storage safely --- .../ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts | 7 ++++++- .../resources/ui/playwright/utils/tokenStorage.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts index bccd0c64d74d..c55f2ede2885 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/LiveIndexingTab.spec.ts @@ -11,7 +11,12 @@ * limitations under the License. */ import test, { expect, Page } from '@playwright/test'; -import { getDefaultAdminAPIContext } from '../../utils/common'; +import { GlobalSettingOptions } from '../../constant/settings'; +import { + getDefaultAdminAPIContext, + redirectToHomePage, +} from '../../utils/common'; +import { settingClick } from '../../utils/sidebar'; test.use({ storageState: 'playwright/.auth/admin.json' }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tokenStorage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tokenStorage.ts index a634ce24fa30..895436b971fa 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tokenStorage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tokenStorage.ts @@ -67,9 +67,15 @@ const executeTokenOperation = async ( reject(request.error); }; - // Handle case where database doesn't exist yet - request.onupgradeneeded = () => { - resolve(null); + // Initialize the store when this is the first IndexedDB access for + // the origin. Resolving during onupgradeneeded used to leave an + // empty version-1 database behind; the following write would then + // wait forever because there was no store and no further upgrade. + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } }; }); }; From b35160d6e2aaa60ec57b1c6120b1079eb35e2eb2 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 22:38:21 -0700 Subject: [PATCH 58/60] test(ui): satisfy quick filter formatting rule --- .../ui/src/components/Explore/ExploreQuickFilters.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx index d7d3cd04f1cf..c47d17c1730c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx @@ -402,6 +402,7 @@ describe('ExploreQuickFilters component', () => { await waitFor(() => expect(getAggregationOptions).toHaveBeenCalledTimes(1) ); + expect(mockGetAggregationOptions.mock.calls[0][1]).toBe( 'domains.displayName.keyword' ); @@ -412,6 +413,7 @@ describe('ExploreQuickFilters component', () => { await waitFor(() => expect(getAggregationOptions).toHaveBeenCalledTimes(2) ); + expect(mockGetAggregationOptions.mock.calls[1][1]).toBe( 'entityType.keyword' ); From cb0c38dbbd7681cf9d7b4ab69a0b2f887c5a57af Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 20 Aug 2026 23:06:37 -0700 Subject: [PATCH 59/60] test(playwright): surface rejected data product assets --- .../playwright/support/domain/DataProduct.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts index 26719bf94ed5..4375d87c0e13 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/domain/DataProduct.ts @@ -140,7 +140,11 @@ export class DataProduct extends EntityClass { } private getFqn() { - return this.data?.fullyQualifiedName ?? this.data.name; + return ( + this.responseData?.fullyQualifiedName ?? + this.data?.fullyQualifiedName ?? + this.data.name + ); } async addAssets(apiContext: APIRequestContext, assets: AssetReference[]) { @@ -151,13 +155,19 @@ export class DataProduct extends EntityClass { } ); - // A 400 here is a bulk-operation report (numberOfRowsFailed and a - // failedRequest list), not a transport failure, so the caller inspects the - // body rather than having it raised. + const data = await response.json(); + + if (!response.ok()) { + throw new Error( + `DataProduct.addAssets() failed with status ${response.status()}: ${JSON.stringify( + data + )}` + ); + } + // Do not replace responseData: this endpoint returns a bulk-operation - // result, not a DataProduct. Callers still need the created entity's id and - // fullyQualifiedName after adding assets. - return response.json(); + // result, not a DataProduct. + return data; } async addInputPorts( From efde2ec235207597b0d5a2ed0c798a67c30f34f0 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 21 Aug 2026 11:45:33 -0700 Subject: [PATCH 60/60] test(playwright): align dashboard assets with product domain --- .../e2e/Features/DataQuality/DataQualityDashboard.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts index 4e5b0ecef1ab..028aeb1eb6a1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataQuality/DataQualityDashboard.spec.ts @@ -41,6 +41,7 @@ import { waitForIncidentToBeIndexed, } from '../../../utils/dataQuality'; import { getCurrentMillis } from '../../../utils/dateTime'; +import { assignDomainToEntity } from '../../../utils/domain'; import { waitForAllLoadersToDisappear } from '../../../utils/entity'; import { visitDataQualityTab } from '../../../utils/testCases'; @@ -127,6 +128,11 @@ test.describe( await table3.create(apiContext); await domain.create(apiContext); await dataProduct.create(apiContext); + await Promise.all( + [table1, table2, table3].map((table) => + assignDomainToEntity(apiContext, table, domain) + ) + ); await dataProduct.addAssets(apiContext, [ { id: table1.entityResponseData.id, type: 'table' }, { id: table2.entityResponseData.id, type: 'table' },