diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricBulkImportExportEdit.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricBulkImportExportEdit.spec.ts index 60e195acb4ec..1c6067998a48 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricBulkImportExportEdit.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/MetricBulkImportExportEdit.spec.ts @@ -183,6 +183,10 @@ let viewOnlyRole: RolesClass; let metricEditorUser: UserClass; let metricEditorPolicy: PolicyClass; let metricEditorRole: RolesClass; +// Dedicated admin user for export/import tests so that completed background +// jobs accumulate in this user's tray instead of the shared admin session, +// preventing the tray from blocking other admin tests in the same worker. +let metricExportUser: UserClass; let metricTypeId: string | undefined; let metricCustomPropertyName: string; @@ -973,6 +977,9 @@ test.describe( }, ], }); + + metricExportUser = new UserClass(undefined, true); + await metricExportUser.create(apiContext); }); test.afterAll(async () => { @@ -984,6 +991,7 @@ test.describe( metricEditorUser?.delete(apiContext), metricEditorRole?.delete(apiContext), metricEditorPolicy?.delete(apiContext), + metricExportUser?.delete(apiContext), ]); await cleanupFixtures(); await cleanupMetricCustomProperty(); @@ -991,84 +999,94 @@ test.describe( }); test('Admin starts exactly one async export job from the metrics listing', async ({ - page, + browser, }) => { - await redirectToHomePage(page); - await waitForMetricsPage(page); - await filterMetrics(page, fixtures.prefix); - await openMetricActions(page); - - let exportRequestCount = 0; - page.on('request', (request) => { - if (request.url().includes('/api/v1/metrics/name/*/exportAsync')) { - exportRequestCount += 1; - } - }); - - const exportResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/metrics/name/*/exportAsync') && - response.request().method() === 'GET' - ); + const page = await browser.newPage(); + await metricExportUser.login(page); + try { + await redirectToHomePage(page); + await waitForMetricsPage(page); + await filterMetrics(page, fixtures.prefix); + await openMetricActions(page); + + let exportRequestCount = 0; + page.on('request', (request) => { + if (request.url().includes('/api/v1/metrics/name/*/exportAsync')) { + exportRequestCount += 1; + } + }); - await clickMetricAction(page, 'Export'); - const response = await exportResponse; - expect(response.ok()).toBeTruthy(); - // Verify exactly one export request was fired (no duplicate calls). - await expect.poll(() => exportRequestCount).toBe(1); - await expect(page.locator('.csv-jobs-tray-launcher')).toBeVisible({ - timeout: 30000, - }); - await page.locator('.csv-jobs-tray-launcher').click(); - await expect(page.locator('.csv-jobs-tray-popover')).toBeVisible(); - // Verify the export job appears in the tray. Parallel workers share the - // admin identity and may have their own active jobs; checking an exact - // count is fragile. Instead, assert that a tray item carrying the export - // label is visible — the exportRequestCount check above already guarantees - // exactly one export request was sent. - await expect( - page - .locator('.csv-jobs-tray-item') - .filter({ hasText: /Exporting Metrics|Exported Metrics/ }) - ).toBeVisible(); + const exportResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/metrics/name/*/exportAsync') && + response.request().method() === 'GET' + ); + + await clickMetricAction(page, 'Export'); + const response = await exportResponse; + expect(response.status()).toBe(202); + // Verify exactly one export request was fired (no duplicate calls). + await expect.poll(() => exportRequestCount).toBe(1); + await expect(page.locator('.csv-jobs-tray-launcher')).toBeVisible({ + timeout: 30000, + }); + await page.locator('.csv-jobs-tray-launcher').click(); + await expect(page.locator('.csv-jobs-tray-popover')).toBeVisible(); + // Verify the export job appears in the tray. Each test uses a dedicated + // user session so only this test's own job is visible — checking the + // label is sufficient. + await expect( + page + .locator('.csv-jobs-tray-item') + .filter({ hasText: /Exporting Metrics|Exported Metrics/ }) + ).toBeVisible(); + } finally { + await page.close(); + } }); test('Admin imports a metric CSV through preview and async apply', async ({ - page, + browser, }) => { test.slow(); - const importedMetricName = `${fixtures.prefix}_imported`; - fixtures.metrics.push({ - id: '', - name: importedMetricName, - fullyQualifiedName: importedMetricName, - }); + const page = await browser.newPage(); + await metricExportUser.login(page); + try { + const importedMetricName = `${fixtures.prefix}_imported`; + fixtures.metrics.push({ + id: '', + name: importedMetricName, + fullyQualifiedName: importedMetricName, + }); - await redirectToHomePage(page); - await waitForMetricsPage(page); - await openMetricActions(page); - await clickMetricAction(page, 'Import'); - await expect(page).toHaveURL(/\/bulk\/import\/metric\/\*/); + await redirectToHomePage(page); + await waitForMetricsPage(page); + await openMetricActions(page); + await clickMetricAction(page, 'Import'); + await expect(page).toHaveURL(/\/bulk\/import\/metric\/\*/); - const csvPath = createMetricCsvFile(importedMetricName); - await uploadMetricCsvAndWaitForPreview(page, csvPath); - await expect( - page.getByRole('gridcell', { exact: true, name: importedMetricName }) - ).toBeVisible(); - await expect( - page.getByRole('button', { name: /Start Import/i }) - ).toBeVisible(); + const csvPath = createMetricCsvFile(importedMetricName); + await uploadMetricCsvAndWaitForPreview(page, csvPath); + await expect( + page.getByRole('gridcell', { exact: true, name: importedMetricName }) + ).toBeVisible(); + await expect( + page.getByRole('button', { name: /Start Import/i }) + ).toBeVisible(); - const applyResponse = waitForMetricImportResponse(page, false); - await page.getByRole('button', { name: /Start Import/i }).click(); - await applyResponse; - await expectMetricImportStatus(page, { - processed: '1', - passed: '1', - failed: '0', - }); + const applyResponse = waitForMetricImportResponse(page, false); + await page.getByRole('button', { name: /Start Import/i }).click(); + await applyResponse; + await expectMetricImportStatus(page, { + processed: '1', + passed: '1', + failed: '0', + }); - await expectImportedMetricComplexFields(importedMetricName); + await expectImportedMetricComplexFields(importedMetricName); + } finally { + await page.close(); + } }); test('Admin sees metric CSV validation failures for missing names and invalid references', async ({ @@ -1096,68 +1114,74 @@ test.describe( }); test('Admin imports a CSV update for an existing metric', async ({ - page, + browser, }) => { test.slow(); - const existingMetricName = fixtures.metrics[1].name; - const updatedDisplayName = `${fixtures.prefix} Import Updated`; - const csv = createCsv([ - [ - existingMetricName, - updatedDisplayName, - 'Metric updated from Playwright CSV', - 'COUNT', - 'COUNT', - '', - 'MONTH', - 'SQL', - 'COUNT(order_id)', - fixtures.metrics[0].fullyQualifiedName, - fixtures.secondTag.fullyQualifiedName, - fixtures.nestedGlossaryTerm.fullyQualifiedName, - 'Tier.Tier3', - `user:${fixtures.owner.name}`, - `team:${fixtures.reviewer.name}`, - fixtures.domain.fullyQualifiedName, - fixtures.dataProduct.fullyQualifiedName, - 'Approved', - `${metricCustomPropertyName}:updated custom value`, - ], - ]); - const csvPath = test - .info() - .outputPath(`${existingMetricName}-update.csv`); - fs.writeFileSync(csvPath, csv); - - await redirectToHomePage(page); - await waitForMetricsPage(page); - await openMetricActions(page); - await clickMetricAction(page, 'Import'); - await expect(page).toHaveURL(/\/bulk\/import\/metric\/\*/); - - await uploadMetricCsvAndWaitForPreview(page, csvPath); - await expect(page.getByText(updatedDisplayName)).toBeVisible(); - - const applyResponse = waitForMetricImportResponse(page, false); - await page.getByRole('button', { name: /Start Import/i }).click(); - const response = await applyResponse; - expect(response.ok()).toBeTruthy(); - await expectMetricImportStatus(page, { - processed: '1', - passed: '1', - failed: '0', - }); + const page = await browser.newPage(); + await metricExportUser.login(page); + try { + const existingMetricName = fixtures.metrics[1].name; + const updatedDisplayName = `${fixtures.prefix} Import Updated`; + const csv = createCsv([ + [ + existingMetricName, + updatedDisplayName, + 'Metric updated from Playwright CSV', + 'COUNT', + 'COUNT', + '', + 'MONTH', + 'SQL', + 'COUNT(order_id)', + fixtures.metrics[0].fullyQualifiedName, + fixtures.secondTag.fullyQualifiedName, + fixtures.nestedGlossaryTerm.fullyQualifiedName, + 'Tier.Tier3', + `user:${fixtures.owner.name}`, + `team:${fixtures.reviewer.name}`, + fixtures.domain.fullyQualifiedName, + fixtures.dataProduct.fullyQualifiedName, + 'Approved', + `${metricCustomPropertyName}:updated custom value`, + ], + ]); + const csvPath = test + .info() + .outputPath(`${existingMetricName}-update.csv`); + fs.writeFileSync(csvPath, csv); + + await redirectToHomePage(page); + await waitForMetricsPage(page); + await openMetricActions(page); + await clickMetricAction(page, 'Import'); + await expect(page).toHaveURL(/\/bulk\/import\/metric\/\*/); + + await uploadMetricCsvAndWaitForPreview(page, csvPath); + await expect(page.getByText(updatedDisplayName)).toBeVisible(); + + const applyResponse = waitForMetricImportResponse(page, false); + await page.getByRole('button', { name: /Start Import/i }).click(); + const response = await applyResponse; + expect(response.status()).toBe(200); + await expectMetricImportStatus(page, { + processed: '1', + passed: '1', + failed: '0', + }); - const updatedMetric = await parseResponse( - await apiContext.get( - `/api/v1/metrics/name/${existingMetricName}?fields=extension&include=all` - ), - 'fetch CSV-updated metric' - ); - expect(updatedMetric.displayName).toBe(updatedDisplayName); - expect(updatedMetric.extension).toMatchObject({ - [metricCustomPropertyName]: 'updated custom value', - }); + const updatedMetric = await parseResponse( + await apiContext.get( + `/api/v1/metrics/name/${existingMetricName}?fields=extension&include=all` + ), + 'fetch CSV-updated metric' + ); + expect(updatedMetric.displayName).toBe(updatedDisplayName); + expect(updatedMetric.extension).toMatchObject({ + [metricCustomPropertyName]: 'updated custom value', + }); + } finally { + await page.close(); + } }); test('Admin bulk edits filtered metrics from the listing API without export jobs', async ({ @@ -1198,7 +1222,7 @@ test.describe( const updateResponse = waitForMetricImportResponse(page, false); await page.getByRole('button', { name: 'Update' }).click(); const response = await updateResponse; - expect(response.ok()).toBeTruthy(); + expect(response.status()).toBe(200); await page.waitForURL(/\/metrics/, { timeout: 90000 }); const updatedMetric = await parseResponse( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts index 033c556896fd..81c682d49473 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/SearchExport.spec.ts @@ -11,9 +11,14 @@ * limitations under the License. */ -import { APIRequestContext, expect, Page } from '@playwright/test'; +import { APIRequestContext, expect, Page, test } from '@playwright/test'; +import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; -import { clickOutside, redirectToExplorePage } from '../../utils/common'; +import { + clickOutside, + getApiContext, + redirectToExplorePage, +} from '../../utils/common'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { clickUpdateButtonIfVisible, @@ -22,7 +27,11 @@ import { getExportModalContent, openExportScopeModal, } from '../../utils/explore'; -import { test } from '../fixtures/pages'; + +// Dedicated admin user so that completed search-export background jobs +// accumulate in this user's tray instead of the shared admin session, +// preventing the tray from blocking other admin tests in the same worker. +let searchExportUser: UserClass; const startAsyncExport = async (page: Page) => { const exportAsyncPromise = page.waitForResponse( @@ -111,12 +120,24 @@ test.describe( headers: { 'Content-Type': 'application/json-patch+json' }, } ); + } + + searchExportUser = new UserClass(undefined, true); + await searchExportUser.create(apiContext); + + await afterAction(); + }); + test.afterAll(async ({ browser }) => { + if (searchExportUser) { + const { apiContext, afterAction } = await performAdminLogin(browser); + await searchExportUser.delete(apiContext); await afterAction(); } }); test.beforeEach(async ({ page }) => { + await searchExportUser.login(page); await redirectToExplorePage(page); }); @@ -182,7 +203,6 @@ test.describe( test('Search mode visible export downloads CSV with tab-specific row count', async ({ page, - browser, }) => { test.slow(); @@ -209,7 +229,7 @@ test.describe( const jobId = await startAsyncExport(page); await test.step('CSV row count matches the displayed tab count', async () => { - const { apiContext, afterAction } = await performAdminLogin(browser); + const { apiContext, afterAction } = await getApiContext(page); const csvText = await fetchCompletedExportCsv(apiContext, jobId); expect(countCsvResponseRows(csvText)).toBe(expectedCount); @@ -261,7 +281,6 @@ test.describe( test('Filtered search visible export downloads CSV with the filtered record count', async ({ page, - browser, }) => { test.slow(); @@ -330,7 +349,7 @@ test.describe( const jobId = await startAsyncExport(page); await test.step('CSV row count matches the filtered record count', async () => { - const { apiContext, afterAction } = await performAdminLogin(browser); + const { apiContext, afterAction } = await getApiContext(page); const csvText = await fetchCompletedExportCsv(apiContext, jobId); expect(countCsvResponseRows(csvText)).toBe(filteredCount); @@ -341,7 +360,6 @@ test.describe( test('Browse mode visible export downloads CSV with current page row count', async ({ page, - browser, }) => { test.slow(); @@ -379,7 +397,7 @@ test.describe( const jobId = await startAsyncExport(page); await test.step('CSV row count matches the displayed page count', async () => { - const { apiContext, afterAction } = await performAdminLogin(browser); + const { apiContext, afterAction } = await getApiContext(page); const csvText = await fetchCompletedExportCsv(apiContext, jobId); expect(countCsvResponseRows(csvText)).toBe(expectedCount); @@ -429,7 +447,6 @@ test.describe( test('Export queues a background job and downloads from the jobs tray', async ({ page, - browser, }) => { test.slow(); @@ -489,10 +506,11 @@ test.describe( // API first (the same way fetchCompletedExportCsv does), so a stalled job is // named as such and the UI waits that follow are short. // - // performAdminLogin, not page.request: the latter carries the page's cookies - // but not the bearer token these endpoints need, so it returns an error object - // rather than the job array. - const { apiContext, afterAction } = await performAdminLogin(browser); + // getApiContext(page), not page.request: page.request carries cookies but + // not the bearer token the csvAsyncJobs endpoint requires. getApiContext + // extracts the token from the page's storage so the request is authenticated + // as searchExportUser — the same user who created the job. + const { apiContext, afterAction } = await getApiContext(page); await waitForExportJobCompleted(apiContext, jobId); await afterAction(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts index 9ebdeb58a9ad..fa73dbf53216 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/GlossaryImportExport.spec.ts @@ -48,10 +48,11 @@ import { } from '../../utils/importUtils'; import { settingClick, sidebarClick } from '../../utils/sidebar'; -// use the admin user to login -test.use({ - storageState: 'playwright/.auth/admin.json', -}); +// Dedicated admin user for glossary import/export tests. Using a fresh user +// instead of the shared admin.json session prevents completed export/import +// jobs from accumulating in the admin background-jobs tray and blocking other +// admin tests that run in the same CI worker. +const glossaryExportUser = new UserClass(undefined, true); const user1 = new UserClass(); const user2 = new UserClass(); @@ -108,6 +109,7 @@ test.describe('Glossary Bulk Import Export', { tag: '@import-export' }, () => { test.beforeAll('setup pre-test', async () => { const { apiContext, afterAction } = await createAdminApiContext(); + await glossaryExportUser.create(apiContext); await user1.create(apiContext); await user2.create(apiContext); await user3.create(apiContext); @@ -122,6 +124,7 @@ test.describe('Glossary Bulk Import Export', { tag: '@import-export' }, () => { test.afterAll('Cleanup', async () => { const { apiContext, afterAction } = await createAdminApiContext(); + await glossaryExportUser.delete(apiContext); await user1.delete(apiContext); await user2.delete(apiContext); await user3.delete(apiContext); @@ -132,6 +135,7 @@ test.describe('Glossary Bulk Import Export', { tag: '@import-export' }, () => { }); test.beforeEach(async ({ page }) => { + await glossaryExportUser.login(page); await redirectToHomePage(page); });