mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-27 08:44:49 +00:00
* feat: add domain icon and comma-separated domain names * fix integration test id * Revamp domain render on search card * fix unit test unused props * fix integration test * nit * fix minor unused props * Fix all failing integration test * nit * Fix domain propagation test * Change font size for domain count * fix overflow count number --------- Co-authored-by: Anujkumar Yadav <anujkumaryadav@Anujkumars-MacBook-Pro.local> (cherry picked from commit 76c4e371a9384f83c35cd490ecea1e08db7549e6)
534 lines
16 KiB
TypeScript
534 lines
16 KiB
TypeScript
/*
|
|
* Copyright 2024 Collate.
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
import { Browser, expect, Locator, Page, request } from '@playwright/test';
|
|
import { randomUUID } from 'crypto';
|
|
import { SidebarItem } from '../constant/sidebar';
|
|
import { adjectives, nouns } from '../constant/user';
|
|
import { Domain } from '../support/domain/Domain';
|
|
import { sidebarClick } from './sidebar';
|
|
|
|
export const uuid = () => randomUUID().split('-')[0];
|
|
export const fullUuid = () => randomUUID();
|
|
|
|
export const descriptionBox = '.om-block-editor[contenteditable="true"]';
|
|
export const descriptionBoxReadOnly =
|
|
'.om-block-editor[contenteditable="false"]';
|
|
|
|
export const INVALID_NAMES = {
|
|
MAX_LENGTH:
|
|
'a87439625b1c2d3e4f5061728394a5b6c7d8e90a1b2c3d4e5f67890aba87439625b1c2d3e4f5061728394a5b6c7d8e90a1b2c3d4e5f67890abName can be a maximum of 128 characters',
|
|
WITH_SPECIAL_CHARS: '::normalName::',
|
|
};
|
|
|
|
export const NAME_VALIDATION_ERROR =
|
|
'Name must contain only letters, numbers, underscores, hyphens, periods, parenthesis, and ampersands.';
|
|
|
|
export const NAME_MIN_MAX_LENGTH_VALIDATION_ERROR =
|
|
'Name size must be between 2 and 64';
|
|
|
|
export const NAME_MAX_LENGTH_VALIDATION_ERROR =
|
|
'Name can be a maximum of 128 characters';
|
|
|
|
export const getToken = async (page: Page) => {
|
|
return page.evaluate(
|
|
() =>
|
|
JSON.parse(localStorage.getItem('om-session') ?? '{}')?.oidcIdToken ?? ''
|
|
);
|
|
};
|
|
|
|
export const getAuthContext = async (token: string) => {
|
|
return await request.newContext({
|
|
// Default timeout is 30s making it to 1m for AUTs
|
|
timeout: 90000,
|
|
extraHTTPHeaders: {
|
|
Connection: 'keep-alive',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
};
|
|
|
|
export const redirectToHomePage = async (page: Page) => {
|
|
await page.goto('/');
|
|
await page.waitForURL('**/my-data');
|
|
await page.waitForLoadState('networkidle');
|
|
};
|
|
|
|
export const redirectToExplorePage = async (page: Page) => {
|
|
await page.goto('/explore');
|
|
await page.waitForURL('**/explore');
|
|
await page.waitForLoadState('networkidle');
|
|
};
|
|
|
|
export const removeLandingBanner = async (page: Page) => {
|
|
try {
|
|
const welcomePageCloseButton = await page
|
|
.waitForSelector('[data-testid="welcome-screen-close-btn"]', {
|
|
state: 'visible',
|
|
timeout: 5000,
|
|
})
|
|
.catch(() => {
|
|
// Do nothing if the welcome banner does not exist
|
|
return;
|
|
});
|
|
|
|
// Close the welcome banner if it exists
|
|
if (welcomePageCloseButton?.isVisible()) {
|
|
await welcomePageCloseButton.click();
|
|
}
|
|
} catch {
|
|
// Do nothing if the welcome banner does not exist
|
|
return;
|
|
}
|
|
};
|
|
|
|
export const createNewPage = async (browser: Browser) => {
|
|
// create a new page
|
|
const page = await browser.newPage();
|
|
await redirectToHomePage(page);
|
|
|
|
// get the token from localStorage
|
|
const token = await getToken(page);
|
|
|
|
// create a new context with the token
|
|
const apiContext = await getAuthContext(token);
|
|
|
|
const afterAction = async () => {
|
|
await apiContext.dispose();
|
|
await page.close();
|
|
};
|
|
|
|
return { page, apiContext, afterAction };
|
|
};
|
|
|
|
/**
|
|
* Retrieves the API context for the given page.
|
|
* @param page The Playwright page object.
|
|
* @returns An object containing the API context and a cleanup function.
|
|
*/
|
|
export const getApiContext = async (page: Page) => {
|
|
const token = await getToken(page);
|
|
const apiContext = await getAuthContext(token);
|
|
const afterAction = async () => await apiContext.dispose();
|
|
|
|
return { apiContext, afterAction };
|
|
};
|
|
|
|
const DASHBOARD_DATA_MODEL = 'DashboardDataModel';
|
|
|
|
export const getEntityTypeSearchIndexMapping = (entityType: string) => {
|
|
const entityMapping = {
|
|
Table: 'table_search_index',
|
|
Topic: 'topic_search_index',
|
|
Dashboard: 'dashboard_search_index',
|
|
Pipeline: 'pipeline_search_index',
|
|
MlModel: 'mlmodel_search_index',
|
|
Container: 'container_search_index',
|
|
SearchIndex: 'search_entity_search_index',
|
|
ApiEndpoint: 'api_endpoint_search_index',
|
|
Metric: 'metric_search_index',
|
|
[DASHBOARD_DATA_MODEL]: 'dashboard_data_model_search_index',
|
|
};
|
|
|
|
return entityMapping[entityType as keyof typeof entityMapping];
|
|
};
|
|
|
|
export const toastNotification = async (
|
|
page: Page,
|
|
message: string | RegExp,
|
|
timeout?: number
|
|
) => {
|
|
await page.waitForSelector('[data-testid="alert-bar"]', {
|
|
state: 'visible',
|
|
});
|
|
|
|
await expect(page.getByTestId('alert-bar')).toHaveText(message, { timeout });
|
|
|
|
await expect(page.getByTestId('alert-icon')).toBeVisible();
|
|
|
|
await expect(page.getByTestId('alert-icon-close')).toBeVisible();
|
|
};
|
|
|
|
export const clickOutside = async (page: Page) => {
|
|
await page.locator('body').click({
|
|
position: {
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
});
|
|
};
|
|
|
|
export const visitOwnProfilePage = async (page: Page) => {
|
|
await page.locator('[data-testid="dropdown-profile"] svg').click();
|
|
await page.waitForSelector('[role="menu"].profile-dropdown', {
|
|
state: 'visible',
|
|
});
|
|
const userResponse = page.waitForResponse(
|
|
'/api/v1/users/name/*?fields=*&include=all'
|
|
);
|
|
await page.getByTestId('user-name').click();
|
|
await userResponse;
|
|
await clickOutside(page);
|
|
};
|
|
|
|
export const assignDomain = async (
|
|
page: Page,
|
|
domain: { name: string; displayName: string; fullyQualifiedName?: string }
|
|
) => {
|
|
await page.getByTestId('add-domain').click();
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
const searchDomain = page.waitForResponse(
|
|
`/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*`
|
|
);
|
|
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('searchbar')
|
|
.fill(domain.name);
|
|
|
|
await searchDomain;
|
|
|
|
// Wait for the tag element to be visible and ensure page is still valid
|
|
const tagSelector = page.getByTestId(`tag-${domain.fullyQualifiedName}`);
|
|
await tagSelector.waitFor({ state: 'visible' });
|
|
await tagSelector.click();
|
|
|
|
const patchReq = page.waitForResponse(
|
|
(req) => req.request().method() === 'PATCH'
|
|
);
|
|
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('saveAssociatedTag')
|
|
.click();
|
|
await patchReq;
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await expect(page.getByTestId('domain-link')).toContainText(
|
|
domain.displayName
|
|
);
|
|
};
|
|
|
|
export const updateDomain = async (
|
|
page: Page,
|
|
domain: { name: string; displayName: string; fullyQualifiedName?: string }
|
|
) => {
|
|
await page.getByTestId('add-domain').click();
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('searchbar')
|
|
.clear();
|
|
|
|
const searchDomain = page.waitForResponse(
|
|
`/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*`
|
|
);
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('searchbar')
|
|
.fill(domain.name);
|
|
await searchDomain;
|
|
|
|
await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click();
|
|
|
|
const patchReq = page.waitForResponse(
|
|
(req) => req.request().method() === 'PATCH'
|
|
);
|
|
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('saveAssociatedTag')
|
|
.click();
|
|
await patchReq;
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await expect(page.getByTestId('header-domain-container')).toContainText('+1');
|
|
|
|
await page.getByTestId('header-domain-container').getByText('+1').hover();
|
|
|
|
await expect(
|
|
page.getByRole('menuitem', { name: domain.displayName })
|
|
).toBeVisible();
|
|
};
|
|
|
|
export const removeDomain = async (
|
|
page: Page,
|
|
domain: { name: string; displayName: string; fullyQualifiedName?: string }
|
|
) => {
|
|
await page.getByTestId('add-domain').click();
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await page.getByTestId(`tag-${domain.fullyQualifiedName}`).click();
|
|
|
|
const patchReq = page.waitForResponse(
|
|
(req) => req.request().method() === 'PATCH'
|
|
);
|
|
|
|
await page
|
|
.getByTestId('domain-selectable-tree')
|
|
.getByTestId('saveAssociatedTag')
|
|
.click();
|
|
await patchReq;
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await expect(page.getByTestId('no-domain-text')).toContainText('No Domains');
|
|
};
|
|
|
|
export const assignDataProduct = async (
|
|
page: Page,
|
|
domain: { name: string; displayName: string; fullyQualifiedName?: string },
|
|
dataProduct: {
|
|
name: string;
|
|
displayName: string;
|
|
fullyQualifiedName?: string;
|
|
},
|
|
action: 'Add' | 'Edit' = 'Add',
|
|
parentId = 'KnowledgePanel.DataProducts'
|
|
) => {
|
|
await page
|
|
.getByTestId(parentId)
|
|
.getByTestId('data-products-container')
|
|
.getByTestId(action === 'Add' ? 'add-data-product' : 'edit-button')
|
|
.click();
|
|
|
|
const searchDataProduct = page.waitForResponse(
|
|
`/api/v1/search/query?q=*${encodeURIComponent(domain.name)}*`
|
|
);
|
|
|
|
await page
|
|
.locator('[data-testid="data-product-selector"] input')
|
|
.fill(dataProduct.displayName);
|
|
await searchDataProduct;
|
|
await page.getByTestId(`tag-${dataProduct.fullyQualifiedName}`).click();
|
|
|
|
await expect(
|
|
page
|
|
.getByTestId('data-product-dropdown-actions')
|
|
.getByTestId('saveAssociatedTag')
|
|
).toBeEnabled();
|
|
|
|
const patchReq = page.waitForResponse(
|
|
(req) => req.request().method() === 'PATCH'
|
|
);
|
|
|
|
await page
|
|
.getByTestId('data-product-dropdown-actions')
|
|
.getByTestId('saveAssociatedTag')
|
|
.click();
|
|
await patchReq;
|
|
|
|
await expect(
|
|
page
|
|
.getByTestId(parentId)
|
|
.getByTestId('data-products-list')
|
|
.getByTestId(`data-product-${dataProduct.fullyQualifiedName}`)
|
|
).toBeVisible();
|
|
};
|
|
|
|
export const removeDataProduct = async (
|
|
page: Page,
|
|
dataProduct: {
|
|
name: string;
|
|
displayName: string;
|
|
fullyQualifiedName?: string;
|
|
}
|
|
) => {
|
|
await page
|
|
.getByTestId('KnowledgePanel.DataProducts')
|
|
.getByTestId('data-products-container')
|
|
.getByTestId('edit-button')
|
|
.click();
|
|
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
|
|
await page
|
|
.getByTestId(`selected-tag-${dataProduct.fullyQualifiedName}`)
|
|
.getByTestId('remove-tags')
|
|
.locator('svg')
|
|
.click();
|
|
|
|
await expect(
|
|
page
|
|
.getByTestId('data-product-dropdown-actions')
|
|
.getByTestId('saveAssociatedTag')
|
|
).toBeEnabled();
|
|
|
|
const patchReq = page.waitForResponse(
|
|
(req) => req.request().method() === 'PATCH'
|
|
);
|
|
|
|
await page
|
|
.getByTestId('data-product-dropdown-actions')
|
|
.getByTestId('saveAssociatedTag')
|
|
.click();
|
|
await patchReq;
|
|
|
|
await expect(
|
|
page
|
|
.getByTestId('KnowledgePanel.DataProducts')
|
|
.getByTestId('data-products-list')
|
|
.getByTestId(`data-product-${dataProduct.fullyQualifiedName}`)
|
|
).not.toBeVisible();
|
|
};
|
|
|
|
export const visitGlossaryPage = async (page: Page, glossaryName: string) => {
|
|
await redirectToHomePage(page);
|
|
const glossaryResponse = page.waitForResponse('/api/v1/glossaries?fields=*');
|
|
await sidebarClick(page, SidebarItem.GLOSSARY);
|
|
await glossaryResponse;
|
|
await page.getByRole('menuitem', { name: glossaryName }).click();
|
|
await page.waitForLoadState('networkidle');
|
|
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
|
|
};
|
|
|
|
export const getRandomFirstName = () => {
|
|
return `${
|
|
adjectives[Math.floor(Math.random() * adjectives.length)]
|
|
}${uuid()}`;
|
|
};
|
|
export const getRandomLastName = () => {
|
|
return `${nouns[Math.floor(Math.random() * nouns.length)]}${uuid()}`;
|
|
};
|
|
|
|
export const generateRandomUsername = (prefix = '') => {
|
|
const firstName = `${prefix}${getRandomFirstName()}`;
|
|
const lastName = `${prefix}${getRandomLastName()}`;
|
|
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: `${firstName}.${lastName}@example.com`,
|
|
password: 'User@OMD123',
|
|
};
|
|
};
|
|
|
|
export const verifyDomainLinkInCard = async (
|
|
entityCard: Locator,
|
|
domain: Domain['responseData']
|
|
) => {
|
|
const domainLink = entityCard.getByTestId('domain-link').filter({
|
|
hasText: domain.displayName,
|
|
});
|
|
|
|
await expect(domainLink).toBeVisible();
|
|
await expect(domainLink).toContainText(domain.displayName);
|
|
|
|
const href = await domainLink.getAttribute('href');
|
|
|
|
expect(href).toContain('/domain/');
|
|
await expect(domainLink).toBeEnabled();
|
|
};
|
|
|
|
export const verifyDomainPropagation = async (
|
|
page: Page,
|
|
domain: Domain['responseData'],
|
|
childFqnSearchTerm: string
|
|
) => {
|
|
await page.getByTestId('searchBox').fill(childFqnSearchTerm);
|
|
await page.getByTestId('searchBox').press('Enter');
|
|
await page.waitForSelector(`[data-testid*="table-data-card"]`);
|
|
|
|
const entityCard = page.getByTestId(`table-data-card_${childFqnSearchTerm}`);
|
|
|
|
await expect(entityCard).toBeVisible();
|
|
|
|
const domainLink = entityCard.getByTestId('domain-link').first();
|
|
|
|
await expect(domainLink).toBeVisible();
|
|
await expect(domainLink).toContainText(domain.displayName);
|
|
};
|
|
|
|
export const replaceAllSpacialCharWith_ = (text: string) => {
|
|
return text.replaceAll(/[&/\\#, +()$~%.'":*?<>{}]/g, '_');
|
|
};
|
|
|
|
// Since the tests run in parallel sometimes the error toast alert pops up
|
|
// Stating the domain or glossary does not exist since it's deleted in other test
|
|
// This error toast blocks the buttons at the top
|
|
// Below logic closes the alert if it's present to avoid flakiness in tests
|
|
export const closeFirstPopupAlert = async (page: Page) => {
|
|
const toastElement = page.getByTestId('alert-bar');
|
|
|
|
if ((await toastElement.count()) > 0) {
|
|
await page.getByTestId('alert-icon-close').first().click();
|
|
}
|
|
};
|
|
|
|
export const reloadAndWaitForNetworkIdle = async (page: Page) => {
|
|
await page.reload();
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await page.waitForSelector('[data-testid="loader"]', {
|
|
state: 'detached',
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Utility function to handle API calls with retry logic for connection-related errors.
|
|
* This is particularly useful for cleanup operations that might fail due to network issues.
|
|
*
|
|
* @param apiCall - The API call function to execute
|
|
* @param operationName - Name of the operation for logging purposes
|
|
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
|
* @param baseDelay - Base delay in milliseconds for exponential backoff (default: 1000)
|
|
* @returns The result of the API call if successful
|
|
*/
|
|
export const executeWithRetry = async <T>(
|
|
apiCall: () => Promise<T>,
|
|
operationName: string,
|
|
maxRetries = 3,
|
|
baseDelay = 1000
|
|
): Promise<T | void> => {
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
return await apiCall();
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
|
|
// Check if it's a retriable error (connection-related issues)
|
|
const isRetriableError =
|
|
errorMessage.includes('socket hang up') ||
|
|
errorMessage.includes('ECONNRESET') ||
|
|
errorMessage.includes('ENOTFOUND') ||
|
|
errorMessage.includes('ETIMEDOUT') ||
|
|
errorMessage.includes('Connection refused') ||
|
|
errorMessage.includes('ECONNREFUSED');
|
|
|
|
if (isRetriableError && attempt < maxRetries - 1) {
|
|
// Exponential backoff: 1s, 2s, 4s
|
|
const delay = baseDelay * Math.pow(2, attempt);
|
|
// eslint-disable-next-line no-console
|
|
console.log(
|
|
`${operationName} attempt ${
|
|
attempt + 1
|
|
} failed with retriable error: ${errorMessage}. Retrying in ${delay}ms...`
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
|
|
continue;
|
|
} else {
|
|
// eslint-disable-next-line no-console
|
|
console.error(
|
|
`Failed to ${operationName} after ${attempt + 1} attempts:`,
|
|
errorMessage
|
|
);
|
|
|
|
// Don't throw the error to prevent test failures - just log it
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
};
|