Refactor dropdown utilities and fix test helper functions

This commit is contained in:
Bikram Choudhury
2025-12-30 18:26:10 +05:30
parent fda6b59bc1
commit 8c67026760
6 changed files with 242 additions and 154 deletions

View File

@@ -19,9 +19,21 @@ const NavigationControls: React.FC<NavigationControlsProps> = ({
isPreviousDisabled,
}) => (
<Stack horizontal tokens={{ childrenGap: 20 }}>
<PrimaryButton text={primaryBtnText} onClick={onPrimary} allowDisabledFocus disabled={isPrimaryDisabled} />
<DefaultButton text="Previous" onClick={onPrevious} allowDisabledFocus disabled={isPreviousDisabled} />
<DefaultButton text="Cancel" onClick={onCancel} />
<PrimaryButton
data-test="copy-job-primary"
text={primaryBtnText}
onClick={onPrimary}
allowDisabledFocus
disabled={isPrimaryDisabled}
/>
<DefaultButton
data-test="copy-job-previous"
text="Previous"
onClick={onPrevious}
allowDisabledFocus
disabled={isPreviousDisabled}
/>
<DefaultButton data-test="copy-job-cancel" text="Cancel" onClick={onCancel} />
</Stack>
);

View File

@@ -9,7 +9,7 @@ import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { CopyJobContextProviderType, CopyJobContextState } from "../../../../Types/CopyJobTypes";
import { AccountDropdown } from "./AccountDropdown";
import { AccountDropdown, normalizeAccountId } from "./AccountDropdown";
jest.mock("../../../../../../hooks/useDatabaseAccounts");
jest.mock("../../../../../../UserContext", () => ({
@@ -202,13 +202,16 @@ describe("AccountDropdown", () => {
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction(mockCopyJobState);
expect(newState.source.account).toBe(mockDatabaseAccount1);
expect(newState.source.account).toEqual({
...mockDatabaseAccount1,
id: normalizeAccountId(mockDatabaseAccount1.id),
});
});
it("should auto-select predefined account from userContext if available", async () => {
const userContextAccount = {
...mockDatabaseAccount2,
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account2",
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account2",
};
(userContext as any).databaseAccount = userContextAccount;
@@ -223,7 +226,10 @@ describe("AccountDropdown", () => {
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction(mockCopyJobState);
expect(newState.source.account).toBe(mockDatabaseAccount2);
expect(newState.source.account).toEqual({
...mockDatabaseAccount2,
id: normalizeAccountId(mockDatabaseAccount2.id),
});
});
it("should keep current account if it exists in the filtered list", async () => {
@@ -248,7 +254,16 @@ describe("AccountDropdown", () => {
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState);
expect(newState).toBe(contextWithSelectedAccount.copyJobState);
expect(newState).toEqual({
...contextWithSelectedAccount.copyJobState,
source: {
...contextWithSelectedAccount.copyJobState.source,
account: {
...mockDatabaseAccount1,
id: normalizeAccountId(mockDatabaseAccount1.id),
},
},
});
});
it("should handle account change when user selects different account", async () => {
@@ -272,7 +287,7 @@ describe("AccountDropdown", () => {
it("should normalize account ID for Portal platform", () => {
const portalAccount = {
...mockDatabaseAccount1,
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDb/databaseAccounts/account1",
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/account1",
};
(configContext as any).platform = Platform.Portal;

View File

@@ -12,7 +12,7 @@ import FieldRow from "../../Components/FieldRow";
interface AccountDropdownProps {}
const normalizeAccountId = (id: string) => {
export const normalizeAccountId = (id: string = "") => {
if (configContext.platform === Platform.Portal) {
return id.replace("/Microsoft.DocumentDb/", "/Microsoft.DocumentDB/");
} else if (configContext.platform === Platform.Hosted) {
@@ -27,7 +27,12 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
const sqlApiOnlyAccounts: DatabaseAccount[] = (allAccounts || []).filter((account) => apiType(account) === "SQL");
const sqlApiOnlyAccounts = (allAccounts || [])
.filter((account) => apiType(account) === "SQL")
.map((account) => ({
...account,
id: normalizeAccountId(account.id),
}));
const updateCopyJobState = (newAccount: DatabaseAccount) => {
setCopyJobState((prevState) => {
@@ -47,9 +52,8 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
useEffect(() => {
if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) {
const currentAccountId = copyJobState?.source?.account?.id;
const predefinedAccountId = userContext.databaseAccount?.id;
const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id);
const selectedAccountId = currentAccountId || predefinedAccountId;
const targetAccount: DatabaseAccount | null =
sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null;
updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]);
@@ -58,7 +62,7 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
const accountOptions =
sqlApiOnlyAccounts?.map((account) => ({
key: normalizeAccountId(account.id),
key: account.id,
text: account.name,
data: account,
})) || [];

View File

@@ -185,6 +185,39 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
@@ -490,15 +523,6 @@ export class DataExplorer {
return this.frame.getByTestId("notification-console/header-status");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
@@ -527,6 +551,80 @@ export class DataExplorer {
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
return page.waitForResponse(async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
});
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
@@ -544,77 +642,4 @@ export class ContainerCopy {
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
static async waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
return page.waitForResponse(async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
});
}
static async interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
}

View File

@@ -3,7 +3,14 @@ import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { DatabaseAccount, Subscription } from "../../src/Contracts/DataModels";
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
import { ContainerCopy, getAccountName, TestAccount } from "../fx";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../fx";
test.describe.configure({ mode: "serial" });
let page: Page;
@@ -138,9 +145,10 @@ test("Verify Point-in-Time Restore timer and refresh button workflow", async ()
await pitrBtn.click();
page.context().on("page", async (newPage) => {
expect(newPage.url()).toMatch(
new RegExp(`/providers/Microsoft\\.Document[DB][Db]/databaseAccounts/${expectedSourceAccountName}/backupRestore`),
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
@@ -279,26 +287,16 @@ test("Loading and verifying subscription & account dropdown", async () => {
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
const subscriptionPromise = ContainerCopy.waitForApiResponse(
page,
"/Microsoft.ResourceGraph/resources",
"POST",
(payload: any) => {
return (
payload.query.includes("resources | where type == 'microsoft.documentdb/databaseaccounts'") &&
payload.query.includes("| where type == 'microsoft.resources/subscriptions'")
);
},
);
const subscriptionPromise = waitForApiResponse(page, "/Microsoft.ResourceGraph/resources", "POST", (payload: any) => {
return (
payload.query.includes("resources | where type == 'microsoft.documentdb/databaseaccounts'") &&
payload.query.includes("| where type == 'microsoft.resources/subscriptions'")
);
});
const accountPromise = ContainerCopy.waitForApiResponse(
page,
"/Microsoft.ResourceGraph/resources",
"POST",
(payload: any) => {
return payload.query.includes("resources | where type =~ 'microsoft.documentdb/databaseaccounts'");
},
);
const accountPromise = waitForApiResponse(page, "/Microsoft.ResourceGraph/resources", "POST", (payload: any) => {
return payload.query.includes("resources | where type =~ 'microsoft.documentdb/databaseaccounts'");
});
const subscriptionResponse = await subscriptionPromise;
const data = await subscriptionResponse.json();
@@ -316,11 +314,11 @@ test("Loading and verifying subscription & account dropdown", async () => {
await expect(subscriptionDropdown).toHaveText(new RegExp(selectedSubscription.subscriptionName));
await subscriptionDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Subscription");
const subscriptionDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
expect(subscriptionDropdownItems).toHaveCount(data.count);
const subscriptionItem = subscriptionDropdownItems.filter({ hasText: selectedSubscription.subscriptionName });
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: selectedSubscription.subscriptionName },
{ ariaLabel: "Subscription", itemCount: data.count },
);
await subscriptionItem.click();
const expectedAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
@@ -330,13 +328,12 @@ test("Loading and verifying subscription & account dropdown", async () => {
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const accountDropdownItemCount = await dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']").count();
expect(accountDropdownItemCount).toBeLessThanOrEqual(accountData.count);
await dropdownItemsWrapper
.locator("button.ms-Dropdown-item[role='option']", { hasText: expectedAccountName })
.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
expectedSourceSubscription = selectedSubscription;
expectedSourceAccount = selectedAccount;
@@ -369,15 +366,18 @@ test("Verifying source and target container selection", async () => {
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Database");
const sourceDbDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await sourceDbDropdownItems.first().click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 }, { ariaLabel: "Database" });
await sourceDbDropdownItem.click();
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Container");
const sourceContainerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await sourceContainerDropdownItems.first().click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
@@ -385,22 +385,32 @@ test("Verifying source and target container selection", async () => {
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Database");
const targetDbDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await targetDbDropdownItems.first().click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(frame, { position: 0 }, { ariaLabel: "Database" });
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Container");
const targetContainerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await targetContainerDropdownItems.first().click();
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Reselect target container to be different from source container
await targetContainerDropdown.click();
await targetContainerDropdownItems.nth(1).click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
@@ -440,7 +450,7 @@ test("Testing API request interception with duplicate job name", async () => {
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await ContainerCopy.interceptAndInspectApiRequest(
await interceptAndInspectApiRequest(
page,
`${expectedSourceAccount.name}/dataTransferJobs/${duplicateJobName}`,
"PUT",
@@ -472,7 +482,7 @@ test("Testing API request success with valid job name and verifying copy job cre
const validJobName = expectedJobName;
const copyJobCreationPromise = ContainerCopy.waitForApiResponse(
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedSourceAccount.name}/dataTransferJobs/${validJobName}`,
"PUT",
@@ -551,23 +561,41 @@ test("Cancel a copy job", async () => {
// Select source containers quickly
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
await sourceDatabaseDropdown.click();
await dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']").first().click();
const sourceDatabaseDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDatabaseDropdownItem.click();
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
await sourceContainerDropdown.click();
await dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']").first().click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
// Select target containers
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
await dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']").first().click();
const targetDatabaseDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDatabaseDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
await targetContainerDropdown.click();
await dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']").nth(1).click();
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem.click();
await panel.getByRole("button", { name: "Next" }).click();

View File

@@ -1,5 +1,5 @@
import { expect, Page, test } from "@playwright/test";
import { DataExplorer, TestAccount } from "../../fx";
import { DataExplorer, getDropdownItemByNameOrPosition, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Change Partition Key", () => {
@@ -81,7 +81,11 @@ test.describe("Change Partition Key", () => {
await changePkPanel.getByLabel("Use existing container").check();
await changePkPanel.getByText("Choose an existing container").click();
const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers");
const containerDropdownItem = await getDropdownItemByNameOrPosition(
explorer.frame,
{ name: newContainerId },
{ ariaLabel: "Existing Containers" },
);
await containerDropdownItem.click();
await changePkPanel.getByTestId("Panel/OkButton").click();