diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx index 188c7d352..65a841406 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/NavigationControls.tsx @@ -19,9 +19,21 @@ const NavigationControls: React.FC = ({ isPreviousDisabled, }) => ( - - - + + + ); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx index a25663813..d56031fed 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.test.tsx @@ -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; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx index f585c860f..d82f02f56 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/AccountDropdown.tsx @@ -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 = () => { 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 = () => { 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 = () => { const accountOptions = sqlApiOnlyAccounts?.map((account) => ({ - key: normalizeAccountId(account.id), + key: account.id, text: account.name, data: account, })) || []; diff --git a/test/fx.ts b/test/fx.ts index 967e88396..783e4e1b0 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -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 { + 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 { - 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 { 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 { + 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 { - 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(); - }, - ); - } } diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts index bd0794d29..a7b05b07e 100644 --- a/test/sql/containercopy.spec.ts +++ b/test/sql/containercopy.spec.ts @@ -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(); diff --git a/test/sql/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index da9b422ef..a7479d394 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -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();