diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
index 27175de68..7f5537392 100644
--- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
+++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts
@@ -173,5 +173,10 @@ export default {
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
+ dialog: {
+ heading: "",
+ confirmButtonText: "Confirm",
+ cancelButtonText: "Cancel",
+ },
},
};
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx
index f8cad1cd5..abb614042 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable jest/no-conditional-expect */
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
@@ -5,6 +6,20 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
import CopyJobActionMenu from "./CopyJobActionMenu";
+const mockShowOkCancelModalDialog = jest.fn();
+const mockCloseDialog = jest.fn();
+const mockOpenDialog = jest.fn();
+
+jest.mock("../../../Controls/Dialog", () => ({
+ useDialog: {
+ getState: () => ({
+ showOkCancelModalDialog: mockShowOkCancelModalDialog,
+ closeDialog: mockCloseDialog,
+ openDialog: mockOpenDialog,
+ }),
+ },
+}));
+
jest.mock("../../ContainerCopyMessages", () => ({
__esModule: true,
default: {
@@ -18,6 +33,11 @@ jest.mock("../../ContainerCopyMessages", () => ({
cancel: "Cancel",
complete: "Complete",
},
+ dialog: {
+ heading: "Confirm Action",
+ confirmButtonText: "Confirm",
+ cancelButtonText: "Cancel",
+ },
},
},
}));
@@ -50,6 +70,9 @@ describe("CopyJobActionMenu", () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockShowOkCancelModalDialog.mockClear();
+ mockCloseDialog.mockClear();
+ mockOpenDialog.mockClear();
});
describe("Component Rendering", () => {
@@ -266,7 +289,29 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
});
- it("should call handleClick when cancel action is clicked", () => {
+ it("should show confirmation dialog when cancel action is clicked", () => {
+ const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
+ "Confirm Action",
+ null,
+ "Confirm",
+ expect.any(Function),
+ "Cancel",
+ null,
+ expect.any(Object), // dialogBody content
+ );
+ });
+
+ it("should call handleClick when dialog is confirmed for cancel action", () => {
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
render();
@@ -277,6 +322,9 @@ describe("CopyJobActionMenu", () => {
const cancelButton = screen.getByText("Cancel");
fireEvent.click(cancelButton);
+ const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
+ onOkCallback();
+
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
});
@@ -294,7 +342,33 @@ describe("CopyJobActionMenu", () => {
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
});
- it("should call handleClick when complete action is clicked", () => {
+ it("should show confirmation dialog when complete action is clicked", () => {
+ const job = createMockJob({
+ Name: "Test Online Job",
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const completeButton = screen.getByText("Complete");
+ fireEvent.click(completeButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
+ "Confirm Action",
+ null,
+ "Confirm",
+ expect.any(Function),
+ "Cancel",
+ null,
+ expect.any(Object), // dialogBody content
+ );
+ });
+
+ it("should call handleClick when dialog is confirmed for complete action", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
Mode: CopyJobMigrationType.Online,
@@ -308,10 +382,87 @@ describe("CopyJobActionMenu", () => {
const completeButton = screen.getByText("Complete");
fireEvent.click(completeButton);
+ const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
+ onOkCallback();
+
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
});
});
+ describe("Dialog Body Content", () => {
+ it("should pass correct dialog body content for cancel action", () => {
+ const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
+ "Confirm Action",
+ null,
+ "Confirm",
+ expect.any(Function),
+ "Cancel",
+ null,
+ expect.objectContaining({
+ props: expect.objectContaining({
+ tokens: expect.any(Object),
+ children: expect.any(Array),
+ }),
+ }),
+ );
+ });
+
+ it("should pass correct dialog body content for complete action", () => {
+ const job = createMockJob({
+ Name: "OnlineTestJob",
+ Status: CopyJobStatusType.InProgress,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const completeButton = screen.getByText("Complete");
+ fireEvent.click(completeButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
+ "Confirm Action",
+ null,
+ "Confirm",
+ expect.any(Function),
+ "Cancel",
+ null,
+ expect.objectContaining({
+ props: expect.objectContaining({
+ tokens: expect.any(Object),
+ children: expect.any(Array),
+ }),
+ }),
+ );
+ });
+
+ it("should not show dialog body for actions without confirmation", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
+ });
+ });
+
describe("Disabled States During Updates", () => {
const TestComponentWrapper: React.FC<{
job: CopyJobType;
@@ -339,8 +490,13 @@ describe("CopyJobActionMenu", () => {
const pauseButton = screen.getByText("Pause");
fireEvent.click(pauseButton);
fireEvent.click(actionButton);
- const pauseButtonAfterClick = screen.getByText("Pause");
+
+ const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
expect(pauseButtonAfterClick).toBeInTheDocument();
+ expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true");
+
+ const cancelButtonAfterClick = screen.getByText("Cancel").closest("button");
+ expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true");
});
it("should not disable actions for different jobs when one is updating", () => {
@@ -360,22 +516,6 @@ describe("CopyJobActionMenu", () => {
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
- it("should properly handle multiple action types being disabled for the same job", () => {
- const job = createMockJob({ Status: CopyJobStatusType.InProgress });
- render();
- const actionButton = screen.getByRole("button", { name: "Actions" });
-
- fireEvent.click(actionButton);
- fireEvent.click(screen.getByText("Pause"));
-
- fireEvent.click(actionButton);
- fireEvent.click(screen.getByText("Cancel"));
-
- fireEvent.click(actionButton);
- expect(screen.getByText("Pause")).toBeInTheDocument();
- expect(screen.getByText("Cancel")).toBeInTheDocument();
- });
-
it("should handle complete action disabled state for online jobs", () => {
const job = createMockJob({
Status: CopyJobStatusType.InProgress,
@@ -462,6 +602,7 @@ describe("CopyJobActionMenu", () => {
expect(actionButton).toHaveAttribute("aria-label", "Actions");
expect(actionButton).toHaveAttribute("title", "Actions");
+ expect(actionButton).toHaveAttribute("role", "button");
const moreIcon = actionButton.querySelector('[data-icon-name="More"]');
expect(moreIcon || actionButton).toBeInTheDocument();
@@ -608,4 +749,129 @@ describe("CopyJobActionMenu", () => {
}).not.toThrow();
});
});
+
+ describe("Complete Coverage Tests", () => {
+ it("should handle all possible dialog scenarios", () => {
+ const dialogTests = [
+ { action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true },
+ {
+ action: CopyJobActions.complete,
+ status: CopyJobStatusType.InProgress,
+ mode: CopyJobMigrationType.Online,
+ shouldShowDialog: true,
+ },
+ { action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false },
+ { action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false },
+ ];
+
+ dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => {
+ jest.clearAllMocks();
+
+ const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` });
+ const { unmount } = render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const actionText = action.charAt(0).toUpperCase() + action.slice(1);
+ if (screen.queryByText(actionText)) {
+ fireEvent.click(screen.getByText(actionText));
+
+ if (shouldShowDialog) {
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
+ } else {
+ expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled();
+ expect(mockHandleClick).toHaveBeenCalled();
+ }
+ }
+
+ unmount();
+ });
+ });
+
+ it("should verify component handles state updates correctly", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const stateUpdater = jest.fn();
+
+ const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => {
+ setUpdatingJobAction({ jobName: job.Name, action });
+ stateUpdater(job.Name, action);
+ };
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const pauseButton = screen.getByText("Pause");
+ fireEvent.click(pauseButton);
+
+ expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause);
+ });
+ });
+
+ describe("Full Integration Coverage", () => {
+ it("should test complete workflow for cancel action with dialog", () => {
+ const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress });
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job");
+ fireEvent.click(actionButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ fireEvent.click(cancelButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith(
+ "Confirm Action", // title
+ null, // subText
+ "Confirm", // confirmLabel
+ expect.any(Function), // onOk
+ "Cancel", // cancelLabel
+ null, // onCancel
+ expect.any(Object), // contentHtml (dialogBody)
+ );
+
+ const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
+ onOkCallback();
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function));
+ });
+
+ it("should test complete workflow for complete action with dialog", () => {
+ const job = createMockJob({
+ Name: "Online Integration Job",
+ Status: CopyJobStatusType.Running,
+ Mode: CopyJobMigrationType.Online,
+ });
+
+ render();
+
+ const actionButton = screen.getByRole("button", { name: "Actions" });
+ fireEvent.click(actionButton);
+
+ const completeButton = screen.getByText("Complete");
+ fireEvent.click(completeButton);
+
+ expect(mockShowOkCancelModalDialog).toHaveBeenCalled();
+
+ const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6];
+ expect(dialogContent).toBeTruthy();
+
+ const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3];
+ onOkCallback();
+
+ expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function));
+ });
+
+ it("should maintain proper component lifecycle", () => {
+ const job = createMockJob({ Status: CopyJobStatusType.InProgress });
+ const { rerender, unmount } = render();
+
+ rerender();
+ expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument();
+
+ expect(() => unmount()).not.toThrow();
+ });
+ });
});
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
index 5d41b8595..058a717bb 100644
--- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
+++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx
@@ -1,5 +1,6 @@
-import { IconButton, IContextualMenuProps } from "@fluentui/react";
+import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
import React from "react";
+import { useDialog } from "../../../Controls/Dialog";
import ContainerCopyMessages from "../../ContainerCopyMessages";
import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums";
import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes";
@@ -9,6 +10,28 @@ interface CopyJobActionMenuProps {
handleClick: HandleJobActionClickType;
}
+const dialogBody = {
+ [CopyJobActions.cancel]: (jobName: string) => (
+
+
+ You are about to cancel {jobName} copy job.
+
+ Cancelling will stop the job immediately.
+
+ ),
+ [CopyJobActions.complete]: (jobName: string) => (
+
+
+ You are about to complete {jobName} copy job.
+
+
+ Once completed, continuous data copy will stop after any pending documents are processed. To maintain data
+ integrity, we recommend stopping updates to the source container before completing the job.
+
+
+ ),
+};
+
const CopyJobActionMenu: React.FC = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if (
@@ -22,6 +45,20 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick
return null;
}
+ const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => {
+ useDialog
+ .getState()
+ .showOkCancelModalDialog(
+ ContainerCopyMessages.MonitorJobs.dialog.heading,
+ null,
+ ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText,
+ () => handleClick(job, action, setUpdatingJobAction),
+ ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText,
+ null,
+ action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null,
+ );
+ };
+
const getMenuItems = (): IContextualMenuProps["items"] => {
const isThisJobUpdating = updatingJobAction?.jobName === job.Name;
const updatingAction = updatingJobAction?.action;
@@ -32,21 +69,21 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
iconProps: { iconName: "Pause" },
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
- disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
+ disabled: isThisJobUpdating,
},
{
key: CopyJobActions.cancel,
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
iconProps: { iconName: "Cancel" },
- onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
- disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
+ onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
+ disabled: isThisJobUpdating,
},
{
key: CopyJobActions.resume,
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
iconProps: { iconName: "Play" },
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
- disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
+ disabled: isThisJobUpdating,
},
];
@@ -67,7 +104,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick
key: CopyJobActions.complete,
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
iconProps: { iconName: "CheckMark" },
- onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
+ onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
});
}
@@ -86,8 +123,8 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick
data-test={`CopyJobActionMenu/Button:${job.Name}`}
role="button"
iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }}
- menuProps={{ items: getMenuItems() }}
- menuIconProps={{ iconName: "" }}
+ menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
+ menuIconProps={{ iconName: "", className: "hidden" }}
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
/>
diff --git a/src/Main.tsx b/src/Main.tsx
index f30ff9902..af3d462a7 100644
--- a/src/Main.tsx
+++ b/src/Main.tsx
@@ -128,6 +128,7 @@ const App = (): JSX.Element => {
<>
+
>
) : (
diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts
deleted file mode 100644
index c019b99b7..000000000
--- a/test/sql/containercopy.spec.ts
+++ /dev/null
@@ -1,493 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-import { expect, Frame, Locator, Page, test } from "@playwright/test";
-import { set } from "lodash";
-import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
-import {
- ContainerCopy,
- getAccountName,
- getDropdownItemByNameOrPosition,
- interceptAndInspectApiRequest,
- TestAccount,
- waitForApiResponse,
-} from "../fx";
-import { createMultipleTestContainers } from "../testData";
-
-let page: Page;
-let wrapper: Locator = null!;
-let panel: Locator = null!;
-let frame: Frame = null!;
-let expectedCopyJobNameInitial: string = null!;
-let expectedJobName: string = "";
-let targetAccountName: string = "";
-let expectedSourceAccountName: string = "";
-let expectedSubscriptionName: string = "";
-const VISIBLE_TIMEOUT_MS = 30 * 1000;
-
-test.describe.configure({ mode: "serial" });
-
-test.describe("Container Copy", () => {
- test.beforeAll("Container Copy - Before All", async ({ browser }) => {
- await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
-
- page = await browser.newPage();
- ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
- expectedJobName = `test_job_${Date.now()}`;
- targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
- });
-
- test.afterEach("Container Copy - After Each", async () => {
- await page.unroute(/.*/, (route) => route.continue());
- });
-
- test("Loading and verifying the content of the page", async () => {
- expect(wrapper).not.toBeNull();
- await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
- await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
- await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
- });
-
- test("Successfully create a copy job for offline migration", async () => {
- expect(wrapper).not.toBeNull();
- // Loading and verifying subscription & account dropdown
-
- const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
- await createCopyJobButton.click();
- panel = frame.getByTestId("Panel:Create copy job");
- await expect(panel).toBeVisible();
-
- await page.waitForTimeout(10 * 1000);
-
- const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
-
- const expectedAccountName = targetAccountName;
- expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
-
- await subscriptionDropdown.click();
- const subscriptionItem = await getDropdownItemByNameOrPosition(
- frame,
- { name: expectedSubscriptionName },
- { ariaLabel: "Subscription" },
- );
- await subscriptionItem.click();
-
- // Load account dropdown based on selected subscription
-
- const accountDropdown = panel.getByTestId("account-dropdown");
- await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
- await accountDropdown.click();
-
- const accountItem = await getDropdownItemByNameOrPosition(
- frame,
- { name: expectedAccountName },
- { ariaLabel: "Account" },
- );
- await accountItem.click();
-
- // Verifying online or offline checkbox functionality
- /**
- * This test verifies the functionality of the migration type checkbox that toggles between
- * online and offline container copy modes. It ensures that:
- * 1. When online mode is selected, the user is directed to a permissions screen
- * 2. When offline mode is selected, the user bypasses the permissions screen
- * 3. The UI correctly reflects the selected migration type throughout the workflow
- */
- const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
- await fluentUiCheckboxContainer.click();
- await panel.getByRole("button", { name: "Next" }).click();
- await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
- await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
- await panel.getByRole("button", { name: "Previous" }).click();
- await fluentUiCheckboxContainer.click();
- await panel.getByRole("button", { name: "Next" }).click();
- await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
- await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
-
- // Verifying source and target container selection
-
- const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
- expect(sourceContainerDropdown).toBeVisible();
- await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
-
- const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
- await sourceDatabaseDropdown.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();
- const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
- frame,
- { position: 0 },
- { ariaLabel: "Container" },
- );
- await sourceContainerDropdownItem.click();
-
- const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
- expect(targetContainerDropdown).toBeVisible();
- await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
-
- const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
- await targetDatabaseDropdown.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();
- 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();
- const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
- frame,
- { position: 1 },
- { ariaLabel: "Container" },
- );
- await targetContainerDropdownItem2.click();
-
- const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
- const selectedSourceContainer = await sourceContainerDropdown.innerText();
- const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
- const selectedTargetContainer = await targetContainerDropdown.innerText();
- expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
- selectedSourceContainer,
- )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
-
- await panel.getByRole("button", { name: "Next" }).click();
-
- await expect(errorContainer).not.toBeVisible();
- await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
-
- // Verifying the preview of the copy job
- const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
- await expect(previewContainer).toBeVisible();
- await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
- await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
- const jobNameInput = previewContainer.getByTestId("job-name-textfield");
- await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
- const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
- await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
-
- await jobNameInput.fill("test job name");
- await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
-
- // Testing API request interception with duplicate job name
- const duplicateJobName = "test-job-name-1";
- await jobNameInput.fill(duplicateJobName);
-
- const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
- const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
- await interceptAndInspectApiRequest(
- page,
- `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
- "PUT",
- new Error(expectedErrorMessage),
- (url?: string) => url?.includes(duplicateJobName) ?? false,
- );
-
- let errorThrown = false;
- try {
- await copyButton.click();
- await page.waitForTimeout(2000);
- } catch (error: any) {
- errorThrown = true;
- expect(error.message).toContain("not allowed");
- }
- if (!errorThrown) {
- const errorContainer = panel.getByTestId("Panel:ErrorContainer");
- await expect(errorContainer).toBeVisible();
- await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
- }
-
- await expect(panel).toBeVisible();
-
- // Testing API request success with valid job name and verifying copy job creation
-
- const validJobName = expectedJobName;
-
- const copyJobCreationPromise = waitForApiResponse(
- page,
- `${expectedAccountName}/dataTransferJobs/${validJobName}`,
- "PUT",
- );
-
- await jobNameInput.fill(validJobName);
- await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
-
- await copyButton.click();
-
- const response = await copyJobCreationPromise;
- expect(response.ok()).toBe(true);
-
- await expect(panel).not.toBeVisible({ timeout: 10000 });
-
- const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
- await jobsListContainer.waitFor({ state: "visible" });
-
- const jobItem = jobsListContainer.getByText(validJobName);
- await jobItem.waitFor({ state: "visible" });
- await expect(jobItem).toBeVisible();
- });
-
- test("Verify Online or Offline Container Copy Permissions Panel", async () => {
- expect(wrapper).not.toBeNull();
-
- // Opening the Create Copy Job panel again to verify initial state
- const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
- await createCopyJobButton.click();
- panel = frame.getByTestId("Panel:Create copy job");
- await expect(panel).toBeVisible();
- await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
-
- // select different account dropdown
-
- const accountDropdown = panel.getByTestId("account-dropdown");
- await accountDropdown.click();
-
- const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
- expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
-
- const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
-
- const filteredItems = [];
- for (const item of allDropdownItems) {
- const testContent = (await item.textContent()) ?? "";
- if (testContent.trim() !== targetAccountName.trim()) {
- filteredItems.push(item);
- }
- }
-
- if (filteredItems.length > 0) {
- const firstDropdownItem = filteredItems[0];
- expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
- await firstDropdownItem.click();
- } else {
- throw new Error("No dropdown items available after filtering");
- }
-
- const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
- await fluentUiCheckboxContainer.click();
-
- await panel.getByRole("button", { name: "Next" }).click();
-
- // Verifying Assign Permissions panel for online copy
-
- const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
- await expect(permissionScreen).toBeVisible();
-
- await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
- await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
-
- // Verify Point-in-Time Restore timer and refresh button workflow
-
- await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
- const mockData = {
- identity: {
- type: "SystemAssigned",
- principalId: "00-11-22-33",
- },
- properties: {
- defaultIdentity: "SystemAssignedIdentity",
- backupPolicy: {
- type: "Continuous",
- },
- capabilities: [{ name: "EnableOnlineContainerCopy" }],
- },
- };
- if (route.request().method() === "GET") {
- const response = await route.fetch();
- const actualData = await response.json();
- const mergedData = { ...actualData };
-
- set(mergedData, "identity", mockData.identity);
- set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
- set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
- set(mergedData, "properties.capabilities", mockData.properties.capabilities);
-
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify(mergedData),
- });
- } else {
- await route.continue();
- }
- });
-
- await expect(permissionScreen).toBeVisible();
-
- const expandedOnlineAccordionHeader = permissionScreen
- .getByTestId("permission-group-container-onlineConfigs")
- .locator("button[aria-expanded='true']");
- await expect(expandedOnlineAccordionHeader).toBeVisible();
-
- const accordionItem = expandedOnlineAccordionHeader
- .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
- .first();
-
- const accordionPanel = accordionItem
- .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
- .first();
-
- await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
-
- const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
- await expect(pitrBtn).toBeVisible();
- await pitrBtn.click();
-
- page.context().on("page", async (newPage) => {
- const expectedUrlEndPattern = new RegExp(
- `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
- );
- expect(newPage.url()).toMatch(expectedUrlEndPattern);
- await newPage.close();
- });
-
- const loadingOverlay = frame.locator("[data-test='loading-overlay']");
- await expect(loadingOverlay).toBeVisible();
-
- const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
- await expect(refreshBtn).not.toBeVisible();
-
- // Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
- await page.clock.fastForward(11 * 60 * 1000);
-
- await expect(refreshBtn).toBeVisible();
- await expect(pitrBtn).not.toBeVisible();
-
- // Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
-
- await page.route(
- `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
- async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({
- value: [
- {
- principalId: "00-11-22-33",
- roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
- },
- ],
- }),
- });
- },
- );
-
- await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({
- value: [
- {
- name: "00000000-0000-0000-0000-000000000001",
- },
- ],
- }),
- });
- });
-
- await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
- const mockData = {
- identity: {
- type: "SystemAssigned",
- principalId: "00-11-22-33",
- },
- properties: {
- defaultIdentity: "SystemAssignedIdentity",
- backupPolicy: {
- type: "Continuous",
- },
- capabilities: [{ name: "EnableOnlineContainerCopy" }],
- },
- };
-
- if (route.request().method() === "PATCH") {
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify({ status: "Succeeded" }),
- });
- } else if (route.request().method() === "GET") {
- // Get the actual response and merge with mock data
- const response = await route.fetch();
- const actualData = await response.json();
- const mergedData = { ...actualData };
- set(mergedData, "identity", mockData.identity);
- set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
- set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
- set(mergedData, "properties.capabilities", mockData.properties.capabilities);
-
- await route.fulfill({
- status: 200,
- contentType: "application/json",
- body: JSON.stringify(mergedData),
- });
- } else {
- await route.continue();
- }
- });
-
- await expect(permissionScreen).toBeVisible();
-
- const expandedCrossAccordionHeader = permissionScreen
- .getByTestId("permission-group-container-crossAccountConfigs")
- .locator("button[aria-expanded='true']");
- await expect(expandedCrossAccordionHeader).toBeVisible();
-
- const crossAccordionItem = expandedCrossAccordionHeader
- .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
- .first();
-
- const crossAccordionPanel = crossAccordionItem
- .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
- .first();
-
- const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
- await expect(toggleButton).toBeVisible();
- await toggleButton.click();
-
- const popover = frame.locator("[data-test='popover-container']");
- await expect(popover).toBeVisible();
-
- const yesButton = popover.getByRole("button", { name: /Yes/i });
- const noButton = popover.getByRole("button", { name: /No/i });
- await expect(yesButton).toBeVisible();
- await expect(noButton).toBeVisible();
-
- await yesButton.click();
-
- await expect(loadingOverlay).toBeVisible();
-
- await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
- await expect(popover).toBeHidden({ timeout: 10 * 1000 });
-
- await panel.getByRole("button", { name: "Cancel" }).click();
- });
-
- test.afterAll("Container Copy - After All", async () => {
- await page.unroute(/.*/, (route) => route.continue());
- await page.close();
- });
-});
diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts
new file mode 100644
index 000000000..390eee68f
--- /dev/null
+++ b/test/sql/containercopy/offlineMigration.spec.ts
@@ -0,0 +1,251 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { expect, Frame, Locator, Page, test } from "@playwright/test";
+import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils";
+import {
+ ContainerCopy,
+ getAccountName,
+ getDropdownItemByNameOrPosition,
+ interceptAndInspectApiRequest,
+ TestAccount,
+ waitForApiResponse,
+} from "../../fx";
+import { createMultipleTestContainers } from "../../testData";
+
+test.describe("Container Copy - Offline Migration", () => {
+ let page: Page;
+ let wrapper: Locator;
+ let panel: Locator;
+ let frame: Frame;
+ let expectedJobName: string;
+ let targetAccountName: string;
+ let expectedSubscriptionName: string;
+ let expectedCopyJobNameInitial: string;
+
+ test.beforeEach("Setup for offline migration test", async ({ browser }) => {
+ await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
+
+ page = await browser.newPage();
+ ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
+ expectedJobName = `offline_test_job_${Date.now()}`;
+ targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
+ });
+
+ test.afterEach("Cleanup after offline migration test", async () => {
+ await page.unroute(/.*/, (route) => route.continue());
+ await page.close();
+ });
+
+ test("Successfully create and manage offline migration copy job", async () => {
+ expect(wrapper).not.toBeNull();
+ await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
+
+ // Open Create Copy Job panel
+ const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
+ await expect(createCopyJobButton).toBeVisible();
+ await createCopyJobButton.click();
+ panel = frame.getByTestId("Panel:Create copy job");
+ await expect(panel).toBeVisible();
+
+ // Reduced wait time for better performance
+ await page.waitForTimeout(2000);
+
+ // Setup subscription and account
+ const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
+ const expectedAccountName = targetAccountName;
+ expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
+
+ await subscriptionDropdown.click();
+ const subscriptionItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { name: expectedSubscriptionName },
+ { ariaLabel: "Subscription" },
+ );
+ await subscriptionItem.click();
+
+ // Select account
+ const accountDropdown = panel.getByTestId("account-dropdown");
+ await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
+ await accountDropdown.click();
+
+ const accountItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { name: expectedAccountName },
+ { ariaLabel: "Account" },
+ );
+ await accountItem.click();
+
+ // Test offline migration mode toggle functionality
+ const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
+
+ // First test online mode (should show permissions screen)
+ await fluentUiCheckboxContainer.click();
+ await panel.getByRole("button", { name: "Next" }).click();
+ await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
+ await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
+
+ // Go back and switch to offline mode
+ await panel.getByRole("button", { name: "Previous" }).click();
+ await fluentUiCheckboxContainer.click();
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Verify we skip permissions screen in offline mode
+ await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
+ await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
+
+ // Test source and target container selection with validation
+ const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
+ expect(sourceContainerDropdown).toBeVisible();
+ await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
+
+ // Select source database first (containers are disabled until database is selected)
+ const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
+ await sourceDatabaseDropdown.click();
+ const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Database" },
+ );
+ await sourceDbDropdownItem.click();
+
+ // Now container dropdown should be enabled
+ await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
+ await sourceContainerDropdown.click();
+ const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Container" },
+ );
+ await sourceContainerDropdownItem.click();
+
+ // Test target container selection
+ const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
+ expect(targetContainerDropdown).toBeVisible();
+ await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
+
+ const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
+ await targetDatabaseDropdown.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();
+
+ // First try selecting the same container (should show error)
+ const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Container" },
+ );
+ await targetContainerDropdownItem1.click();
+
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Verify validation error for same source and target containers
+ const errorContainer = panel.getByTestId("Panel:ErrorContainer");
+ await expect(errorContainer).toBeVisible();
+ await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
+
+ // Select different target container
+ await targetContainerDropdown.click();
+ const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 1 },
+ { ariaLabel: "Container" },
+ );
+ await targetContainerDropdownItem2.click();
+
+ // Generate expected job name based on selections
+ const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
+ const selectedSourceContainer = await sourceContainerDropdown.innerText();
+ const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
+ const selectedTargetContainer = await targetContainerDropdown.innerText();
+ expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
+ selectedSourceContainer,
+ )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
+
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Error should disappear and preview should be visible
+ await expect(errorContainer).not.toBeVisible();
+ await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
+
+ // Verify job preview details
+ const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
+ await expect(previewContainer).toBeVisible();
+ await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
+ await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
+
+ const jobNameInput = previewContainer.getByTestId("job-name-textfield");
+ await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
+
+ const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
+ await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
+
+ // Test invalid job name validation (spaces not allowed)
+ await jobNameInput.fill("test job name");
+ await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
+
+ // Test duplicate job name error handling
+ const duplicateJobName = "test-job-name-1";
+ await jobNameInput.fill(duplicateJobName);
+
+ const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
+ const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
+
+ await interceptAndInspectApiRequest(
+ page,
+ `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
+ "PUT",
+ new Error(expectedErrorMessage),
+ (url?: string) => url?.includes(duplicateJobName) ?? false,
+ );
+
+ let errorThrown = false;
+ try {
+ await copyButton.click();
+ await page.waitForTimeout(2000);
+ } catch (error: any) {
+ errorThrown = true;
+ expect(error.message).toContain("not allowed");
+ }
+
+ if (!errorThrown) {
+ const errorContainer = panel.getByTestId("Panel:ErrorContainer");
+ await expect(errorContainer).toBeVisible();
+ await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
+ }
+
+ await expect(panel).toBeVisible();
+
+ // Test successful job creation with valid job name
+ const validJobName = expectedJobName;
+
+ const copyJobCreationPromise = waitForApiResponse(
+ page,
+ `${expectedAccountName}/dataTransferJobs/${validJobName}`,
+ "PUT",
+ );
+
+ await jobNameInput.fill(validJobName);
+ await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
+
+ await copyButton.click();
+
+ const response = await copyJobCreationPromise;
+ expect(response.ok()).toBe(true);
+
+ // Verify panel closes and job appears in the list
+ await expect(panel).not.toBeVisible({ timeout: 5000 });
+
+ const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
+ await jobsListContainer.waitFor({ state: "visible", timeout: 5000 });
+
+ const jobItem = jobsListContainer.getByText(validJobName);
+ await jobItem.waitFor({ state: "visible", timeout: 5000 });
+ await expect(jobItem).toBeVisible();
+ });
+});
diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts
new file mode 100644
index 000000000..3e61a7849
--- /dev/null
+++ b/test/sql/containercopy/onlineMigration.spec.ts
@@ -0,0 +1,181 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { expect, Frame, Locator, Page, test } from "@playwright/test";
+import {
+ ContainerCopy,
+ getAccountName,
+ getDropdownItemByNameOrPosition,
+ TestAccount,
+ waitForApiResponse,
+} from "../../fx";
+import { createMultipleTestContainers } from "../../testData";
+
+test.describe("Container Copy - Online Migration", () => {
+ let page: Page;
+ let wrapper: Locator;
+ let panel: Locator;
+ let frame: Frame;
+ let targetAccountName: string;
+
+ test.beforeEach("Setup for online migration test", async ({ browser }) => {
+ await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
+
+ page = await browser.newPage();
+ ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
+ targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
+ });
+
+ test.afterEach("Cleanup after online migration test", async () => {
+ await page.unroute(/.*/, (route) => route.continue());
+ await page.close();
+ });
+
+ test("Successfully create and manage online migration copy job", async () => {
+ expect(wrapper).not.toBeNull();
+ await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
+
+ // Open Create Copy Job panel
+ const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
+ await expect(createCopyJobButton).toBeVisible();
+ await createCopyJobButton.click();
+ panel = frame.getByTestId("Panel:Create copy job");
+ await expect(panel).toBeVisible();
+
+ // Reduced wait time for better performance
+ await page.waitForTimeout(1000);
+
+ // Enable online migration mode
+ const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
+ await fluentUiCheckboxContainer.click();
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Verify permissions screen is shown for online migration
+ const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
+ await expect(permissionScreen).toBeVisible();
+ await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
+
+ // Skip permissions setup and proceed to container selection
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Configure source and target containers for online migration
+ const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
+ await sourceDatabaseDropdown.click();
+ const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Database" },
+ );
+ await sourceDbDropdownItem.click();
+
+ const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
+ await sourceContainerDropdown.click();
+ const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Container" },
+ );
+ await sourceContainerDropdownItem.click();
+
+ const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
+ await targetDatabaseDropdown.click();
+ const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 0 },
+ { ariaLabel: "Database" },
+ );
+ await targetDbDropdownItem.click();
+
+ const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
+ await targetContainerDropdown.click();
+ const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(
+ frame,
+ { position: 1 },
+ { ariaLabel: "Container" },
+ );
+ await targetContainerDropdownItem.click();
+
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Verify job preview and create the online migration job
+ const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
+ await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName);
+
+ const jobNameInput = previewContainer.getByTestId("job-name-textfield");
+ const onlineMigrationJobName = await jobNameInput.inputValue();
+
+ const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
+
+ const copyJobCreationPromise = waitForApiResponse(
+ page,
+ `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
+ "PUT",
+ );
+ await copyButton.click();
+ await page.waitForTimeout(1000); // Reduced wait time
+
+ const response = await copyJobCreationPromise;
+ expect(response.ok()).toBe(true);
+
+ // Verify panel closes and job appears in the list
+ await expect(panel).not.toBeVisible({ timeout: 5000 });
+
+ const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
+ await jobsListContainer.waitFor({ state: "visible", timeout: 5000 });
+
+ let jobRow, statusCell, actionMenuButton;
+ jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
+ statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
+ await jobRow.waitFor({ state: "visible", timeout: 5000 });
+
+ // Verify job status changes to queued state
+ await expect(statusCell).toContainText(/running|queued|pending/i, { timeout: 5000 });
+
+ // Test job lifecycle management through action menu
+ actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
+ await actionMenuButton.click();
+
+ // Test pause functionality
+ const pauseAction = frame.locator(".ms-ContextualMenu-list button:has-text('Pause')");
+ await pauseAction.click();
+
+ const pauseResponse = await waitForApiResponse(
+ page,
+ `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`,
+ "POST",
+ );
+ expect(pauseResponse.ok()).toBe(true);
+
+ // Verify job status changes to paused
+ jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
+ await jobRow.waitFor({ state: "visible", timeout: 5000 });
+ statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
+ await expect(statusCell).toContainText(/paused/i, { timeout: 5000 });
+ await page.waitForTimeout(1000);
+
+ // Test cancel job functionality
+ actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
+ await actionMenuButton.click();
+ await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
+
+ // Verify cancellation confirmation dialog
+ await expect(frame.locator(".ms-Dialog-main")).toBeVisible({ timeout: 2000 });
+ await expect(frame.locator(".ms-Dialog-main")).toContainText(onlineMigrationJobName);
+
+ const cancelDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Cancel");
+ await expect(cancelDialogButton).toBeVisible();
+ await cancelDialogButton.click();
+ await expect(frame.locator(".ms-Dialog-main")).not.toBeVisible();
+
+ actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
+ await actionMenuButton.click();
+ await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
+
+ const confirmDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Confirm");
+ await expect(confirmDialogButton).toBeVisible();
+ await confirmDialogButton.click();
+
+ // Verify final job status is cancelled
+ jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
+ statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
+ await expect(statusCell).toContainText(/cancelled/i, { timeout: 5000 });
+ });
+});
diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts
new file mode 100644
index 000000000..519d8ec9e
--- /dev/null
+++ b/test/sql/containercopy/permissionsScreen.spec.ts
@@ -0,0 +1,267 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { expect, Frame, Locator, Page, test } from "@playwright/test";
+import { set } from "lodash";
+import { ContainerCopy, getAccountName, TestAccount } from "../../fx";
+
+const VISIBLE_TIMEOUT_MS = 30 * 1000;
+
+test.describe("Container Copy - Permission Screen Verification", () => {
+ let page: Page;
+ let wrapper: Locator;
+ let panel: Locator;
+ let frame: Frame;
+ let targetAccountName: string;
+ let expectedSourceAccountName: string;
+
+ test.beforeEach("Setup for each test", async ({ browser }) => {
+ page = await browser.newPage();
+ ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
+ targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
+ });
+
+ test.afterEach("Cleanup after each test", async () => {
+ await page.unroute(/.*/, (route) => route.continue());
+ await page.close();
+ });
+
+ test("Verify online container copy permissions panel functionality", async () => {
+ expect(wrapper).not.toBeNull();
+
+ // Verify all command bar buttons are visible
+ await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS });
+
+ const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
+ await expect(createCopyJobButton).toBeVisible();
+ await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible();
+ await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible();
+
+ // Open the Create Copy Job panel
+ await createCopyJobButton.click();
+ panel = frame.getByTestId("Panel:Create copy job");
+ await expect(panel).toBeVisible();
+ await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
+
+ // Select a different account for cross-account testing
+ const accountDropdown = panel.getByTestId("account-dropdown");
+ await accountDropdown.click();
+
+ const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
+ expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
+
+ const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
+
+ const filteredItems = [];
+ for (const item of allDropdownItems) {
+ const testContent = (await item.textContent()) ?? "";
+ if (testContent.trim() !== targetAccountName.trim()) {
+ filteredItems.push(item);
+ }
+ }
+
+ if (filteredItems.length > 0) {
+ const firstDropdownItem = filteredItems[0];
+ expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
+ await firstDropdownItem.click();
+ } else {
+ throw new Error("No dropdown items available after filtering");
+ }
+
+ // Enable online migration mode
+ const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
+ await fluentUiCheckboxContainer.click();
+ await panel.getByRole("button", { name: "Next" }).click();
+
+ // Verify Assign Permissions panel for online copy
+ const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
+ await expect(permissionScreen).toBeVisible();
+ await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
+ await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
+
+ // Setup API mocking for the source account
+ await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
+ const mockData = {
+ identity: {
+ type: "SystemAssigned",
+ principalId: "00-11-22-33",
+ },
+ properties: {
+ defaultIdentity: "SystemAssignedIdentity",
+ backupPolicy: {
+ type: "Continuous",
+ },
+ capabilities: [{ name: "EnableOnlineContainerCopy" }],
+ },
+ };
+ if (route.request().method() === "GET") {
+ const response = await route.fetch();
+ const actualData = await response.json();
+ const mergedData = { ...actualData };
+
+ set(mergedData, "identity", mockData.identity);
+ set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
+ set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
+ set(mergedData, "properties.capabilities", mockData.properties.capabilities);
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mergedData),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Verify Point-in-Time Restore functionality
+ const expandedOnlineAccordionHeader = permissionScreen
+ .getByTestId("permission-group-container-onlineConfigs")
+ .locator("button[aria-expanded='true']");
+ await expect(expandedOnlineAccordionHeader).toBeVisible();
+
+ const accordionItem = expandedOnlineAccordionHeader
+ .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
+ .first();
+
+ const accordionPanel = accordionItem
+ .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
+ .first();
+
+ // Install clock mock and test PITR functionality
+ await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
+
+ const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
+ await expect(pitrBtn).toBeVisible();
+ await pitrBtn.click();
+
+ // Verify new page opens with correct URL pattern
+ page.context().on("page", async (newPage) => {
+ const expectedUrlEndPattern = new RegExp(
+ `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
+ );
+ expect(newPage.url()).toMatch(expectedUrlEndPattern);
+ await newPage.close();
+ });
+
+ const loadingOverlay = frame.locator("[data-test='loading-overlay']");
+ await expect(loadingOverlay).toBeVisible();
+
+ const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
+ await expect(refreshBtn).not.toBeVisible();
+
+ // Fast forward time by 11 minutes
+ await page.clock.fastForward(11 * 60 * 1000);
+
+ await expect(refreshBtn).toBeVisible({ timeout: 5000 });
+ await expect(pitrBtn).not.toBeVisible();
+
+ // Setup additional API mocks for role assignments and permissions
+ await page.route(
+ `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ value: [
+ {
+ principalId: "00-11-22-33",
+ roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
+ },
+ ],
+ }),
+ });
+ },
+ );
+
+ await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ value: [
+ {
+ name: "00000000-0000-0000-0000-000000000001",
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
+ const mockData = {
+ identity: {
+ type: "SystemAssigned",
+ principalId: "00-11-22-33",
+ },
+ properties: {
+ defaultIdentity: "SystemAssignedIdentity",
+ backupPolicy: {
+ type: "Continuous",
+ },
+ capabilities: [{ name: "EnableOnlineContainerCopy" }],
+ },
+ };
+
+ if (route.request().method() === "PATCH") {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ status: "Succeeded" }),
+ });
+ } else if (route.request().method() === "GET") {
+ const response = await route.fetch();
+ const actualData = await response.json();
+ const mergedData = { ...actualData };
+ set(mergedData, "identity", mockData.identity);
+ set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
+ set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
+ set(mergedData, "properties.capabilities", mockData.properties.capabilities);
+
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mergedData),
+ });
+ } else {
+ await route.continue();
+ }
+ });
+
+ // Verify cross-account permissions functionality
+ const expandedCrossAccordionHeader = permissionScreen
+ .getByTestId("permission-group-container-crossAccountConfigs")
+ .locator("button[aria-expanded='true']");
+ await expect(expandedCrossAccordionHeader).toBeVisible();
+
+ const crossAccordionItem = expandedCrossAccordionHeader
+ .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
+ .first();
+
+ const crossAccordionPanel = crossAccordionItem
+ .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
+ .first();
+
+ const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
+ await expect(toggleButton).toBeVisible();
+ await toggleButton.click();
+
+ // Verify popover functionality
+ const popover = frame.locator("[data-test='popover-container']");
+ await expect(popover).toBeVisible();
+
+ const yesButton = popover.getByRole("button", { name: /Yes/i });
+ const noButton = popover.getByRole("button", { name: /No/i });
+ await expect(yesButton).toBeVisible();
+ await expect(noButton).toBeVisible();
+
+ await yesButton.click();
+
+ // Verify loading states
+ await expect(loadingOverlay).toBeVisible();
+ await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
+ await expect(popover).toBeHidden({ timeout: 10 * 1000 });
+
+ // Cancel the panel to clean up
+ await panel.getByRole("button", { name: "Cancel" }).click();
+ });
+});