From e27cff0553e6bb9a7004c09753bb978a58f0d152 Mon Sep 17 00:00:00 2001 From: sakshigupta12feb Date: Thu, 8 Jan 2026 13:27:57 +0530 Subject: [PATCH 01/20] Copy jobs dark theme (#2308) * added a dark theme toggle button on Copyjobs next to refresh button and covered full feature * Fix formatting in Utils.test.ts * updated infor icon , error icon and text on jobs details page * rebased from master * updated the conflicts * updated the conflicts * fixed the test suit * fixed review comments * test fix --------- Co-authored-by: Sakshi Gupta --- less/documentDB.less | 6 +- src/Common/LoadingOverlay.tsx | 10 ++- src/Common/Pager/Pager.css | 11 +++ src/Common/Pager/index.tsx | 4 +- .../CommandBar/CopyJobCommandBar.test.tsx | 8 +- .../CommandBar/CopyJobCommandBar.tsx | 24 ++--- .../ContainerCopy/CommandBar/Utils.test.ts | 60 +++++++------ .../ContainerCopy/CommandBar/Utils.ts | 17 +++- .../AssignPermissions/AddManagedIdentity.tsx | 9 +- .../AddReadPermissionToDefaultIdentity.tsx | 7 +- .../AssignPermissions/AssignPermissions.tsx | 10 +-- .../DefaultManagedIdentity.tsx | 7 +- .../AssignPermissions/PointInTimeRestore.tsx | 7 +- .../AddManagedIdentity.test.tsx.snap | 14 +-- .../Screens/Components/PopoverContainer.tsx | 4 +- .../PopoverContainer.test.tsx.snap | 28 +++--- .../AddCollectionPanelWrapper.tsx | 2 +- .../AddCollectionPanelWrapper.test.tsx.snap | 8 +- .../Screens/PreviewCopyJob/PreviewCopyJob.tsx | 12 ++- .../PreviewCopyJob.test.tsx.snap | 68 +++++++-------- .../Components/MigrationTypeCheckbox.tsx | 29 +++++-- .../Screens/SelectAccount/SelectAccount.tsx | 2 +- .../__snapshots__/SelectAccount.test.tsx.snap | 2 +- .../SelectSourceAndTargetContainers.tsx | 2 +- .../components/DatabaseContainerSection.tsx | 6 +- .../Components/CopyJobDetails.tsx | 30 +++++-- .../Components/CopyJobStatusWithIcon.tsx | 39 ++++----- .../Components/CopyJobsList.tsx | 33 +++++-- .../CopyJobStatusWithIcon.test.tsx.snap | 34 ++++---- .../ContainerCopy/containerCopyStyles.less | 87 ++++++++++++++++--- .../ThroughputBucketsComponent.tsx | 5 +- .../VectorEmbeddingPoliciesComponent.tsx | 3 + .../AddCollectionPanel/AddCollectionPanel.tsx | 2 +- .../AddCollectionPanel.test.tsx.snap | 5 ++ src/Explorer/Panes/PanelLoadingScreen.tsx | 2 +- src/less/DarkModeMenus.less | 6 -- src/less/ThemeSystem.less | 2 + 37 files changed, 392 insertions(+), 213 deletions(-) diff --git a/less/documentDB.less b/less/documentDB.less index 3950aad9c..83a2f1a2b 100644 --- a/less/documentDB.less +++ b/less/documentDB.less @@ -406,7 +406,11 @@ body { width: 440px; min-height: 565px; } - +.dataExplorerLoaderforcopyJobs{ + width: 100%; + min-height: 565px; + right: 0; +} .dataExplorerTabLoaderContainer { left: initial; top: initial; diff --git a/src/Common/LoadingOverlay.tsx b/src/Common/LoadingOverlay.tsx index 2cbf34213..96bf1943a 100644 --- a/src/Common/LoadingOverlay.tsx +++ b/src/Common/LoadingOverlay.tsx @@ -1,4 +1,5 @@ import { Overlay, Spinner, SpinnerSize } from "@fluentui/react"; +import { useThemeStore } from "hooks/useTheme"; import React from "react"; interface LoadingOverlayProps { @@ -7,6 +8,7 @@ interface LoadingOverlayProps { } const LoadingOverlay: React.FC = ({ isLoading, label }) => { + const isDarkMode = useThemeStore((state) => state.isDarkMode); if (!isLoading) { return null; } @@ -16,7 +18,7 @@ const LoadingOverlay: React.FC = ({ isLoading, label }) => data-test="loading-overlay" styles={{ root: { - backgroundColor: "rgba(255,255,255,0.9)", + backgroundColor: isDarkMode ? "rgba(32, 31, 30, 0.9)" : "rgba(255,255,255,0.9)", zIndex: 9999, display: "flex", alignItems: "center", @@ -24,7 +26,11 @@ const LoadingOverlay: React.FC = ({ isLoading, label }) => }, }} > - + ); }; diff --git a/src/Common/Pager/Pager.css b/src/Common/Pager/Pager.css index a29b6f50a..3379c2094 100644 --- a/src/Common/Pager/Pager.css +++ b/src/Common/Pager/Pager.css @@ -11,3 +11,14 @@ gap: 8px; align-items: center; } + +/* Override dark mode inherit for pagination icons */ +body.isDarkMode .pager-container .ms-Button .ms-Button-icon, +body.isDarkMode .pager-container .ms-Button i { + color: var(--colorBrandForeground1); +} + +body.isDarkMode .pager-container .ms-Button:disabled .ms-Button-icon, +body.isDarkMode .pager-container .ms-Button:disabled i { + color: var(--colorNeutralForegroundDisabled); +} \ No newline at end of file diff --git a/src/Common/Pager/index.tsx b/src/Common/Pager/index.tsx index 06ff7f2be..f85209c24 100644 --- a/src/Common/Pager/index.tsx +++ b/src/Common/Pager/index.tsx @@ -59,7 +59,7 @@ const Pager: React.FC = ({ return (
{showItemCount && ( - + Showing {startIndex + 1} - {endIndex} of {totalCount} items )} @@ -82,7 +82,7 @@ const Pager: React.FC = ({ disabled={disabled || currentPage === 1} styles={iconButtonStyles} /> - + Page {currentPage} of {totalPages} { render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false); expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(1); }); @@ -163,7 +163,7 @@ describe("CopyJobCommandBar", () => { render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer, false); expect(mockConvertButton.mock.calls[0][0]).toEqual(mockCommandButtonProps); }); @@ -175,11 +175,11 @@ describe("CopyJobCommandBar", () => { mockConvertButton.mockReturnValue([]); const { rerender } = render(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer1, false); rerender(); - expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2); + expect(mockGetCommandBarButtons).toHaveBeenCalledWith(mockExplorer2, false); expect(mockGetCommandBarButtons).toHaveBeenCalledTimes(2); }); }); diff --git a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx index 92b1107c9..9cd2f5002 100644 --- a/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx +++ b/src/Explorer/ContainerCopy/CommandBar/CopyJobCommandBar.tsx @@ -1,24 +1,28 @@ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import React from "react"; -import { StyleConstants } from "../../../Common/StyleConstants"; +import { useThemeStore } from "../../../hooks/useTheme"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import * as CommandBarUtil from "../../Menus/CommandBar/CommandBarUtil"; +import { getThemeTokens } from "../../Theme/ThemeUtil"; import { ContainerCopyProps } from "../Types/CopyJobTypes"; import { getCommandBarButtons } from "./Utils"; -const backgroundColor = StyleConstants.BaseLight; -const rootStyle = { - root: { - backgroundColor: backgroundColor, - }, -}; - const CopyJobCommandBar: React.FC = ({ explorer }) => { - const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer); + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const themeTokens = getThemeTokens(isDarkMode); + const backgroundColor = themeTokens.colorNeutralBackground1; + + const rootStyle = { + root: { + backgroundColor: backgroundColor, + }, + }; + + const commandBarItems: CommandButtonComponentProps[] = getCommandBarButtons(explorer, isDarkMode); const controlButtons: ICommandBarItemProps[] = CommandBarUtil.convertButton(commandBarItems, backgroundColor); return ( -
+
{ describe("getCommandBarButtons", () => { it("should return an array of command button props", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); expect(buttons).toBeDefined(); expect(Array.isArray(buttons)).toBe(true); @@ -58,7 +58,7 @@ describe("CommandBar Utils", () => { }); it("should include create copy job button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const createButton = buttons[0]; expect(createButton).toBeDefined(); @@ -70,7 +70,7 @@ describe("CommandBar Utils", () => { }); it("should include refresh button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const refreshButton = buttons[1]; expect(refreshButton).toBeDefined(); @@ -80,11 +80,11 @@ describe("CommandBar Utils", () => { }); it("should include feedback button when platform is Portal", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); - expect(buttons.length).toBe(3); + expect(buttons.length).toBe(4); - const feedbackButton = buttons[2]; + const feedbackButton = buttons[3]; expect(feedbackButton).toBeDefined(); expect(feedbackButton.ariaLabel).toBe("Provide feedback on copy jobs"); expect(feedbackButton.tooltipText).toBe("Feedback"); @@ -105,13 +105,13 @@ describe("CommandBar Utils", () => { })); const { getCommandBarButtons: getCommandBarButtonsEmulator } = await import("./Utils"); - const buttons = getCommandBarButtonsEmulator(mockExplorer); + const buttons = getCommandBarButtonsEmulator(mockExplorer, false); - expect(buttons.length).toBe(2); + expect(buttons.length).toBe(3); }); it("should call openCreateCopyJobPanel when create button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const createButton = buttons[0]; createButton.onCommandClick({} as React.SyntheticEvent); @@ -121,7 +121,7 @@ describe("CommandBar Utils", () => { }); it("should call refreshJobList when refresh button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const refreshButton = buttons[1]; refreshButton.onCommandClick({} as React.SyntheticEvent); @@ -130,8 +130,8 @@ describe("CommandBar Utils", () => { }); it("should call openContainerCopyFeedbackBlade when feedback button is clicked", () => { - const buttons = getCommandBarButtons(mockExplorer); - const feedbackButton = buttons[2]; + const buttons = getCommandBarButtons(mockExplorer, false); + const feedbackButton = buttons[3]; feedbackButton.onCommandClick({} as React.SyntheticEvent); @@ -139,7 +139,7 @@ describe("CommandBar Utils", () => { }); it("should return buttons with correct icon sources", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); expect(buttons[0].iconSrc).toBeDefined(); expect(buttons[0].iconAlt).toBe("Create Copy Job"); @@ -148,7 +148,10 @@ describe("CommandBar Utils", () => { expect(buttons[1].iconAlt).toBe("Refresh"); expect(buttons[2].iconSrc).toBeDefined(); - expect(buttons[2].iconAlt).toBe("Feedback"); + expect(buttons[2].iconAlt).toBe("Dark Theme"); + + expect(buttons[3].iconSrc).toBeDefined(); + expect(buttons[3].iconAlt).toBe("Feedback"); }); it("should handle null MonitorCopyJobsRefState ref gracefully", () => { @@ -157,14 +160,14 @@ describe("CommandBar Utils", () => { return selector(state); }); - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); const refreshButton = buttons[1]; expect(() => refreshButton.onCommandClick({} as React.SyntheticEvent)).not.toThrow(); }); it("should set hasPopup to false for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.hasPopup).toBe(false); @@ -172,7 +175,7 @@ describe("CommandBar Utils", () => { }); it("should set commandButtonLabel to undefined for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.commandButtonLabel).toBeUndefined(); @@ -180,7 +183,7 @@ describe("CommandBar Utils", () => { }); it("should respect disabled state when provided", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.disabled).toBe(false); @@ -188,7 +191,7 @@ describe("CommandBar Utils", () => { }); it("should return CommandButtonComponentProps with all required properties", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button: CommandButtonComponentProps) => { expect(button).toHaveProperty("iconSrc"); @@ -202,18 +205,19 @@ describe("CommandBar Utils", () => { }); }); - it("should maintain button order: create, refresh, feedback", () => { - const buttons = getCommandBarButtons(mockExplorer); + it("should maintain button order: create, refresh, themeToggle, feedback", () => { + const buttons = getCommandBarButtons(mockExplorer, false); expect(buttons[0].tooltipText).toBe("Create Copy Job"); expect(buttons[1].tooltipText).toBe("Refresh"); - expect(buttons[2].tooltipText).toBe("Feedback"); + expect(buttons[2].tooltipText).toBe("Dark Theme"); + expect(buttons[3].tooltipText).toBe("Feedback"); }); }); describe("Button click handlers", () => { it("should execute click handlers without errors", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(() => button.onCommandClick({} as React.SyntheticEvent)).not.toThrow(); @@ -221,7 +225,7 @@ describe("CommandBar Utils", () => { }); it("should call correct action for each button", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons[0].onCommandClick({} as React.SyntheticEvent); expect(Actions.openCreateCopyJobPanel).toHaveBeenCalledWith(mockExplorer); @@ -229,14 +233,14 @@ describe("CommandBar Utils", () => { buttons[1].onCommandClick({} as React.SyntheticEvent); expect(mockRefreshJobList).toHaveBeenCalled(); - buttons[2].onCommandClick({} as React.SyntheticEvent); + buttons[3].onCommandClick({} as React.SyntheticEvent); expect(mockOpenContainerCopyFeedbackBlade).toHaveBeenCalled(); }); }); describe("Accessibility", () => { it("should have aria labels for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.ariaLabel).toBeDefined(); @@ -246,7 +250,7 @@ describe("CommandBar Utils", () => { }); it("should have tooltip text for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.tooltipText).toBeDefined(); @@ -256,7 +260,7 @@ describe("CommandBar Utils", () => { }); it("should have icon alt text for all buttons", () => { - const buttons = getCommandBarButtons(mockExplorer); + const buttons = getCommandBarButtons(mockExplorer, false); buttons.forEach((button) => { expect(button.iconAlt).toBeDefined(); diff --git a/src/Explorer/ContainerCopy/CommandBar/Utils.ts b/src/Explorer/ContainerCopy/CommandBar/Utils.ts index 152c2dfbd..d00b3788d 100644 --- a/src/Explorer/ContainerCopy/CommandBar/Utils.ts +++ b/src/Explorer/ContainerCopy/CommandBar/Utils.ts @@ -1,7 +1,10 @@ import AddIcon from "../../../../images/Add.svg"; import FeedbackIcon from "../../../../images/Feedback-Command.svg"; +import MoonIcon from "../../../../images/MoonIcon.svg"; import RefreshIcon from "../../../../images/refresh-cosmos.svg"; +import SunIcon from "../../../../images/SunIcon.svg"; import { configContext, Platform } from "../../../ConfigContext"; +import { useThemeStore } from "../../../hooks/useTheme"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; import * as Actions from "../Actions/CopyJobActions"; @@ -9,7 +12,7 @@ import ContainerCopyMessages from "../ContainerCopyMessages"; import { MonitorCopyJobsRefState } from "../MonitorCopyJobs/MonitorCopyJobRefState"; import { CopyJobCommandBarBtnType } from "../Types/CopyJobTypes"; -function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] { +function getCopyJobBtns(explorer: Explorer, isDarkMode: boolean): CopyJobCommandBarBtnType[] { const monitorCopyJobsRef = MonitorCopyJobsRefState((state) => state.ref); const buttons: CopyJobCommandBarBtnType[] = [ { @@ -26,7 +29,15 @@ function getCopyJobBtns(explorer: Explorer): CopyJobCommandBarBtnType[] { ariaLabel: ContainerCopyMessages.refreshButtonAriaLabel, onClick: () => monitorCopyJobsRef?.refreshJobList(), }, + { + key: "themeToggle", + iconSrc: isDarkMode ? SunIcon : MoonIcon, + label: isDarkMode ? "Light Theme" : "Dark Theme", + ariaLabel: isDarkMode ? "Switch to Light Theme" : "Switch to Dark Theme", + onClick: () => useThemeStore.getState().toggleTheme(), + }, ]; + if (configContext.platform === Platform.Portal) { buttons.push({ key: "feedback", @@ -54,6 +65,6 @@ function btnMapper(config: CopyJobCommandBarBtnType): CommandButtonComponentProp }; } -export function getCommandBarButtons(explorer: Explorer): CommandButtonComponentProps[] { - return getCopyJobBtns(explorer).map(btnMapper); +export function getCommandBarButtons(explorer: Explorer, isDarkMode: boolean): CommandButtonComponentProps[] { + return getCopyJobBtns(explorer, isDarkMode).map(btnMapper); } diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx index 76dca972b..86c59611c 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddManagedIdentity.tsx @@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle"; const managedIdentityTooltip = ( {ContainerCopyMessages.addManagedIdentity.tooltip.content}   - + {ContainerCopyMessages.addManagedIdentity.tooltip.hrefText} @@ -26,7 +31,7 @@ const AddManagedIdentity: React.FC = () => { return ( - + {ContainerCopyMessages.addManagedIdentity.description}  {ContainerCopyMessages.addManagedIdentity.descriptionHrefText} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx index 051471e11..5af5630d7 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AddReadPermissionToDefaultIdentity.tsx @@ -13,7 +13,12 @@ import useToggle from "./hooks/useToggle"; const TooltipContent = ( {ContainerCopyMessages.readPermissionAssigned.tooltip.content}   - + {ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx index f3cb98d75..40a657f59 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/AssignPermissions.tsx @@ -48,8 +48,8 @@ const PermissionGroup: React.FC = ({ id, title, descripti tokens={{ childrenGap: 15 }} styles={{ root: { - background: "#fafafa", - border: "1px solid #e1e1e1", + background: "var(--colorNeutralBackground2)", + border: "1px solid var(--colorNeutralStroke1)", borderRadius: 8, padding: 16, boxShadow: "0 1px 3px rgba(0,0,0,0.1)", @@ -57,11 +57,11 @@ const PermissionGroup: React.FC = ({ id, title, descripti }} > - + {title} {description && ( - + {description} )} @@ -105,7 +105,7 @@ const AssignPermissions = () => { className="assignPermissionsContainer" tokens={{ childrenGap: 20 }} > - + {isSameAccount && copyJobState.migrationType === CopyJobMigrationType.Online ? ContainerCopyMessages.assignPermissions.intraAccountOnlineDescription( copyJobState?.source?.account?.name || "", diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx index 3eeb60bbf..92c752171 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/DefaultManagedIdentity.tsx @@ -12,7 +12,12 @@ import useToggle from "./hooks/useToggle"; const managedIdentityTooltip = ( {ContainerCopyMessages.defaultManagedIdentity.tooltip.content}   - + {ContainerCopyMessages.defaultManagedIdentity.tooltip.hrefText} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx index 85fbc769a..6725f4981 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/PointInTimeRestore.tsx @@ -13,7 +13,12 @@ import InfoTooltip from "../Components/InfoTooltip"; const tooltipContent = ( {ContainerCopyMessages.pointInTimeRestore.tooltip.content}   - + {ContainerCopyMessages.pointInTimeRestore.tooltip.hrefText} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap index 12060d2f0..5e565200b 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/AssignPermissions/__snapshots__/AddManagedIdentity.test.tsx.snap @@ -5,7 +5,7 @@ exports[`AddManagedIdentity Snapshot Tests renders initial state correctly 1`] = class="ms-Stack addManagedIdentityContainer css-109" > A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.   @@ -93,7 +93,7 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = ` class="ms-Stack addManagedIdentityContainer css-109" > A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.   @@ -196,13 +196,13 @@ exports[`AddManagedIdentity Snapshot Tests renders loading state 1`] = `
Enable system assigned managed identity Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. @@ -265,7 +265,7 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi class="ms-Stack addManagedIdentityContainer css-109" > A system-assigned managed identity is restricted to one per resource and is tied to the lifecycle of this resource. Once enabled, you can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC). The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.   @@ -351,13 +351,13 @@ exports[`AddManagedIdentity Snapshot Tests renders with toggle on and popover vi style="max-width: 450px;" > Enable system assigned managed identity Enable system-assigned managed identity on the test-target-account. To confirm, click the "Yes" button. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx index f00cfa858..8ec30dedc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/PopoverContainer.tsx @@ -23,10 +23,10 @@ const PopoverContainer: React.FC = React.memo( style={{ maxWidth: 450 }} > - + {title} - {children} + {children} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap index fbe0ca8c1..7a6897fd6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/Components/__snapshots__/PopoverContainer.test.tsx.snap @@ -8,11 +8,11 @@ exports[`PopoverMessage Component Edge Cases should handle empty string title 1` style="max-width: 450px;" >
Test content @@ -76,7 +76,7 @@ exports[`PopoverMessage Component Edge Cases should handle null children 1`] = ` style="max-width: 450px;" > Test Title @@ -139,7 +139,7 @@ exports[`PopoverMessage Component Edge Cases should handle undefined children 1` style="max-width: 450px;" > Test Title @@ -202,13 +202,13 @@ exports[`PopoverMessage Component Edge Cases should handle very long title 1`] = style="max-width: 450px;" > This is a very long title that might cause layout issues or text wrapping in the popover component
Test content @@ -274,13 +274,13 @@ exports[`PopoverMessage Component Rendering should render correctly when visible style="max-width: 450px;" > Test Title
Test content @@ -344,13 +344,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe style="max-width: 450px;" > Test Title

@@ -419,13 +419,13 @@ exports[`PopoverMessage Component Rendering should render correctly with differe style="max-width: 450px;" > Custom Title

Test content @@ -493,13 +493,13 @@ exports[`PopoverMessage Component Rendering should render correctly with loading data-testid="loading-overlay" /> Test Title
Test content diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx index 01a2db73f..a274c9bbc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/AddCollectionPanelWrapper.tsx @@ -41,7 +41,7 @@ const AddCollectionPanelWrapper: React.FunctionComponent - {ContainerCopyMessages.createNewContainerSubHeading} + {ContainerCopyMessages.createNewContainerSubHeading} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap index 6824baba9..ae6d7b4ec 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/CreateContainer/__snapshots__/AddCollectionPanelWrapper.test.tsx.snap @@ -9,7 +9,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -50,7 +50,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -91,7 +91,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. @@ -132,7 +132,7 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit class="ms-StackItem addCollectionPanelHeader css-110" > Select the properties for your container. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx index 4abf891f1..84abc0ece 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/PreviewCopyJob/PreviewCopyJob.tsx @@ -36,12 +36,16 @@ const PreviewCopyJob: React.FC = () => { - {ContainerCopyMessages.sourceSubscriptionLabel} - {copyJobState.source?.subscription?.displayName} + {ContainerCopyMessages.sourceSubscriptionLabel} + + {copyJobState.source?.subscription?.displayName} + - {ContainerCopyMessages.sourceAccountLabel} - {copyJobState.source?.account?.name} + {ContainerCopyMessages.sourceAccountLabel} + + {copyJobState.source?.account?.name} + Source subscription Test Subscription @@ -62,12 +62,12 @@ exports[`PreviewCopyJob should handle special characters in database and contain class="ms-Stack css-124" > Source account test-account @@ -369,12 +369,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` class="ms-Stack css-124" > Source subscription Test Subscription @@ -384,12 +384,12 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1` class="ms-Stack css-124" > Source account test-account @@ -691,12 +691,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`] class="ms-Stack css-124" > Source subscription Test Subscription @@ -706,12 +706,12 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`] class="ms-Stack css-124" > Source account test-account @@ -1013,12 +1013,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1 class="ms-Stack css-124" > Source subscription This is a very long subscription name that might cause display issues if not handled properly @@ -1028,12 +1028,12 @@ exports[`PreviewCopyJob should render with long subscription and account names 1 class="ms-Stack css-124" > Source account this-is-a-very-long-database-account-name-that-might-cause-display-issues @@ -1335,12 +1335,12 @@ exports[`PreviewCopyJob should render with missing source account information 1` class="ms-Stack css-124" > Source subscription Test Subscription @@ -1350,7 +1350,7 @@ exports[`PreviewCopyJob should render with missing source account information 1` class="ms-Stack css-124" > Source account @@ -1651,7 +1651,7 @@ exports[`PreviewCopyJob should render with missing source subscription informati class="ms-Stack css-124" > Source subscription @@ -1660,12 +1660,12 @@ exports[`PreviewCopyJob should render with missing source subscription informati class="ms-Stack css-124" > Source account test-account @@ -1967,12 +1967,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` class="ms-Stack css-124" > Source subscription Test Subscription @@ -1982,12 +1982,12 @@ exports[`PreviewCopyJob should render with online migration type 1`] = ` class="ms-Stack css-124" > Source account test-account @@ -2289,12 +2289,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` class="ms-Stack css-124" > Source subscription Test Subscription @@ -2304,12 +2304,12 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = ` class="ms-Stack css-124" > Source account test-account @@ -2611,12 +2611,12 @@ exports[`PreviewCopyJob should render with undefined database and container name class="ms-Stack css-124" > Source subscription Test Subscription @@ -2626,12 +2626,12 @@ exports[`PreviewCopyJob should render with undefined database and container name class="ms-Stack css-124" > Source account test-account diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx index 092c5ad67..4e8ae6946 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ /* eslint-disable react/display-name */ -import { Checkbox, Stack } from "@fluentui/react"; +import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react"; import React from "react"; import ContainerCopyMessages from "../../../../ContainerCopyMessages"; @@ -9,8 +9,25 @@ interface MigrationTypeCheckboxProps { onChange: (_ev?: React.FormEvent, checked?: boolean) => void; } -export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => ( - - - -)); +const checkboxStyles: ICheckboxStyles = { + text: { color: "var(--colorNeutralForeground1)" }, + checkbox: { borderColor: "var(--colorNeutralStroke1)" }, + root: { + selectors: { + ":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" }, + }, + }, +}; + +export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => { + return ( + + + + ); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index ba1072de7..1d7715f48 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -21,7 +21,7 @@ const SelectAccount = React.memo(() => { return ( - {ContainerCopyMessages.selectAccountDescription} + {ContainerCopyMessages.selectAccountDescription} diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap index 90a8ddc2b..b84b677cc 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -6,7 +6,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot data-test="Panel:SelectAccountContainer" > Please select a source account from which to copy. diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx index d235b40d6..6a0ad3715 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectSourceAndTargetContainers/SelectSourceAndTargetContainers.tsx @@ -52,7 +52,7 @@ const SelectSourceAndTargetContainers = ({ showAddCollectionPanel }: SelectSourc className="selectSourceAndTargetContainers" tokens={{ childrenGap: 25 }} > - {ContainerCopyMessages.selectSourceAndTargetContainersDescription} + {ContainerCopyMessages.selectSourceAndTargetContainersDescription} {handleOnDemandCreateContainer && ( - handleOnDemandCreateContainer()}> + handleOnDemandCreateContainer()} + > {ContainerCopyMessages.createContainerButtonLabel} )} diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx index f131cc4e5..63c7fcf46 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobDetails.tsx @@ -1,5 +1,6 @@ import { DetailsList, DetailsListLayoutMode, IColumn, Stack, Text } from "@fluentui/react"; import React, { memo } from "react"; +import { useThemeStore } from "../../../../hooks/useTheme"; import ContainerCopyMessages from "../../ContainerCopyMessages"; import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; import { CopyJobType } from "../../Types/CopyJobTypes"; @@ -63,6 +64,19 @@ const getCopyJobDetailsListColumns = (): IColumn[] => { }; const CopyJobDetails: React.FC = ({ job }) => { + const isDarkMode = useThemeStore((state) => state.isDarkMode); + + const errorMessageStyle: React.CSSProperties = { + whiteSpace: "pre-wrap", + ...(isDarkMode && { + whiteSpace: "pre-wrap", + backgroundColor: "var(--colorNeutralBackground2)", + color: "var(--colorNeutralForeground1)", + padding: "10px", + borderRadius: "4px", + }), + }; + const selectedContainers = [ { sourceContainerName: job?.Source?.containerName || "N/A", @@ -77,10 +91,10 @@ const CopyJobDetails: React.FC = ({ job }) => { {job.Error ? ( - + {ContainerCopyMessages.errorTitle} - + {job.Error.message} @@ -88,16 +102,16 @@ const CopyJobDetails: React.FC = ({ job }) => { - {ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime} - {job.LastUpdatedTime} + {ContainerCopyMessages.MonitorJobs.Columns.lastUpdatedTime} + {job.LastUpdatedTime} - {ContainerCopyMessages.sourceAccountLabel} - {job.Source?.remoteAccountName} + {ContainerCopyMessages.sourceAccountLabel} + {job.Source?.remoteAccountName} - {ContainerCopyMessages.MonitorJobs.Columns.mode} - {job.Mode} + {ContainerCopyMessages.MonitorJobs.Columns.mode} + {job.Mode} diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx index f4061a3b5..1fb996639 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobStatusWithIcon.tsx @@ -1,30 +1,14 @@ -import { FontIcon, getTheme, mergeStyles, mergeStyleSets, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react"; +import { FontIcon, mergeStyles, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react"; import PropTypes from "prop-types"; import React from "react"; import ContainerCopyMessages from "../../ContainerCopyMessages"; import { CopyJobStatusType } from "../../Enums/CopyJobEnums"; -const theme = getTheme(); - const iconClass = mergeStyles({ fontSize: "16px", marginRight: "8px", }); -const classNames = mergeStyleSets({ - [CopyJobStatusType.Pending]: [{ color: theme.semanticColors.bodySubtext }, iconClass], - [CopyJobStatusType.InProgress]: [{ color: theme.palette.themePrimary }, iconClass], - [CopyJobStatusType.Running]: [{ color: theme.palette.themePrimary }, iconClass], - [CopyJobStatusType.Partitioning]: [{ color: theme.palette.themePrimary }, iconClass], - [CopyJobStatusType.Paused]: [{ color: theme.palette.themePrimary }, iconClass], - [CopyJobStatusType.Skipped]: [{ color: theme.semanticColors.bodySubtext }, iconClass], - [CopyJobStatusType.Cancelled]: [{ color: theme.semanticColors.bodySubtext }, iconClass], - [CopyJobStatusType.Failed]: [{ color: theme.semanticColors.errorIcon }, iconClass], - [CopyJobStatusType.Faulted]: [{ color: theme.semanticColors.errorIcon }, iconClass], - [CopyJobStatusType.Completed]: [{ color: theme.semanticColors.successIcon }, iconClass], - unknown: [{ color: theme.semanticColors.bodySubtext }, iconClass], -}); - const iconMap: Partial> = { [CopyJobStatusType.Pending]: "Clock", [CopyJobStatusType.Paused]: "CirclePause", @@ -35,6 +19,17 @@ const iconMap: Partial> = { [CopyJobStatusType.Completed]: "CompletedSolid", }; +// Icon colors for different statuses +const statusIconColors: Partial> = { + [CopyJobStatusType.Failed]: "var(--colorPaletteRedForeground1)", + [CopyJobStatusType.Faulted]: "var(--colorPaletteRedForeground1)", + [CopyJobStatusType.Completed]: "var(--colorSuccessGreen)", + [CopyJobStatusType.InProgress]: "var(--colorBrandForeground1)", + [CopyJobStatusType.Running]: "var(--colorBrandForeground1)", + [CopyJobStatusType.Partitioning]: "var(--colorBrandForeground1)", + [CopyJobStatusType.Paused]: "var(--colorBrandForeground1)", +}; + export interface CopyJobStatusWithIconProps { status: CopyJobStatusType; } @@ -47,19 +42,17 @@ const CopyJobStatusWithIcon: React.FC = React.memo(( CopyJobStatusType.InProgress, CopyJobStatusType.Partitioning, ].includes(status); + const iconColor = statusIconColors[status] || "var(--colorNeutralForeground2)"; + const iconStyle = mergeStyles(iconClass, { color: iconColor }); return ( {isSpinnerStatus ? ( ) : ( - + )} - {statusText} + {statusText} ); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx index 86da3a03e..a263ac137 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -15,6 +15,8 @@ import { } from "@fluentui/react"; import React, { useEffect } from "react"; import Pager from "../../../../Common/Pager"; +import { useThemeStore } from "../../../../hooks/useTheme"; +import { getThemeTokens } from "../../../Theme/ThemeUtil"; import { openCopyJobDetailsPanel } from "../../Actions/CopyJobActions"; import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; import { getColumns } from "./CopyJobColumns"; @@ -26,13 +28,15 @@ interface CopyJobsListProps { } const styles = { - container: { height: "calc(100vh - 25em)" } as React.CSSProperties, + container: { height: "100%" } as React.CSSProperties, stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, }; const PAGE_SIZE = 10; const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const themeTokens = getThemeTokens(isDarkMode); const [startIndex, setStartIndex] = React.useState(0); const [sortedJobs, setSortedJobs] = React.useState(jobs); const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined); @@ -88,11 +92,28 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa enableShimmer={false} constrainMode={ConstrainMode.unconstrained} layoutMode={DetailsListLayoutMode.justified} - onRenderDetailsHeader={(props, defaultRender) => ( - - {defaultRender({ ...props })} - - )} + onRenderDetailsHeader={(props, defaultRender) => { + const bgColor = themeTokens.colorNeutralBackground3; + const textColor = themeTokens.colorNeutralForeground1; + return ( + +
+ {defaultRender({ + ...props, + styles: { + root: { + backgroundColor: bgColor, + selectors: { + ".ms-DetailsHeader-cellTitle": { color: textColor }, + ".ms-DetailsHeader-cellName": { color: textColor }, + }, + }, + }, + })} +
+
+ ); + }} />
diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap index 9940ee7e9..d2e4482ce 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/__snapshots__/CopyJobStatusWithIcon.test.tsx.snap @@ -13,7 +13,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders InProgress with spin />
Running @@ -33,7 +33,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Partitioning with sp />
Running @@ -53,7 +53,7 @@ exports[`CopyJobStatusWithIcon Spinner Status Types renders Running with spinner />
Running @@ -66,7 +66,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Cancelled @@ -87,7 +87,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Completed @@ -108,7 +108,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Failed @@ -129,7 +129,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Failed @@ -150,7 +150,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Paused @@ -171,7 +171,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Queued @@ -192,7 +192,7 @@ exports[`CopyJobStatusWithIcon Static Icon Status Types - Snapshot Tests renders > Cancelled diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 05d9facec..c86986c62 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -1,6 +1,30 @@ @import "../../../less/Common/Constants.less"; +// Common theme-aware classes +.themeText { + color: var(--colorNeutralForeground1); +} + +.themeTextSecondary { + color: var(--colorNeutralForeground2); +} + +.themeLinkText { + color: var(--colorBrandForeground1); +} + +.themeBackground { + background-color: var(--colorNeutralBackground1); +} + +.themeBackgroundSecondary { + background-color: var(--colorNeutralBackground2); +} + #containerCopyWrapper { + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); + .centerContent { justify-content: center; align-items: center; @@ -9,20 +33,30 @@ .noCopyJobsMessage { font-weight: 600; margin: 0 auto; - color: @FocusColor; + color: var(--colorNeutralForeground2); } button.createCopyJobButton { - color: @LinkColor; + color: var(--colorBrandForeground1); } } } .createCopyJobScreensContainer { height: 100%; padding: 1em 1.5em; + background-color: var(--colorNeutralBackground1); + color: var(--colorNeutralForeground1); .pointInTimeRestoreContainer, .onlineCopyContainer { position: relative; } + + .toggle-label { + color: var(--colorNeutralForeground1); + } + + .accordionHeaderText { + color: var(--colorNeutralForeground1); + } label { padding: 0; @@ -71,7 +105,7 @@ } .foreground { z-index: 10; - background-color: #f9f9f9; + background-color: var(--colorNeutralBackground2); padding: 20px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); transform: translate(0%, -9%); @@ -80,14 +114,40 @@ .createCopyJobErrorMessageBar { margin-bottom: 2em; } + body.isDarkMode & { + .ms-TooltipHost .ms-Image { + filter: invert(1); + } + + .ms-TextField { + .ms-TextField-fieldGroup { + background-color: var(--colorNeutralBackground1); + border-color: var(--colorNeutralStroke1); + } + + .ms-TextField-field { + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + + &::placeholder { + color: var(--colorNeutralForeground4); + } + } + + .ms-Label { + color: var(--colorNeutralForeground1); + } + } + } .create-container-link-btn { padding: 0; height: 25px; - color: @LinkColor; + color: var(--colorBrandForeground1); &:focus { outline: none; } + } /* Create collection panel */ @@ -105,7 +165,6 @@ width: 100%; max-width: 100%; margin: 0 auto; - .ms-DetailsList { width: 100%; @@ -114,33 +173,33 @@ padding: @DefaultSpace 20px; font-weight: 600; font-size: @DefaultFontSize; - color: @BaseHigh; - background-color: @BaseLow; - border-bottom: @ButtonBorderWidth solid @BaseMedium; + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground2); + border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1); &:hover { - background-color: @BaseMediumLow; + background-color: var(--colorNeutralBackground3); } } } .ms-DetailsRow { - border-bottom: @ButtonBorderWidth solid @BaseMedium; + border-bottom: @ButtonBorderWidth solid var(--colorNeutralStroke1); &:hover { - background-color: @BaseMediumLow; + background-color: var(--colorNeutralBackground2); } .ms-DetailsRow-cell { padding: @MediumSpace 20px; font-size: @DefaultFontSize; - color: @BaseHigh; + color: var(--colorNeutralForeground1); min-height: 48px; display: flex; align-items: center; .jobNameLink { - color: @LinkColor; + color: var(--colorBrandForeground1); text-overflow: ellipsis; white-space: nowrap; overflow: hidden; @@ -168,7 +227,7 @@ } .ms-DetailsRow-cell { font-size: @DefaultFontSize; - color: @BaseHigh; + color: var(--colorNeutralForeground1); } } } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx index f49d60967..bd8f638cd 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent.tsx @@ -103,7 +103,10 @@ export const ThroughputBucketsComponent: FC = ( offText="Inactive" checked={bucket.maxThroughputPercentage !== 100} onChange={(event, checked) => onToggle(bucket.id, checked)} - styles={{ root: { marginBottom: 0 }, text: { fontSize: 12 } }} + styles={{ + root: { marginBottom: 0 }, + text: { fontSize: 12, color: "var(--colorNeutralForeground1)" }, + }} > ))} diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx index ba0c5b62b..21aa1483c 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx @@ -53,6 +53,7 @@ type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexTyp const labelStyles = { root: { fontSize: 12, + color: "var(--colorNeutralForeground1)", }, }; @@ -63,6 +64,8 @@ const textFieldStyles: IStyleFunctionOrObject - + Azure Synapse Link is required for creating an analytical store{" "} {getCollectionName().toLocaleLowerCase()}. Enable Synapse Link for this Cosmos DB account.
Azure Synapse Link is required for creating an analytical store diff --git a/src/Explorer/Panes/PanelLoadingScreen.tsx b/src/Explorer/Panes/PanelLoadingScreen.tsx index 8068b7945..a8b7856a8 100644 --- a/src/Explorer/Panes/PanelLoadingScreen.tsx +++ b/src/Explorer/Panes/PanelLoadingScreen.tsx @@ -2,7 +2,7 @@ import React from "react"; import LoadingIndicator_3Squares from "../../../images/LoadingIndicator_3Squares.gif"; export const PanelLoadingScreen: React.FunctionComponent = () => ( -
+
); diff --git a/src/less/DarkModeMenus.less b/src/less/DarkModeMenus.less index a0724ea5d..c0cda8932 100644 --- a/src/less/DarkModeMenus.less +++ b/src/less/DarkModeMenus.less @@ -316,11 +316,6 @@ body.isDarkMode { background-color: transparent; } - // High specificity override for any nested elements - * { - color: var(--colorNeutralForeground1); - } - // Ensure links maintain proper colors .ms-Link { color: var(--colorBrandForeground1); @@ -438,7 +433,6 @@ body.isDarkMode { button { &:not(.ms-Button):not(.ms-IconButton) { - background-color: var(--colorNeutralBackground1); color: var(--colorNeutralForeground1); &:hover { diff --git a/src/less/ThemeSystem.less b/src/less/ThemeSystem.less index 6fdeed790..05298d2e6 100644 --- a/src/less/ThemeSystem.less +++ b/src/less/ThemeSystem.less @@ -12,6 +12,7 @@ --colorCompoundBrandStroke1: @SelectionColor; --colorBrandForeground1: @LinkColor; --colorPaletteRedForeground1: @ErrorColor; + --colorSuccessGreen: #107c10; --overlayBackground: rgba(0, 0, 0, 0.4); --colorBrandBackground: @SelectionColor; --colorBrandBackgroundHover: @AccentMediumHigh; @@ -32,6 +33,7 @@ body.isDarkMode { --colorCompoundBrandStroke1: #4db6e8; --colorBrandForeground1: #4db6e8; --colorPaletteRedForeground1: #f25d5d; + --colorSuccessGreen: #107c10; --overlayBackground: rgba(0, 0, 0, 0.4); --colorBrandBackground: #0078d4; --colorBrandBackgroundHover: #106ebe; From b71ea50972156ac57bad3f9b2917c9191c6230ee Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Thu, 8 Jan 2026 17:19:16 +0530 Subject: [PATCH 02/20] Add test infrastructure and data-test attributes for Container Copy e2e tests (#2280) * Add test infrastructure and data-test attributes for Container Copy e2e testing * fix container copy FTs --- .github/workflows/ci.yml | 5 + .../Actions/CopyJobActions.test.tsx | 268 +++++----- .../ContainerCopy/Actions/CopyJobActions.tsx | 10 +- .../MonitorCopyJobs/MonitorCopyJobs.tsx | 4 +- test/README.md | 3 + test/fx.ts | 174 ++++++- test/sql/containercopy.spec.ts | 493 ++++++++++++++++++ .../changePartitionKey.spec.ts | 10 +- test/testData.ts | 63 +++ test/testExplorer/TestExplorer.ts | 7 +- 10 files changed, 878 insertions(+), 159 deletions(-) create mode 100644 test/sql/containercopy.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8db8a78..dac26c32d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,9 @@ jobs: NOSQL_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-readonly.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$NOSQL_READONLY_TESTACCOUNT_TOKEN" echo NOSQL_READONLY_TESTACCOUNT_TOKEN=$NOSQL_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-sql-containercopyonly.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN" + echo NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=$NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN >> $GITHUB_ENV TABLE_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-tables.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$TABLE_TESTACCOUNT_TOKEN" echo TABLE_TESTACCOUNT_TOKEN=$TABLE_TESTACCOUNT_TOKEN >> $GITHUB_ENV @@ -210,6 +213,8 @@ jobs: # MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) # echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" # echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + - name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list - name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3 - name: Upload blob report to GitHub Actions Artifacts diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx index a1885e062..9bb7fe3ea 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.test.tsx @@ -1,5 +1,6 @@ import "@testing-library/jest-dom"; import Explorer from "Explorer/Explorer"; +import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers"; import * as Logger from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import * as dataTransferService from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; @@ -30,6 +31,7 @@ jest.mock("../../../Common/Logger"); jest.mock("../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"); jest.mock("../MonitorCopyJobs/MonitorCopyJobRefState"); jest.mock("../CopyJobUtils"); +jest.mock("../../../Common/dataAccess/dataTransfers"); describe("CopyJobActions", () => { beforeEach(() => { @@ -154,33 +156,31 @@ describe("CopyJobActions", () => { }); it("should fetch and format copy jobs successfully", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "online", - duration: "01:30:45", - source: { - component: "CosmosDBSql", - databaseName: "source-db", - containerName: "source-container", - }, - destination: { - component: "CosmosDBSql", - databaseName: "target-db", - containerName: "target-container", - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "01:30:45", + source: { + component: "CosmosDBSql", + databaseName: "source-db", + containerName: "source-container", + }, + destination: { + component: "CosmosDBSql", + databaseName: "target-db", + containerName: "target-container", }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -201,38 +201,36 @@ describe("CopyJobActions", () => { }); it("should filter jobs by CosmosDBSql component", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "sql-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "02:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "sql-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "02:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - { - properties: { - jobName: "other-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T11:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "01:00:00", - source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + }, + { + properties: { + jobName: "other-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T11:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "OtherComponent", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -247,38 +245,36 @@ describe("CopyJobActions", () => { }); it("should sort jobs by last updated time (newest first)", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "older-job", - status: "Completed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 100, - totalCount: 100, - mode: "offline", - duration: "01:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "older-job", + status: "Completed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 100, + totalCount: 100, + mode: "offline", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - { - properties: { - jobName: "newer-job", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-02T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "online", - duration: "00:30:00", - source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" }, - destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" }, - }, + }, + { + properties: { + jobName: "newer-job", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-02T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "online", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db3", containerName: "c3" }, + destination: { component: "CosmosDBSql", databaseName: "db4", containerName: "c4" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -293,25 +289,23 @@ describe("CopyJobActions", () => { }); it("should calculate completion percentage correctly", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "InProgress", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 75, - totalCount: 100, - mode: "online", - duration: "01:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "InProgress", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 75, + totalCount: 100, + mode: "online", + duration: "01:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -325,25 +319,23 @@ describe("CopyJobActions", () => { }); it("should handle zero total count gracefully", async () => { - const mockResponse = { - value: [ - { - properties: { - jobName: "job-1", - status: "Pending", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 0, - totalCount: 0, - mode: "online", - duration: "00:00:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - }, + const mockResponse = [ + { + properties: { + jobName: "job-1", + status: "Pending", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 0, + totalCount: 0, + mode: "online", + duration: "00:00:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -361,26 +353,24 @@ describe("CopyJobActions", () => { message: "Error message line 1\r\n\r\nError message line 2", code: "ErrorCode123", }; - const mockResponse = { - value: [ - { - properties: { - jobName: "failed-job", - status: "Failed", - lastUpdatedUtcTime: "2025-01-01T10:00:00Z", - processedCount: 50, - totalCount: 100, - mode: "offline", - duration: "00:30:00", - source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, - destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, - error: mockError, - }, + const mockResponse = [ + { + properties: { + jobName: "failed-job", + status: "Failed", + lastUpdatedUtcTime: "2025-01-01T10:00:00Z", + processedCount: 50, + totalCount: 100, + mode: "offline", + duration: "00:30:00", + source: { component: "CosmosDBSql", databaseName: "db1", containerName: "c1" }, + destination: { component: "CosmosDBSql", databaseName: "db2", containerName: "c2" }, + error: mockError, }, - ], - }; + }, + ]; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue(mockResponse); + (getDataTransferJobs as jest.Mock).mockResolvedValue(mockResponse); (CopyJobUtils.formatUTCDateTime as jest.Mock).mockReturnValue({ formattedDateTime: "1/1/2025, 10:00:00 AM", timestamp: 1704106800000, @@ -408,7 +398,7 @@ describe("CopyJobActions", () => { }; (global as any).AbortController = jest.fn(() => mockAbortController); - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ value: [] }); + (getDataTransferJobs as jest.Mock).mockResolvedValue([]); getCopyJobs(); expect(mockAbortController.abort).not.toHaveBeenCalled(); @@ -418,9 +408,7 @@ describe("CopyJobActions", () => { }); it("should throw error for invalid response format", async () => { - (dataTransferService.listByDatabaseAccount as jest.Mock).mockResolvedValue({ - value: "not-an-array", - }); + (getDataTransferJobs as jest.Mock).mockResolvedValue("not-an-array"); await expect(getCopyJobs()).rejects.toThrow("Invalid migration job status response: Expected an array of jobs."); }); @@ -430,7 +418,7 @@ describe("CopyJobActions", () => { message: "Aborted", content: JSON.stringify({ message: "signal is aborted without reason" }), }; - (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(abortError); + (getDataTransferJobs as jest.Mock).mockRejectedValue(abortError); await expect(getCopyJobs()).rejects.toMatchObject({ message: expect.stringContaining("Previous copy job request was cancelled."), @@ -439,7 +427,7 @@ describe("CopyJobActions", () => { it("should handle generic errors", async () => { const genericError = new Error("Network error"); - (dataTransferService.listByDatabaseAccount as jest.Mock).mockRejectedValue(genericError); + (getDataTransferJobs as jest.Mock).mockRejectedValue(genericError); await expect(getCopyJobs()).rejects.toThrow("Network error"); }); diff --git a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx index e2e6b6fc7..821f87bc9 100644 --- a/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx +++ b/src/Explorer/ContainerCopy/Actions/CopyJobActions.tsx @@ -1,13 +1,13 @@ import Explorer from "Explorer/Explorer"; import React from "react"; import { userContext } from "UserContext"; +import { getDataTransferJobs } from "../../../Common/dataAccess/dataTransfers"; import { logError } from "../../../Common/Logger"; import { useSidePanel } from "../../../hooks/useSidePanel"; import { cancel, complete, create, - listByDatabaseAccount, pause, resume, } from "../../../Utils/arm/generatedClients/dataTransferService/dataTransferJobs"; @@ -63,14 +63,8 @@ export const getCopyJobs = async (): Promise => { const { subscriptionId, resourceGroup, accountName } = getAccountDetailsFromResourceId( userContext.databaseAccount?.id || "", ); - const response = await listByDatabaseAccount( - subscriptionId, - resourceGroup, - accountName, - copyJobsAbortController.signal, - ); + const jobs = await getDataTransferJobs(subscriptionId, resourceGroup, accountName, copyJobsAbortController.signal); - const jobs = response.value || []; if (!Array.isArray(jobs)) { throw new Error("Invalid migration job status response: Expected an array of jobs."); } diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx index 56ec498f8..c89488cbc 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/MonitorCopyJobs.tsx @@ -10,7 +10,7 @@ import CopyJobsNotFound from "../MonitorCopyJobs/Components/CopyJobs.NotFound"; import { CopyJobType, JobActionUpdatorType } from "../Types/CopyJobTypes"; import CopyJobsList from "./Components/CopyJobsList"; -const FETCH_INTERVAL_MS = 30 * 1000; +const FETCH_INTERVAL = 2 * 60 * 1000; const SHIMMER_INDENT_LEVELS: IndentLevel[] = Array(7).fill({ level: 0, width: "100%" }); interface MonitorCopyJobsProps { @@ -57,7 +57,7 @@ const MonitorCopyJobs = forwardRef(({ useEffect(() => { fetchJobs(); - const intervalId = setInterval(fetchJobs, FETCH_INTERVAL_MS); + const intervalId = setInterval(fetchJobs, FETCH_INTERVAL); return () => clearInterval(intervalId); }, [fetchJobs]); diff --git a/test/README.md b/test/README.md index 06c695120..ba5acc22e 100644 --- a/test/README.md +++ b/test/README.md @@ -164,6 +164,9 @@ $ENV:NOSQL_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken +# NoSQL API (Container Copy) +$ENV:NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken + # Tables API $ENV:TABLE_TESTACCOUNT_TOKEN=az account get-access-token --scope "https://.documents.azure.com/.default" -o tsv --query accessToken diff --git a/test/fx.ts b/test/fx.ts index 393eb59d7..c1c2b6a47 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -11,7 +11,7 @@ export interface TestNameOptions { prefixed?: boolean; } -export function generateUniqueName(baseName, options?: TestNameOptions): string { +export function generateUniqueName(baseName: string, options?: TestNameOptions): string { const length = options?.length ?? 1; const timestamp = options?.timestampped === undefined ? true : options.timestampped; const prefixed = options?.prefixed === undefined ? true : options.prefixed; @@ -40,6 +40,7 @@ export enum TestAccount { Mongo32 = "Mongo32", SQL = "SQL", SQLReadOnly = "SQLReadOnly", + SQLContainerCopyOnly = "SQLContainerCopyOnly", } export const defaultAccounts: Record = { @@ -51,6 +52,7 @@ export const defaultAccounts: Record = { [TestAccount.Mongo32]: "github-e2etests-mongo32", [TestAccount.SQL]: "github-e2etests-sql", [TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly", + [TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly", }; export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; @@ -77,7 +79,14 @@ export function getAccountName(accountType: TestAccount) { ); } -export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise { +type TestExplorerUrlOptions = { + iframeSrc?: string; + enablecontainercopy?: boolean; +}; + +export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise { + const { iframeSrc, enablecontainercopy } = options ?? {}; + // We can't retrieve AZ CLI credentials from the browser so we get them here. const token = await getAzureCLICredentialsToken(); const accountName = getAccountName(accountType); @@ -93,6 +102,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN; const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN; + const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN; const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN; const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN; const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN; @@ -108,6 +118,16 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s } break; + case TestAccount.SQLContainerCopyOnly: + if (nosqlContainerCopyRbacToken) { + params.set("nosqlRbacToken", nosqlContainerCopyRbacToken); + params.set("enableaaddataplane", "true"); + } + if (enablecontainercopy) { + params.set("enablecontainercopy", "true"); + } + break; + case TestAccount.SQLReadOnly: if (nosqlReadOnlyRbacToken) { params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken); @@ -165,6 +185,39 @@ export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: s 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( @@ -515,7 +568,7 @@ export class DataExplorer { } /** Waits for the Data Explorer app to load */ - static async waitForExplorer(page: Page) { + static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise { const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle(); if (iframeElement === null) { throw new Error("Explorer iframe not found"); @@ -527,15 +580,126 @@ export class DataExplorer { throw new Error("Explorer frame not found"); } - await explorerFrame?.getByTestId("DataExplorerRoot").waitFor(); + if (!options?.enablecontainercopy) { + await explorerFrame?.getByTestId("DataExplorerRoot").waitFor(); + } return new DataExplorer(explorerFrame); } /** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */ static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise { - const url = await getTestExplorerUrl(testAccount, iframeSrc); + const url = await getTestExplorerUrl(testAccount, { iframeSrc }); await page.goto(url); return DataExplorer.waitForExplorer(page); } } + +export async function waitForApiResponse( + page: Page, + urlPattern: string, + method?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payloadValidator?: (payload: any) => boolean, +) { + try { + // Check if page is still valid before waiting + if (page.isClosed()) { + throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`); + } + + 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; + }, + { timeout: 60 * 1000 }, + ); + } catch (error) { + if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) { + console.warn("Page was closed while waiting for API response:", urlPattern); + throw new Error(`Page closed while waiting for API response: ${urlPattern}`); + } + throw error; + } +} +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, + public wrapper: Locator, + ) {} + + static async waitForContainerCopy(page: Page): Promise { + const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true }); + const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper"); + return new ContainerCopy(explorerFrame.frame, containerCopyWrapper); + } + + static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts new file mode 100644 index 000000000..c019b99b7 --- /dev/null +++ b/test/sql/containercopy.spec.ts @@ -0,0 +1,493 @@ +/* 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/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index 82341bbdc..95f5a957a 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -1,5 +1,5 @@ // import { expect, 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", () => { @@ -83,8 +83,12 @@ // await changePkPanel.getByLabel("Use existing container").check(); // await changePkPanel.getByText("Choose an existing container").click(); -// const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers"); -// await containerDropdownItem.click(); +// const containerDropdownItem = await getDropdownItemByNameOrPosition( +// explorer.frame, +// { name: newContainerId }, +// { ariaLabel: "Existing Containers" }, +// ); +// await containerDropdownItem.click(); // await changePkPanel.getByTestId("Panel/OkButton").click(); // await explorer.frame.getByRole("button", { name: "Cancel" }).click(); diff --git a/test/testData.ts b/test/testData.ts index 9729a90b4..b440f565c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -80,6 +80,69 @@ type createTestSqlContainerConfig = { databaseName?: string; }; +type createMultipleTestSqlContainerConfig = { + containerCount?: number; + partitionKey?: string; + databaseName?: string; + accountType: TestAccount.SQLContainerCopyOnly | TestAccount.SQL; +}; + +export async function createMultipleTestContainers({ + partitionKey = "/partitionKey", + databaseName = "", + containerCount = 1, + accountType = TestAccount.SQL, +}: createMultipleTestSqlContainerConfig): Promise { + const creationPromises: Promise[] = []; + + const databaseId = databaseName ? databaseName : generateUniqueName("db"); + const credentials = getAzureCLICredentials(); + const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); + const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); + const accountName = getAccountName(accountType); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + + const clientOptions: CosmosClientOptions = { + endpoint: account.documentEndpoint!, + }; + + const rbacToken = + accountType === TestAccount.SQL + ? process.env.NOSQL_TESTACCOUNT_TOKEN + : accountType === TestAccount.SQLContainerCopyOnly + ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN + : ""; + if (rbacToken) { + clientOptions.tokenProvider = async (): Promise => { + const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; + const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; + return authorizationToken; + }; + } else { + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + clientOptions.key = keys.primaryMasterKey; + } + + const client = new CosmosClient(clientOptions); + const { database } = await client.databases.createIfNotExists({ id: databaseId }); + + try { + for (let i = 0; i < containerCount; i++) { + const containerId = `testcontainer_${Date.now()}_${Math.random().toString(36).substring(6)}_${i}`; + creationPromises.push( + database.containers.createIfNotExists({ id: containerId, partitionKey }).then(({ container }) => { + return new TestContainerContext(armClient, client, database, container, new Map()); + }), + ); + } + const contexts = await Promise.all(creationPromises); + return contexts; + } catch (e) { + await database.delete(); + throw e; + } +} + export async function createTestSQLContainer({ includeTestData = false, partitionKey = "/partitionKey", diff --git a/test/testExplorer/TestExplorer.ts b/test/testExplorer/TestExplorer.ts index 7e6dc9c24..2a8d5b115 100644 --- a/test/testExplorer/TestExplorer.ts +++ b/test/testExplorer/TestExplorer.ts @@ -11,8 +11,12 @@ const accountName = urlSearchParams.get("accountName") || "portal-sql-runner-wes const selfServeType = urlSearchParams.get("selfServeType") || "example"; const iframeSrc = urlSearchParams.get("iframeSrc") || "explorer.html?platform=Portal&disablePortalInitCache"; const authToken = urlSearchParams.get("token"); +const enablecontainercopy = urlSearchParams.get("enablecontainercopy"); -const nosqlRbacToken = urlSearchParams.get("nosqlRbacToken") || process.env.NOSQL_TESTACCOUNT_TOKEN || ""; +const nosqlRbacToken = + urlSearchParams.get("nosqlRbacToken") || + (enablecontainercopy ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN : process.env.NOSQL_TESTACCOUNT_TOKEN) || + ""; const nosqlReadOnlyRbacToken = urlSearchParams.get("nosqlReadOnlyRbacToken") || process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN || ""; const tableRbacToken = urlSearchParams.get("tableRbacToken") || process.env.TABLE_TESTACCOUNT_TOKEN || ""; @@ -83,6 +87,7 @@ const initTestExplorer = async (): Promise => { authorizationToken: `Bearer ${authToken}`, aadToken: rbacToken, features: {}, + containerCopyEnabled: enablecontainercopy === "true", hasWriteAccess: true, csmEndpoint: "https://management.azure.com", dnsSuffix: "documents.azure.com", From 38823ac86f5ea05bbd1e97f41c041b9fc86287b8 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Thu, 8 Jan 2026 20:15:25 +0530 Subject: [PATCH 03/20] Fix change partition key FTs (#2309) --- .../changePartitionKey.spec.ts | 206 +++++++++++------- 1 file changed, 122 insertions(+), 84 deletions(-) diff --git a/test/sql/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index 95f5a957a..1f23d3154 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -1,104 +1,142 @@ -// import { expect, test } from "@playwright/test"; -// import { DataExplorer, getDropdownItemByNameOrPosition, TestAccount } from "../../fx"; -// import { createTestSQLContainer, TestContainerContext } from "../../testData"; +import { expect, test } from "@playwright/test"; +import { DataExplorer, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; -// test.describe("Change Partition Key", () => { -// let context: TestContainerContext = null!; -// let explorer: DataExplorer = null!; -// const newPartitionKeyPath = "newPartitionKey"; -// const newContainerId = "testcontainer_1"; +test.describe("Change Partition Key", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const newPartitionKeyPath = "newPartitionKey"; + const newContainerId = "testcontainer_1"; + let previousJobName: string | undefined; -// test.beforeAll("Create Test Database", async () => { -// context = await createTestSQLContainer(); -// }); + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); -// test.beforeEach("Open container settings", async ({ page }) => { -// explorer = await DataExplorer.open(page, TestAccount.SQL); + test.beforeEach("Open container settings", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); -// // Click Scale & Settings and open Partition Key tab -// await explorer.openScaleAndSettings(context); -// const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab"); -// await expect(PartitionKeyTab).toBeVisible(); -// await PartitionKeyTab.click(); -// }); + // Click Scale & Settings and open Partition Key tab + await explorer.openScaleAndSettings(context); + const PartitionKeyTab = explorer.frame.getByTestId("settings-tab-header/PartitionKeyTab"); + await expect(PartitionKeyTab).toBeVisible(); + await PartitionKeyTab.click(); + }); -// // Delete database only if not running in CI -// if (!process.env.CI) { -// test.afterEach("Delete Test Database", async () => { -// await context?.dispose(); -// }); -// } + // Delete database only if not running in CI + if (!process.env.CI) { + test.afterEach("Delete Test Database", async () => { + await context?.dispose(); + }); + } -// test("Change partition key path", async () => { -// await expect(explorer.frame.getByText("/partitionKey")).toBeVisible(); -// await expect(explorer.frame.getByText("Change partition key")).toBeVisible(); -// await expect(explorer.frame.getByText(/To safeguard the integrity of/)).toBeVisible(); -// await expect(explorer.frame.getByText(/To change the partition key/)).toBeVisible(); + test("Change partition key path", async ({ page }) => { + await expect(explorer.frame.getByText("/partitionKey")).toBeVisible(); + await expect(explorer.frame.getByText("Change partition key")).toBeVisible(); + await expect(explorer.frame.getByText(/To safeguard the integrity of/)).toBeVisible(); + await expect(explorer.frame.getByText(/To change the partition key/)).toBeVisible(); -// const changePartitionKeyButton = explorer.frame.getByTestId("change-partition-key-button"); -// expect(changePartitionKeyButton).toBeVisible(); -// await changePartitionKeyButton.click(); + const changePartitionKeyButton = explorer.frame.getByTestId("change-partition-key-button"); + expect(changePartitionKeyButton).toBeVisible(); + await changePartitionKeyButton.click(); -// // Fill out new partition key form in the panel -// const changePkPanel = explorer.frame.getByTestId(`Panel:Change partition key`); -// await expect(changePkPanel.getByText(context.database.id)).toBeVisible(); -// await expect(explorer.frame.getByRole("heading", { name: "Change partition key" })).toBeVisible(); -// await expect(explorer.frame.getByText(/When changing a container/)).toBeVisible(); + // Fill out new partition key form in the panel + const changePkPanel = explorer.frame.getByTestId(`Panel:Change partition key`); + await expect(changePkPanel.getByText(context.database.id)).toBeVisible(); + await expect(explorer.frame.getByRole("heading", { name: "Change partition key" })).toBeVisible(); + await expect(explorer.frame.getByText(/When changing a container/)).toBeVisible(); -// // Try to switch to new container -// await expect(changePkPanel.getByText("New container")).toBeVisible(); -// await expect(changePkPanel.getByText("Existing container")).toBeVisible(); -// await expect(changePkPanel.getByTestId("new-container-id-input")).toBeVisible(); + // Try to switch to new container + await expect(changePkPanel.getByText("New container")).toBeVisible(); + await expect(changePkPanel.getByText("Existing container")).toBeVisible(); + await expect(changePkPanel.getByTestId("new-container-id-input")).toBeVisible(); -// changePkPanel.getByTestId("new-container-id-input").fill(newContainerId); -// await expect(changePkPanel.getByTestId("new-container-partition-key-input")).toBeVisible(); -// changePkPanel.getByTestId("new-container-partition-key-input").fill(newPartitionKeyPath); + changePkPanel.getByTestId("new-container-id-input").fill(newContainerId); + await expect(changePkPanel.getByTestId("new-container-partition-key-input")).toBeVisible(); + changePkPanel.getByTestId("new-container-partition-key-input").fill(newPartitionKeyPath); -// await expect(changePkPanel.getByTestId("add-sub-partition-key-button")).toBeVisible(); -// changePkPanel.getByTestId("add-sub-partition-key-button").click(); -// await expect(changePkPanel.getByTestId("new-container-sub-partition-key-input-0")).toBeVisible(); -// await expect(changePkPanel.getByTestId("remove-sub-partition-key-button-0")).toBeVisible(); -// await expect(changePkPanel.getByTestId("hierarchical-partitioning-info-text")).toBeVisible(); -// await changePkPanel.getByTestId("remove-sub-partition-key-button-0").click(); + await expect(changePkPanel.getByTestId("add-sub-partition-key-button")).toBeVisible(); + changePkPanel.getByTestId("add-sub-partition-key-button").click(); + await expect(changePkPanel.getByTestId("new-container-sub-partition-key-input-0")).toBeVisible(); + await expect(changePkPanel.getByTestId("remove-sub-partition-key-button-0")).toBeVisible(); + await expect(changePkPanel.getByTestId("hierarchical-partitioning-info-text")).toBeVisible(); + await changePkPanel.getByTestId("remove-sub-partition-key-button-0").click(); -// await changePkPanel.getByTestId("Panel/OkButton").click(); + await changePkPanel.getByTestId("Panel/OkButton").click(); -// await expect(changePkPanel).not.toBeVisible({ timeout: 5 * 60 * 1000 }); + let jobName: string | undefined; + await page.waitForRequest( + (req) => { + const requestUrl = req.url(); + if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") { + jobName = new URL(requestUrl).pathname.split("/").pop(); + return true; + } + return false; + }, + { timeout: 120000 }, + ); -// // Verify partition key change job -// const jobText = explorer.frame.getByText(/Partition key change job/); -// await expect(jobText).toBeVisible(); -// await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1"); + await expect(changePkPanel).not.toBeVisible({ timeout: 5 * 60 * 1000 }); -// const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription"); -// // await expect(jobRow.getByText("Pending")).toBeVisible({ timeout: 30 * 1000 }); -// await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 }); + // Verify partition key change job + const jobText = explorer.frame.getByText(/Partition key change job/); + await expect(jobText).toBeVisible(); + // await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText("Portal_testcontainer_1"); + await expect(explorer.frame.locator(".ms-ProgressIndicator-itemName")).toContainText(jobName!); -// const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId); -// expect(newContainerNode).not.toBeNull(); + const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription"); + // await expect(jobRow.getByText("Pending")).toBeVisible({ timeout: 30 * 1000 }); + await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 }); -// // Now try to switch to existing container -// await changePartitionKeyButton.click(); -// await changePkPanel.getByText("Existing container").click(); -// await changePkPanel.getByLabel("Use existing container").check(); -// await changePkPanel.getByText("Choose an existing container").click(); + const newContainerNode = await explorer.waitForContainerNode(context.database.id, newContainerId); + expect(newContainerNode).not.toBeNull(); -// const containerDropdownItem = await getDropdownItemByNameOrPosition( -// explorer.frame, -// { name: newContainerId }, -// { ariaLabel: "Existing Containers" }, -// ); -// await containerDropdownItem.click(); + // Now try to switch to existing container + // Ensure this job name is different from the previously processed job name + previousJobName = jobName; -// await changePkPanel.getByTestId("Panel/OkButton").click(); -// await explorer.frame.getByRole("button", { name: "Cancel" }).click(); + await changePartitionKeyButton.click(); + await changePkPanel.getByText("Existing container").click(); + await changePkPanel.getByLabel("Use existing container").check(); + await changePkPanel.getByText("Choose an existing container").click(); -// // Dismiss overlay if it appears -// const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first(); -// if (await overlayFrame.count()) { -// await overlayFrame.contentFrame().getByLabel("Dismiss").click(); -// } -// const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0"); -// await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 }); -// }); -// }); + const containerDropdownItem = await explorer.getDropdownItemByName(newContainerId, "Existing Containers"); + await containerDropdownItem.click(); + + let secondJobName: string | undefined; + await Promise.all([ + page.waitForRequest( + (req) => { + const requestUrl = req.url(); + if (requestUrl.includes("/dataTransferJobs") && req.method() === "PUT") { + secondJobName = new URL(requestUrl).pathname.split("/").pop(); + return true; + } + return false; + }, + { timeout: 120000 }, + ), + changePkPanel.getByTestId("Panel/OkButton").click(), + ]); + + const cancelButton = explorer.frame.getByRole("button", { name: "Cancel" }); + const isCancelButtonVisible = await cancelButton.isVisible().catch(() => false); + if (isCancelButtonVisible) { + await cancelButton.click(); + + // Dismiss overlay if it appears + const overlayFrame = explorer.frame.locator("#webpack-dev-server-client-overlay").first(); + if (await overlayFrame.count()) { + await overlayFrame.contentFrame().getByLabel("Dismiss").click(); + } + + const cancelledJobRow = explorer.frame.getByTestId("Tab:tab0"); + await expect(cancelledJobRow.getByText("Cancelled")).toBeVisible({ timeout: 30 * 1000 }); + } else { + const jobRow = explorer.frame.locator(".ms-ProgressIndicator-itemDescription"); + await expect(jobRow.getByText("Completed")).toBeVisible({ timeout: 5 * 60 * 1000 }); + expect(secondJobName).not.toBe(previousJobName); + } + }); +}); From 234e4181fc29ce94010ec77e75369bdcb1af51b2 Mon Sep 17 00:00:00 2001 From: Nishtha Ahuja <45535788+nishthaAhujaa@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:32:44 +0530 Subject: [PATCH 04/20] Index Advisor (#2270) Index Advisor on query --- .../Settings/SettingsComponent.test.tsx | 49 +++ .../Controls/Settings/SettingsComponent.tsx | 42 +- .../IndexingPolicyRefreshComponent.tsx | 4 +- .../SettingsComponent.test.tsx.snap | 40 ++ .../Tabs/QueryTab/IndexAdvisorUtils.tsx | 107 +++++ .../Tabs/QueryTab/QueryResultSection.tsx | 12 +- .../Tabs/QueryTab/QueryTabComponent.test.tsx | 3 +- .../Tabs/QueryTab/QueryTabComponent.tsx | 23 ++ .../Tabs/QueryTab/ResultsView.test.tsx | 170 ++++++++ src/Explorer/Tabs/QueryTab/ResultsView.tsx | 381 +++++++++++++++++- src/Explorer/Tabs/QueryTab/StylesAdvisor.ts | 95 +++++ .../Tabs/QueryTab/useQueryMetadataStore.ts | 15 + test/sql/indexAdvisor.spec.ts | 145 +++++++ 13 files changed, 1068 insertions(+), 18 deletions(-) create mode 100644 src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx create mode 100644 src/Explorer/Tabs/QueryTab/ResultsView.test.tsx create mode 100644 src/Explorer/Tabs/QueryTab/StylesAdvisor.ts create mode 100644 src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts create mode 100644 test/sql/indexAdvisor.spec.ts diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index e6e57f1c7..7dfd49b11 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -1,5 +1,8 @@ +import { IndexingPolicy } from "@azure/cosmos"; +import { act } from "@testing-library/react"; import { AuthType } from "AuthType"; import { shallow } from "enzyme"; +import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import ko from "knockout"; import React from "react"; import { updateCollection } from "../../../Common/dataAccess/updateCollection"; @@ -444,3 +447,49 @@ describe("SettingsComponent", () => { expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true); }); }); + +describe("SettingsComponent - indexing policy subscription", () => { + const baseProps: SettingsComponentProps = { + settingsTab: new CollectionSettingsTabV2({ + collection: collection, + tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2, + title: "Scale & Settings", + tabPath: "", + node: undefined, + }), + }; + + it("subscribes to the correct container's indexing policy and updates state on change", async () => { + const containerId = collection.id(); + const mockIndexingPolicy: IndexingPolicy = { + automatic: false, + indexingMode: "lazy", + includedPaths: [{ path: "/foo/*" }], + excludedPaths: [{ path: "/bar/*" }], + compositeIndexes: [], + spatialIndexes: [], + vectorIndexes: [], + fullTextIndexes: [], + }; + + const wrapper = shallow(); + const instance = wrapper.instance() as SettingsComponent; + + await act(async () => { + useIndexingPolicyStore.setState({ + indexingPolicies: { + [containerId]: mockIndexingPolicy, + }, + }); + // Wait for the async refreshCollectionData to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + wrapper.update(); + + expect(wrapper.state("indexingPolicyContent")).toEqual(mockIndexingPolicy); + expect(wrapper.state("indexingPolicyContentBaseline")).toEqual(mockIndexingPolicy); + // @ts-expect-error: rawDataModel is intentionally accessed for test validation + expect(instance.collection.rawDataModel.indexingPolicy).toEqual(mockIndexingPolicy); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 0802fc863..95f7159cc 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -13,6 +13,7 @@ import { ThroughputBucketsComponent, ThroughputBucketsComponentProps, } from "Explorer/Controls/Settings/SettingsSubComponents/ThroughputInputComponents/ThroughputBucketsComponent"; +import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import { useDatabases } from "Explorer/useDatabases"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; @@ -73,7 +74,6 @@ import { parseConflictResolutionMode, parseConflictResolutionProcedure, } from "./SettingsUtils"; - interface SettingsV2TabInfo { tab: SettingsV2TabTypes; content: JSX.Element; @@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component void; constructor(props: SettingsComponentProps) { super(props); @@ -312,6 +312,13 @@ export class SettingsComponent extends React.Component { + this.refreshCollectionData(); + }, + (state) => state.indexingPolicies[this.collection?.id()], + ); + this.refreshCollectionData(); } this.setBaseline(); @@ -319,7 +326,11 @@ export class SettingsComponent extends React.Component => { + const containerId = this.collection.id(); + const latestIndexingPolicy = useIndexingPolicyStore.getState().indexingPolicies[containerId]; + const rawPolicy = latestIndexingPolicy ?? this.collection.indexingPolicy(); + + const latestCollection: DataModels.IndexingPolicy = { + automatic: rawPolicy?.automatic ?? true, + indexingMode: rawPolicy?.indexingMode ?? "consistent", + includedPaths: rawPolicy?.includedPaths ?? [], + excludedPaths: rawPolicy?.excludedPaths ?? [], + compositeIndexes: rawPolicy?.compositeIndexes ?? [], + spatialIndexes: rawPolicy?.spatialIndexes ?? [], + vectorIndexes: rawPolicy?.vectorIndexes ?? [], + fullTextIndexes: rawPolicy?.fullTextIndexes ?? [], + }; + + this.collection.rawDataModel.indexingPolicy = latestCollection; + this.setState({ + indexingPolicyContent: latestCollection, + indexingPolicyContentBaseline: latestCollection, + }); + }; private saveCollectionSettings = async (startKey: number): Promise => { const newCollection: DataModels.Collection = { ...this.collection.rawDataModel }; - if ( this.state.isSubSettingsSaveable || this.state.isContainerPolicyDirty || @@ -1252,7 +1283,6 @@ export class SettingsComponent extends React.Component diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx index d601e3857..509373b8d 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/IndexingPolicyRefresh/IndexingPolicyRefreshComponent.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; import { MessageBar, MessageBarType } from "@fluentui/react"; +import * as React from "react"; +import { handleError } from "../../../../../Common/ErrorHandlingUtils"; import { mongoIndexTransformationRefreshingMessage, renderMongoIndexTransformationRefreshMessage, } from "../../SettingsRenderUtils"; -import { handleError } from "../../../../../Common/ErrorHandlingUtils"; import { isIndexTransforming } from "../../SettingsUtils"; export interface IndexingPolicyRefreshComponentProps { diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 27c4eeff5..569bfd035 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -153,6 +153,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -264,6 +274,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -476,6 +496,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { @@ -653,6 +683,16 @@ exports[`SettingsComponent renders 1`] = ` "partitionKey", ], "rawDataModel": { + "indexingPolicy": { + "automatic": true, + "compositeIndexes": [], + "excludedPaths": [], + "fullTextIndexes": [], + "includedPaths": [], + "indexingMode": "consistent", + "spatialIndexes": [], + "vectorIndexes": [], + }, "uniqueKeyPolicy": { "uniqueKeys": [ { diff --git a/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx new file mode 100644 index 000000000..7e3dfafbc --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/IndexAdvisorUtils.tsx @@ -0,0 +1,107 @@ +import { CircleFilled } from "@fluentui/react-icons"; +import type { IIndexMetric } from "Explorer/Tabs/QueryTab/ResultsView"; +import { useIndexAdvisorStyles } from "Explorer/Tabs/QueryTab/StylesAdvisor"; +import * as React from "react"; + +// SDK response format +export interface IndexMetricsResponse { + UtilizedIndexes?: { + SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>; + CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>; + }; + PotentialIndexes?: { + SingleIndexes?: Array<{ IndexSpec: string; IndexImpactScore?: string }>; + CompositeIndexes?: Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>; + }; +} + +export function parseIndexMetrics(indexMetrics: IndexMetricsResponse): { + included: IIndexMetric[]; + notIncluded: IIndexMetric[]; +} { + const included: IIndexMetric[] = []; + const notIncluded: IIndexMetric[] = []; + + // Process UtilizedIndexes (Included in Current Policy) + if (indexMetrics.UtilizedIndexes) { + // Single indexes + indexMetrics.UtilizedIndexes.SingleIndexes?.forEach((index) => { + included.push({ + index: index.IndexSpec, + impact: index.IndexImpactScore || "Utilized", + section: "Included", + path: index.IndexSpec, + }); + }); + + // Composite indexes + indexMetrics.UtilizedIndexes.CompositeIndexes?.forEach((index) => { + const compositeSpec = index.IndexSpecs.join(", "); + included.push({ + index: compositeSpec, + impact: index.IndexImpactScore || "Utilized", + section: "Included", + composite: index.IndexSpecs.map((spec) => { + const [path, order] = spec.trim().split(/\s+/); + return { + path: path.trim(), + order: order?.toLowerCase() === "desc" ? "descending" : "ascending", + }; + }), + }); + }); + } + + // Process PotentialIndexes (Not Included in Current Policy) + if (indexMetrics.PotentialIndexes) { + // Single indexes + indexMetrics.PotentialIndexes.SingleIndexes?.forEach((index) => { + notIncluded.push({ + index: index.IndexSpec, + impact: index.IndexImpactScore || "Unknown", + section: "Not Included", + path: index.IndexSpec, + }); + }); + + // Composite indexes + indexMetrics.PotentialIndexes.CompositeIndexes?.forEach((index) => { + const compositeSpec = index.IndexSpecs.join(", "); + notIncluded.push({ + index: compositeSpec, + impact: index.IndexImpactScore || "Unknown", + section: "Not Included", + composite: index.IndexSpecs.map((spec) => { + const [path, order] = spec.trim().split(/\s+/); + return { + path: path.trim(), + order: order?.toLowerCase() === "desc" ? "descending" : "ascending", + }; + }), + }); + }); + } + + return { included, notIncluded }; +} + +export const renderImpactDots = (impact: string): JSX.Element => { + const style = useIndexAdvisorStyles(); + let count = 0; + + if (impact === "High") { + count = 3; + } else if (impact === "Medium") { + count = 2; + } else if (impact === "Low") { + count = 1; + } + + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; diff --git a/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx index e6bd5fdc1..a5ee941ef 100644 --- a/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryResultSection.tsx @@ -3,18 +3,21 @@ import QueryError from "Common/QueryError"; import { IndeterminateProgressBar } from "Explorer/Controls/IndeterminateProgressBar"; import { MessageBanner } from "Explorer/Controls/MessageBanner"; import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles"; +import useZoomLevel from "hooks/useZoomLevel"; import React from "react"; +import { conditionalClass } from "Utils/StyleUtils"; import RunQuery from "../../../../images/RunQuery.png"; import { QueryResults } from "../../../Contracts/ViewModels"; import { ErrorList } from "./ErrorList"; import { ResultsView } from "./ResultsView"; -import useZoomLevel from "hooks/useZoomLevel"; -import { conditionalClass } from "Utils/StyleUtils"; export interface ResultsViewProps { isMongoDB: boolean; queryResults: QueryResults; executeQueryDocumentsPage: (firstItemIndex: number) => Promise; + queryEditorContent?: string; + databaseId?: string; + containerId?: string; } interface QueryResultProps extends ResultsViewProps { @@ -49,6 +52,8 @@ export const QueryResultSection: React.FC = ({ queryResults, executeQueryDocumentsPage, isExecuting, + databaseId, + containerId, }: QueryResultProps): JSX.Element => { const styles = useQueryTabStyles(); const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent); @@ -91,6 +96,9 @@ export const QueryResultSection: React.FC = ({ queryResults={queryResults} executeQueryDocumentsPage={executeQueryDocumentsPage} isMongoDB={isMongoDB} + queryEditorContent={queryEditorContent} + databaseId={databaseId} + containerId={containerId} /> ) : ( diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx index bc2e2f213..bf3ed538f 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.test.tsx @@ -52,8 +52,9 @@ describe("QueryTabComponent", () => { copilotVersion: "v3.0", }, }); + const propsMock: Readonly = { - collection: { databaseId: "CopilotSampleDB" }, + collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" }, onTabAccessor: () => jest.fn(), isExecutionError: false, tabId: "mockTabId", diff --git a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx index e3dafec50..7108a3d70 100644 --- a/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx +++ b/src/Explorer/Tabs/QueryTab/QueryTabComponent.tsx @@ -28,6 +28,7 @@ import { useMonacoTheme } from "hooks/useTheme"; import React, { Fragment, createRef } from "react"; import "react-splitter-layout/lib/index.css"; import { format } from "react-string-format"; +import create from "zustand"; //TODO: Uncomment next two lines when query copilot is reinstated in DE // import QueryCommandIcon from "../../../../images/CopilotCommand.svg"; // import LaunchCopilot from "../../../../images/CopilotTabIcon.svg"; @@ -57,6 +58,20 @@ import { SaveQueryPane } from "../../Panes/SaveQueryPane/SaveQueryPane"; import TabsBase from "../TabsBase"; import "./QueryTabComponent.less"; +export interface QueryMetadataStore { + userQuery: string; + databaseId: string; + containerId: string; + setMetadata: (query1: string, db: string, container: string) => void; +} + +export const useQueryMetadataStore = create((set) => ({ + userQuery: "", + databaseId: "", + containerId: "", + setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }), +})); + enum ToggleState { Result, QueryMetrics, @@ -264,6 +279,10 @@ class QueryTabComponentImpl extends React.Component => { + const query1 = this.state.sqlQueryEditorContent; + const db = this.props.collection.databaseId; + const container = this.props.collection.id(); + useQueryMetadataStore.getState().setMetadata(query1, db, container); this._iterator = undefined; setTimeout(async () => { @@ -780,6 +799,8 @@ class QueryTabComponentImpl extends React.Component QueryDocumentsPerPage( firstItemIndex, @@ -795,6 +816,8 @@ class QueryTabComponentImpl extends React.Component this._executeQueryDocumentsPage(firstItemIndex) } diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx new file mode 100644 index 000000000..31bdf939c --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/ResultsView.test.tsx @@ -0,0 +1,170 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import { IndexAdvisorTab } from "Explorer/Tabs/QueryTab/ResultsView"; +import React from "react"; + +const mockReplace = jest.fn(); +const mockFetchAll = jest.fn(); +const mockRead = jest.fn(); +const mockLogConsoleProgress = jest.fn(); +const mockHandleError = jest.fn(); + +const indexMetricsResponse = { + UtilizedIndexes: { + SingleIndexes: [{ IndexSpec: "/foo/?", IndexImpactScore: "High" }], + CompositeIndexes: [{ IndexSpecs: ["/baz/? DESC", "/qux/? ASC"], IndexImpactScore: "Low" }], + }, + PotentialIndexes: { + SingleIndexes: [{ IndexSpec: "/bar/?", IndexImpactScore: "Medium" }], + CompositeIndexes: [] as Array<{ IndexSpecs: string[]; IndexImpactScore?: string }>, + }, +}; + +const mockQueryResults = { + documents: [] as unknown[], + hasMoreResults: false, + itemCount: 0, + firstItemIndex: 0, + lastItemIndex: 0, + requestCharge: 0, + activityId: "test-activity-id", +}; + +mockRead.mockResolvedValue({ + resource: { + indexingPolicy: { + automatic: true, + indexingMode: "consistent", + includedPaths: [{ path: "/*" }, { path: "/foo/?" }], + excludedPaths: [], + }, + partitionKey: "pk", + }, +}); + +mockReplace.mockResolvedValue({ + resource: { + indexingPolicy: { + automatic: true, + indexingMode: "consistent", + includedPaths: [{ path: "/*" }], + excludedPaths: [], + }, + }, +}); + +jest.mock("Common/CosmosClient", () => ({ + client: () => ({ + database: () => ({ + container: () => ({ + items: { + query: () => ({ + fetchAll: mockFetchAll, + }), + }, + read: mockRead, + replace: mockReplace, + }), + }), + }), +})); + +jest.mock("./StylesAdvisor", () => ({ + useIndexAdvisorStyles: () => ({}), +})); + +jest.mock("../../../Utils/NotificationConsoleUtils", () => ({ + logConsoleProgress: (...args: unknown[]) => { + mockLogConsoleProgress(...args); + return () => {}; + }, +})); + +jest.mock("../../../Common/ErrorHandlingUtils", () => ({ + handleError: (...args: unknown[]) => mockHandleError(...args), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchAll.mockResolvedValue({ indexMetrics: indexMetricsResponse }); +}); + +describe("IndexAdvisorTab Basic Tests", () => { + test("component renders without crashing", () => { + const { container } = render( + , + ); + expect(container).toBeTruthy(); + }); + + test("renders component and handles missing parameters", () => { + const { container } = render(); + expect(container).toBeTruthy(); + // Should not crash when parameters are missing + }); + + test("fetches index metrics with query results", async () => { + render( + , + ); + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); + }); + + test("displays content after loading", async () => { + render( + , + ); + // Wait for the component to finish loading + await waitFor(() => expect(mockFetchAll).toHaveBeenCalled()); + // Component should have rendered some content + expect(screen.getByText(/Index Advisor/i)).toBeInTheDocument(); + }); + + test("calls log console progress when fetching metrics", async () => { + render( + , + ); + await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled()); + }); + + test("handles error when fetch fails", async () => { + mockFetchAll.mockRejectedValueOnce(new Error("fetch failed")); + render( + , + ); + await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 }); + }); + + test("renders with all required props", () => { + const { container } = render( + , + ); + expect(container).toBeTruthy(); + expect(container.firstChild).toBeTruthy(); + }); +}); diff --git a/src/Explorer/Tabs/QueryTab/ResultsView.tsx b/src/Explorer/Tabs/QueryTab/ResultsView.tsx index 64b987b69..fbc56e89f 100644 --- a/src/Explorer/Tabs/QueryTab/ResultsView.tsx +++ b/src/Explorer/Tabs/QueryTab/ResultsView.tsx @@ -1,5 +1,8 @@ +import type { CompositePath, IndexingPolicy } from "@azure/cosmos"; +import { FontIcon } from "@fluentui/react"; import { Button, + Checkbox, DataGrid, DataGridBody, DataGridCell, @@ -8,28 +11,45 @@ import { DataGridRow, SelectTabData, SelectTabEvent, + Spinner, Tab, TabList, + Table, + TableBody, + TableCell, TableColumnDefinition, + TableHeader, + TableRow, createTableColumn, } from "@fluentui/react-components"; -import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons"; +import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons"; +import copy from "clipboard-copy"; import { HttpHeaders } from "Common/Constants"; import MongoUtility from "Common/MongoUtility"; import { QueryMetrics } from "Contracts/DataModels"; +import { QueryResults } from "Contracts/ViewModels"; import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import { + parseIndexMetrics, + renderImpactDots, + type IndexMetricsResponse, +} from "Explorer/Tabs/QueryTab/IndexAdvisorUtils"; import { IDocument } from "Explorer/Tabs/QueryTab/QueryTabComponent"; import { useQueryTabStyles } from "Explorer/Tabs/QueryTab/Styles"; +import React, { useCallback, useEffect, useState } from "react"; import { userContext } from "UserContext"; -import copy from "clipboard-copy"; -import React, { useCallback, useState } from "react"; +import { logConsoleProgress } from "Utils/NotificationConsoleUtils"; +import create from "zustand"; +import { client } from "../../../Common/CosmosClient"; +import { handleError } from "../../../Common/ErrorHandlingUtils"; +import { sampleDataClient } from "../../../Common/SampleDataClient"; import { ResultsViewProps } from "./QueryResultSection"; - +import { useIndexAdvisorStyles } from "./StylesAdvisor"; enum ResultsTabs { Results = "results", QueryStats = "queryStats", + IndexAdvisor = "indexadv", } - const ResultsTab: React.FC = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => { const styles = useQueryTabStyles(); /* eslint-disable react/prop-types */ @@ -523,14 +543,331 @@ const QueryStatsTab: React.FC> = ({ query ); }; -export const ResultsView: React.FC = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => { +export interface IIndexMetric { + index: string; + impact: string; + section: "Included" | "Not Included" | "Header"; + path?: string; + composite?: { path: string; order: string }[]; +} +export const IndexAdvisorTab: React.FC<{ + queryResults?: QueryResults; + queryEditorContent?: string; + databaseId?: string; + containerId?: string; +}> = ({ queryResults, queryEditorContent, databaseId, containerId }) => { + const style = useIndexAdvisorStyles(); + + const [loading, setLoading] = useState(false); + const [indexMetrics, setIndexMetrics] = useState(null); + const [showIncluded, setShowIncluded] = useState(true); + const [showNotIncluded, setShowNotIncluded] = useState(true); + const [selectedIndexes, setSelectedIndexes] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [updateMessageShown, setUpdateMessageShown] = useState(false); + const [included, setIncludedIndexes] = useState([]); + const [notIncluded, setNotIncludedIndexes] = useState([]); + const [isUpdating, setIsUpdating] = useState(false); + const [justUpdatedPolicy, setJustUpdatedPolicy] = useState(false); + const indexingMetricsDocLink = "https://learn.microsoft.com/azure/cosmos-db/nosql/index-metrics"; + + const fetchIndexMetrics = async () => { + if (!queryEditorContent || !databaseId || !containerId) { + return; + } + + setLoading(true); + const clearMessage = logConsoleProgress(`Querying items with IndexMetrics in container ${containerId}`); + try { + const querySpec = { + query: queryEditorContent, + }; + + // Use sampleDataClient for CopilotSampleDB, regular client for other databases + const cosmosClient = databaseId === "CopilotSampleDB" ? sampleDataClient() : client(); + + const sdkResponse = await cosmosClient + .database(databaseId) + .container(containerId) + .items.query(querySpec, { + populateIndexMetrics: true, + }) + .fetchAll(); + + const parsedMetrics = + typeof sdkResponse.indexMetrics === "string" ? JSON.parse(sdkResponse.indexMetrics) : sdkResponse.indexMetrics; + + setIndexMetrics(parsedMetrics); + } catch (error) { + handleError(error, "queryItemsWithIndexMetrics", `Error querying items from ${containerId}`); + } finally { + clearMessage(); + setLoading(false); + } + }; + + // Fetch index metrics when query results change (i.e., when Execute Query is clicked) + useEffect(() => { + if (queryEditorContent && databaseId && containerId && queryResults) { + fetchIndexMetrics(); + } + }, [queryResults]); + + useEffect(() => { + if (!indexMetrics) { + return; + } + + const { included, notIncluded } = parseIndexMetrics(indexMetrics); + setIncludedIndexes(included); + setNotIncludedIndexes(notIncluded); + if (justUpdatedPolicy) { + setJustUpdatedPolicy(false); + } else { + setUpdateMessageShown(false); + } + }, [indexMetrics]); + + useEffect(() => { + const allSelected = + notIncluded.length > 0 && notIncluded.every((item) => selectedIndexes.some((s) => s.index === item.index)); + setSelectAll(allSelected); + }, [selectedIndexes, notIncluded]); + + const handleCheckboxChange = (indexObj: IIndexMetric, checked: boolean) => { + if (checked) { + setSelectedIndexes((prev) => [...prev, indexObj]); + } else { + setSelectedIndexes((prev) => prev.filter((item) => item.index !== indexObj.index)); + } + }; + + const handleSelectAll = (checked: boolean) => { + setSelectAll(checked); + setSelectedIndexes(checked ? notIncluded : []); + }; + + const handleUpdatePolicy = async () => { + setIsUpdating(true); + try { + const containerRef = client().database(databaseId).container(containerId); + const { resource: containerDef } = await containerRef.read(); + + const newIncludedPaths = selectedIndexes + .filter((index) => !index.composite) + .map((index) => { + return { + path: index.path, + }; + }); + + const newCompositeIndexes: CompositePath[][] = selectedIndexes + .filter((index) => Array.isArray(index.composite)) + .map( + (index) => + (index.composite as { path: string; order: string }[]).map((comp) => ({ + path: comp.path, + order: comp.order === "descending" ? "descending" : "ascending", + })) as CompositePath[], + ); + + const updatedPolicy: IndexingPolicy = { + ...containerDef.indexingPolicy, + includedPaths: [...(containerDef.indexingPolicy?.includedPaths || []), ...newIncludedPaths], + compositeIndexes: [...(containerDef.indexingPolicy?.compositeIndexes || []), ...newCompositeIndexes], + automatic: containerDef.indexingPolicy?.automatic ?? true, + indexingMode: containerDef.indexingPolicy?.indexingMode ?? "consistent", + excludedPaths: containerDef.indexingPolicy?.excludedPaths ?? [], + }; + await containerRef.replace({ + id: containerId, + partitionKey: containerDef.partitionKey, + indexingPolicy: updatedPolicy, + }); + useIndexingPolicyStore.getState().setIndexingPolicyFor(containerId, updatedPolicy); + const selectedIndexSet = new Set(selectedIndexes.map((s) => s.index)); + const updatedNotIncluded: typeof notIncluded = []; + const newlyIncluded: typeof included = []; + for (const item of notIncluded) { + if (selectedIndexSet.has(item.index)) { + newlyIncluded.push(item); + } else { + updatedNotIncluded.push(item); + } + } + const newIncluded = [...included, ...newlyIncluded]; + const newNotIncluded = updatedNotIncluded; + setIncludedIndexes(newIncluded); + setNotIncludedIndexes(newNotIncluded); + setSelectedIndexes([]); + setSelectAll(false); + setUpdateMessageShown(true); + setJustUpdatedPolicy(true); + } catch (err) { + console.error("Failed to update indexing policy:", err); + } finally { + setIsUpdating(false); + } + }; + + const renderRow = (item: IIndexMetric, index: number) => { + const isHeader = item.section === "Header"; + const isNotIncluded = item.section === "Not Included"; + + return ( + + +
+ {isNotIncluded ? ( + selected.index === item.index)} + onChange={(_, data) => handleCheckboxChange(item, data.checked === true)} + /> + ) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? ( + handleSelectAll(data.checked === true)} /> + ) : ( +
+ )} + {isHeader ? ( + { + if (item.index === "Included in Current Policy") { + setShowIncluded(!showIncluded); + } else if (item.index === "Not Included in Current Policy") { + setShowNotIncluded(!showNotIncluded); + } + }} + > + {item.index === "Included in Current Policy" ? ( + showIncluded ? ( + + ) : ( + + ) + ) : showNotIncluded ? ( + + ) : ( + + )} + + ) : ( +
+ )} +
{item.index}
+
+ {!isHeader && item.impact} +
+
{!isHeader && renderImpactDots(item.impact)}
+
+
+
+ ); + }; + const indexMetricItems = React.useMemo(() => { + const items: IIndexMetric[] = []; + items.push({ index: "Not Included in Current Policy", impact: "", section: "Header" }); + if (showNotIncluded) { + notIncluded.forEach((item) => items.push({ ...item, section: "Not Included" })); + } + items.push({ index: "Included in Current Policy", impact: "", section: "Header" }); + if (showIncluded) { + included.forEach((item) => items.push({ ...item, section: "Included" })); + } + return items; + }, [included, notIncluded, showIncluded, showNotIncluded]); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {updateMessageShown ? ( + <> + + + + + Your indexing policy has been updated with the new included paths. You may review the changes in Scale & + Settings. + + + ) : ( + <> + + Index Advisor uses Indexing Metrics to suggest query paths that, when included in your indexing policy, + can improve the performance of this query by reducing RU costs and lowering latency.{" "} + + Learn more about Indexing Metrics + + .{" "} + + + )} +
+
Indexes analysis
+ + + + +
+
+
+
Index
+
+ Estimated Impact +
+
+
+
+
+ {indexMetricItems.map(renderRow)} +
+ {selectedIndexes.length > 0 && ( +
+ {isUpdating ? ( +
+ {" "} +
+ ) : ( + + )} +
+ )} +
+ ); +}; +export const ResultsView: React.FC = ({ + isMongoDB, + queryResults, + executeQueryDocumentsPage, + queryEditorContent, + databaseId, + containerId, +}) => { const styles = useQueryTabStyles(); const [activeTab, setActiveTab] = useState(ResultsTabs.Results); const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => { setActiveTab(data.value as ResultsTabs); }, []); - return (
@@ -548,6 +885,13 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult > Query Stats + + Index Advisor +
{activeTab === ResultsTabs.Results && ( @@ -558,7 +902,30 @@ export const ResultsView: React.FC = ({ isMongoDB, queryResult /> )} {activeTab === ResultsTabs.QueryStats && } + {activeTab === ResultsTabs.IndexAdvisor && ( + + )}
); }; +export interface IndexingPolicyStore { + indexingPolicies: { [containerId: string]: IndexingPolicy }; + setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void; +} + +export const useIndexingPolicyStore = create((set) => ({ + indexingPolicies: {}, + setIndexingPolicyFor: (containerId, indexingPolicy) => + set((state) => ({ + indexingPolicies: { + ...state.indexingPolicies, + [containerId]: { ...indexingPolicy }, + }, + })), +})); diff --git a/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts new file mode 100644 index 000000000..29f62b35a --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/StylesAdvisor.ts @@ -0,0 +1,95 @@ +import { makeStyles } from "@fluentui/react-components"; +export type IndexAdvisorStyles = ReturnType; +export const useIndexAdvisorStyles = makeStyles({ + indexAdvisorMessage: { + padding: "1rem", + fontSize: "1.2rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + indexAdvisorSuccessIcon: { + width: "18px", + height: "18px", + borderRadius: "50%", + backgroundColor: "#107C10", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + indexAdvisorTitle: { + padding: "1rem", + fontSize: "1.3rem", + fontWeight: "bold", + }, + indexAdvisorTable: { + display: "block", + alignItems: "center", + marginBottom: "7rem", + }, + indexAdvisorGrid: { + display: "grid", + gridTemplateColumns: "30px 30px 1fr 50px 120px", + alignItems: "center", + gap: "15px", + fontWeight: "bold", + }, + indexAdvisorCheckboxSpacer: { + width: "18px", + height: "18px", + }, + indexAdvisorChevronSpacer: { + width: "24px", + }, + indexAdvisorRowBold: { + fontWeight: "bold", + }, + indexAdvisorRowNormal: { + fontWeight: "normal", + }, + indexAdvisorRowImpactHeader: { + fontSize: 0, + }, + indexAdvisorRowImpact: { + fontWeight: "normal", + }, + indexAdvisorImpactDot: { + color: "#0078D4", + fontSize: "12px", + display: "inline-flex", + }, + indexAdvisorImpactDots: { + display: "flex", + alignItems: "center", + gap: "4px", + }, + indexAdvisorButtonBar: { + padding: "1rem", + marginTop: "-7rem", + flexWrap: "wrap", + }, + indexAdvisorButtonSpinner: { + marginTop: "1rem", + minWidth: "320px", + minHeight: "40px", + display: "flex", + alignItems: "left", + justifyContent: "left", + marginLeft: "10rem", + }, + indexAdvisorButton: { + backgroundColor: "#0078D4", + color: "white", + padding: "8px 16px", + border: "none", + borderRadius: "4px", + cursor: "pointer", + marginTop: "1rem", + fontSize: "1rem", + fontWeight: 500, + transition: "background 0.2s", + ":hover": { + backgroundColor: "#005a9e", + }, + }, +}); diff --git a/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts new file mode 100644 index 000000000..cccf3c7bb --- /dev/null +++ b/src/Explorer/Tabs/QueryTab/useQueryMetadataStore.ts @@ -0,0 +1,15 @@ +import create from "zustand"; + +interface QueryMetadataStore { + userQuery: string; + databaseId: string; + containerId: string; + setMetadata: (query1: string, db: string, container: string) => void; +} + +export const useQueryMetadataStore = create((set) => ({ + userQuery: "", + databaseId: "", + containerId: "", + setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }), +})); diff --git a/test/sql/indexAdvisor.spec.ts b/test/sql/indexAdvisor.spec.ts new file mode 100644 index 000000000..4d9ac6aa2 --- /dev/null +++ b/test/sql/indexAdvisor.spec.ts @@ -0,0 +1,145 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { CommandBarButton, DataExplorer, TestAccount } from "../fx"; +import { createTestSQLContainer, TestContainerContext } from "../testData"; + +// Test container context for setup and cleanup +let testContainer: TestContainerContext; +let DATABASE_ID: string; +let CONTAINER_ID: string; + +// Set up test database and container with data before all tests +test.beforeAll(async () => { + testContainer = await createTestSQLContainer(true); + DATABASE_ID = testContainer.database.id; + CONTAINER_ID = testContainer.container.id; +}); + +// Clean up test database after all tests +test.afterAll(async () => { + if (testContainer) { + await testContainer.dispose(); + } +}); + +// Helper function to set up query tab and navigate to Index Advisor +async function setupIndexAdvisorTab(page: Page, customQuery?: string) { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const databaseNode = await explorer.waitForNode(DATABASE_ID); + await databaseNode.expand(); + await page.waitForTimeout(2000); + + const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New SQL Query").click(); + await page.waitForTimeout(2000); + + const queryTab = explorer.queryTab("tab0"); + const queryEditor = queryTab.editor(); + await queryEditor.locator.waitFor({ timeout: 30 * 1000 }); + await queryTab.executeCTA.waitFor(); + + if (customQuery) { + await queryEditor.locator.click(); + await queryEditor.setText(customQuery); + } + + const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery); + await executeQueryButton.click(); + await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); + + const indexAdvisorTab = queryTab.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"); + await indexAdvisorTab.click(); + await page.waitForTimeout(2000); + + return { explorer, queryTab, indexAdvisorTab }; +} + +test("Index Advisor tab loads without errors", async ({ page }) => { + const { indexAdvisorTab } = await setupIndexAdvisorTab(page); + await expect(indexAdvisorTab).toHaveAttribute("aria-selected", "true"); +}); + +test("Verify UI sections are collapsible", async ({ page }) => { + const { explorer } = await setupIndexAdvisorTab(page); + + // Verify both section headers exist + const includedHeader = explorer.frame.getByText("Included in Current Policy", { exact: true }); + const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true }); + + await expect(includedHeader).toBeVisible(); + await expect(notIncludedHeader).toBeVisible(); + + // Test collapsibility by checking if chevron/arrow icon changes state + // Both sections should be expandable/collapsible regardless of content + await includedHeader.click(); + await page.waitForTimeout(300); + await includedHeader.click(); + await page.waitForTimeout(300); + + await notIncludedHeader.click(); + await page.waitForTimeout(300); + await notIncludedHeader.click(); + await page.waitForTimeout(300); +}); + +test("Verify SDK response structure - Case 1: Empty response", async ({ page }) => { + const { explorer } = await setupIndexAdvisorTab(page); + + // Verify both section headers still exist even with no data + await expect(explorer.frame.getByText("Included in Current Policy", { exact: true })).toBeVisible(); + await expect(explorer.frame.getByText("Not Included in Current Policy", { exact: true })).toBeVisible(); + + // Verify table headers + const table = explorer.frame.locator("table"); + await expect(table.getByText("Index", { exact: true })).toBeVisible(); + await expect(table.getByText("Estimated Impact", { exact: true })).toBeVisible(); + + // Verify "Update Indexing Policy" button is NOT visible when there are no potential indexes + const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i }); + await expect(updateButton).not.toBeVisible(); +}); + +test("Verify index suggestions and apply potential index", async ({ page }) => { + const customQuery = 'SELECT * FROM c WHERE c.partitionKey = "partition_1" ORDER BY c.randomData'; + const { explorer } = await setupIndexAdvisorTab(page, customQuery); + + // Wait for Index Advisor to process the query + await page.waitForTimeout(2000); + + // Verify "Not Included in Current Policy" section has suggestions + const notIncludedHeader = explorer.frame.getByText("Not Included in Current Policy", { exact: true }); + await expect(notIncludedHeader).toBeVisible(); + + // Find the checkbox for the suggested composite index + // The composite index should be /partitionKey ASC, /randomData ASC + const checkboxes = explorer.frame.locator('input[type="checkbox"]'); + const checkboxCount = await checkboxes.count(); + + // Should have at least one checkbox for the potential index + expect(checkboxCount).toBeGreaterThan(0); + + // Select the first checkbox (the high-impact composite index) + await checkboxes.first().check(); + await page.waitForTimeout(500); + + // Verify "Update Indexing Policy" button becomes visible + const updateButton = explorer.frame.getByRole("button", { name: /Update Indexing Policy/i }); + await expect(updateButton).toBeVisible(); + + // Click the "Update Indexing Policy" button + await updateButton.click(); + await page.waitForTimeout(1000); + + // Verify success message appears + const successMessage = explorer.frame.getByText(/Your indexing policy has been updated with the new included paths/i); + await expect(successMessage).toBeVisible(); + + // Verify the message mentions reviewing changes in Scale & Settings + const reviewMessage = explorer.frame.getByText(/You may review the changes in Scale & Settings/i); + await expect(reviewMessage).toBeVisible(); + + // Verify the checkmark icon is shown + const checkmarkIcon = explorer.frame.locator('[data-icon-name="CheckMark"]'); + await expect(checkmarkIcon).toBeVisible(); +}); From 92c8afd166bd23505ed751027ed8971ebca241bf Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Fri, 9 Jan 2026 08:23:35 -0800 Subject: [PATCH 05/20] More playwright tests (#2310) * Add playwright tests for Autoscale/Manual Throughpout and TTL * fix unit tests and lint * fix unit tests * fix tests * fix autoscale selector * changed throughput above limit * Add more playwright tests * fix tests * nit * cleanup * format * stored procedure playwright test * add user defined function playwright test * Add user defined functions and trigger test * fix upload items * fix tests * fix lint errors * fix lint * run cleanup every 3 hours * keep cleanup at 2 hours --------- Co-authored-by: Asier Isayas --- .../dataAccess/createStoredProcedure.ts | 19 +-- src/Common/dataAccess/createTrigger.ts | 19 +-- .../dataAccess/createUserDefinedFunction.ts | 20 +-- .../dataAccess/deleteStoredProcedure.ts | 1 + src/Common/dataAccess/deleteTrigger.ts | 1 + .../dataAccess/deleteUserDefinedFunction.ts | 1 + .../ComputedPropertiesComponent.tsx | 7 +- .../SubSettingsComponent.tsx | 4 +- .../ComputedPropertiesComponent.test.tsx.snap | 1 + .../SubSettingsComponent.test.tsx.snap | 10 ++ .../Panes/UploadItemsPane/UploadItemsPane.tsx | 2 +- .../StoredProcedureTabComponent.tsx | 1 - test/fx.ts | 2 + test/sql/document.spec.ts | 117 +++++++++++++++++- .../computedProperties.spec.ts | 108 ++++++++++++++++ test/sql/scaleAndSettings/settings.spec.ts | 26 +++- test/sql/scripts/storedProcedure.spec.ts | 78 ++++++++++++ test/sql/scripts/trigger.spec.ts | 80 ++++++++++++ test/sql/scripts/userDefinedFunction.spec.ts | 82 ++++++++++++ test/testData.ts | 16 ++- 20 files changed, 559 insertions(+), 36 deletions(-) create mode 100644 test/sql/scaleAndSettings/computedProperties.spec.ts create mode 100644 test/sql/scripts/storedProcedure.spec.ts create mode 100644 test/sql/scripts/trigger.spec.ts create mode 100644 test/sql/scripts/userDefinedFunction.spec.ts diff --git a/src/Common/dataAccess/createStoredProcedure.ts b/src/Common/dataAccess/createStoredProcedure.ts index ab7839f3e..28f67846b 100644 --- a/src/Common/dataAccess/createStoredProcedure.ts +++ b/src/Common/dataAccess/createStoredProcedure.ts @@ -9,7 +9,7 @@ import { SqlStoredProcedureCreateUpdateParameters, SqlStoredProcedureResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createStoredProcedure( ): Promise { const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`); try { + let resource: StoredProcedureDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,16 @@ export async function createStoredProcedure( storedProcedure.id, createSprocParams, ); - return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.storedProcedures.create(storedProcedure); + resource = response.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.storedProcedures.create(storedProcedure); - return response?.resource; + logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`); + return resource; } catch (error) { handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`); throw error; diff --git a/src/Common/dataAccess/createTrigger.ts b/src/Common/dataAccess/createTrigger.ts index dd4ec1af5..c1cda22be 100644 --- a/src/Common/dataAccess/createTrigger.ts +++ b/src/Common/dataAccess/createTrigger.ts @@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType"; import { userContext } from "../../UserContext"; import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources"; import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -14,6 +14,7 @@ export async function createTrigger( ): Promise { const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`); try { + let resource: SqlTriggerResource | TriggerDefinition; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -52,14 +53,16 @@ export async function createTrigger( trigger.id, createTriggerParams, ); - return rpResponse && rpResponse.properties?.resource; + resource = rpResponse && rpResponse.properties?.resource; + } else { + const sdkResponse = await client() + .database(databaseId) + .container(collectionId) + .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type + resource = sdkResponse.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type - return response.resource; + logConsoleInfo(`Successfully created trigger ${trigger.id}`); + return resource; } catch (error) { handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`); throw error; diff --git a/src/Common/dataAccess/createUserDefinedFunction.ts b/src/Common/dataAccess/createUserDefinedFunction.ts index 3d1bca86e..c2ac4c489 100644 --- a/src/Common/dataAccess/createUserDefinedFunction.ts +++ b/src/Common/dataAccess/createUserDefinedFunction.ts @@ -9,7 +9,7 @@ import { SqlUserDefinedFunctionCreateUpdateParameters, SqlUserDefinedFunctionResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createUserDefinedFunction( ): Promise { const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`); try { + let resource: UserDefinedFunctionDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,17 @@ export async function createUserDefinedFunction( userDefinedFunction.id, createUDFParams, ); - return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); - } - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.userDefinedFunctions.create(userDefinedFunction); - return response?.resource; + resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.userDefinedFunctions.create(userDefinedFunction); + resource = response.resource; + } + logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`); + return resource; } catch (error) { handleError( error, diff --git a/src/Common/dataAccess/deleteStoredProcedure.ts b/src/Common/dataAccess/deleteStoredProcedure.ts index 403b707ff..61ad16127 100644 --- a/src/Common/dataAccess/deleteStoredProcedure.ts +++ b/src/Common/dataAccess/deleteStoredProcedure.ts @@ -28,6 +28,7 @@ export async function deleteStoredProcedure( } else { await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete(); } + logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`); } catch (error) { handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`); throw error; diff --git a/src/Common/dataAccess/deleteTrigger.ts b/src/Common/dataAccess/deleteTrigger.ts index 22b77f009..568f4cefe 100644 --- a/src/Common/dataAccess/deleteTrigger.ts +++ b/src/Common/dataAccess/deleteTrigger.ts @@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr } else { await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete(); } + logConsoleProgress(`Successfully deleted trigger ${triggerId}`); } catch (error) { handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`); throw error; diff --git a/src/Common/dataAccess/deleteUserDefinedFunction.ts b/src/Common/dataAccess/deleteUserDefinedFunction.ts index ee70b803c..d551cfbf0 100644 --- a/src/Common/dataAccess/deleteUserDefinedFunction.ts +++ b/src/Common/dataAccess/deleteUserDefinedFunction.ts @@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId } else { await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete(); } + logConsoleProgress(`Successfully deleted user defined function ${id}`); } catch (error) { handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`); throw error; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx index a0bfc2116..039a66304 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx @@ -155,7 +155,12 @@ export class ComputedPropertiesComponent extends React.Component<   about how to define computed properties and how to use them. -
+
); } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx index 648395722..e2da4db0b 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx @@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component ( diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap index d5684b825..9646cf995 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap @@ -31,6 +31,7 @@ exports[`ComputedPropertiesComponent renders 1`] = `
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap index 10bf3b17a..3a710db53 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap @@ -167,10 +167,12 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -652,10 +654,12 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1224,10 +1228,12 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1760,10 +1766,12 @@ exports[`SubSettingsComponent renders 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -2330,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, diff --git a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx index 778634d71..47d7fd846 100644 --- a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx +++ b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx @@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent = ({ onUpl tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets." /> {uploadFileData?.length > 0 && ( -
+
File upload status { diff --git a/test/fx.ts b/test/fx.ts index c1c2b6a47..1de8be90d 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -378,7 +378,9 @@ type PanelOpenOptions = { export enum CommandBarButton { Save = "Save", + Execute = "Execute", ExecuteQuery = "Execute Query", + UploadItem = "Upload Item", } /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 95cdd112a..5d17c22c3 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -1,7 +1,18 @@ import { expect, test } from "@playwright/test"; -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; -import { retry, setPartitionKeys } from "../testData"; +import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx"; +import { + createTestSQLContainer, + itemsPerPartition, + partitionCount, + retry, + setPartitionKeys, + TestContainerContext, + TestData, +} from "../testData"; import { documentTestCases } from "./testCases"; let explorer: DataExplorer = null!; @@ -95,3 +106,105 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { } }); } + +test.describe.serial("Upload Item", () => { + let context: TestContainerContext = null!; + let uploadDocumentDirPath: string = null!; + let uploadDocumentFilePath: string = null!; + + test.beforeAll("Create Test database and open documents tab", async ({ browser }) => { + uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-")); + uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json"); + + const page = await browser.newPage(); + context = await createTestSQLContainer(); + explorer = await DataExplorer.open(page, TestAccount.SQL); + + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await containerMenuNode.element.click(); + // We need to click twice in order to remove a tooltip + await containerMenuNode.element.click(); + }); + + test.afterAll("Delete Test Database and uploadDocument temp folder", async () => { + if (existsSync(uploadDocumentFilePath)) { + unlinkSync(uploadDocumentFilePath); + } + if (existsSync(uploadDocumentDirPath)) { + rmdirSync(uploadDocumentDirPath); + } + if (!process.env.CI) { + await context?.dispose(); + } + }); + + test.afterEach("Close Upload Items panel if still open", async () => { + const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items"); + if (await closeUploadItemsPanelButton.isVisible()) { + await closeUploadItemsPanelButton.click(); + } + }); + + test("upload document", async () => { + // Create file to upload + const TestDataJsonString: string = JSON.stringify(TestData, null, 2); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload success message + const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`; + const fileUploadStatus = explorer.frame.getByTestId("file-upload-status"); + await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + + // Select file to upload again + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + await uploadButton.click(); + + // Verify upload failure message + const errorIcon = explorer.frame.getByRole("img", { name: "error" }); + await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(fileUploadStatus).toContainText( + `0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("upload invalid json", async () => { + // Create file to upload + let TestDataJsonString: string = JSON.stringify(TestData, null, 2); + // Remove the first '[' so that it becomes invalid json + TestDataJsonString = TestDataJsonString.substring(1); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload failure message + const fileUploadErrorList = explorer.frame.getByLabel("error list"); + // The parsing error will show up differently in different browsers so just check for the word "JSON" + await expect(fileUploadErrorList).toContainText("JSON", { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scaleAndSettings/computedProperties.spec.ts b/test/sql/scaleAndSettings/computedProperties.spec.ts new file mode 100644 index 000000000..d1f95e53e --- /dev/null +++ b/test/sql/scaleAndSettings/computedProperties.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from "@playwright/test"; +import * as DataModels from "../../../src/Contracts/DataModels"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Computed Properties", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + // Click Scale & Settings and open Settings tab + await explorer.openScaleAndSettings(context); + const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab"); + await computedPropertiesTab.click(); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add valid computed property", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT VALUE LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid query", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Failed to update container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid json", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString + "]"); + + // Save button should remain disabled due to invalid json + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeDisabled(); + }); + + const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise => { + // Get computed properties text box + await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" }); + const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor"); + await computedPropertiesEditor.click(); + + // Clear existing content (Ctrl+A + Backspace does not work with webkit) + for (let i = 0; i < 100; i++) { + await page.keyboard.press("Backspace"); + } + }; +}); diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index f82c5413f..3f14422eb 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -11,7 +11,7 @@ test.describe("Settings under Scale & Settings", () => { const page = await browser.newPage(); explorer = await DataExplorer.open(page, TestAccount.SQL); - // Click Scale & Settings and open Scale tab + // Click Scale & Settings and open Settings tab await explorer.openScaleAndSettings(context); const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab"); await settingsTab.click(); @@ -53,4 +53,28 @@ test.describe("Settings under Scale & Settings", () => { }, ); }); + + test("Set Geospatial Config to Geometry then Geography", async () => { + const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" }); + await geometryRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + + const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" }); + await geographyRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); }); diff --git a/test/sql/scripts/storedProcedure.spec.ts b/test/sql/scripts/storedProcedure.spec.ts new file mode 100644 index 000000000..35fb4e0f8 --- /dev/null +++ b/test/sql/scripts/storedProcedure.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Stored Procedures", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => { + void page; + // Open container context menu and click New Stored Procedure + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Stored Procedure").click(); + + // Type stored procedure id and use stock procedure + const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id"); + await storedProcedureIdTextBox.isVisible(); + const storedProcedureName = `stored-procedure-${testInfo.testId}`; + await storedProcedureIdTextBox.fill(storedProcedureName); + + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created stored procedure ${storedProcedureName}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Execute stored procedure + const executeButton = explorer.commandBarButton(CommandBarButton.Execute); + await executeButton.click(); + const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton"); + await executeSidePanelButton.click(); + + const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result"); + await expect(executeStoredProcedureResult).toBeAttached({ + timeout: ONE_MINUTE_MS, + }); + + // Delete stored procedure + await containerNode.expand(); + const storedProceduresNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures`, + ); + await storedProceduresNode.expand(); + const storedProcedureNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`, + ); + + await storedProcedureNode.openContextMenu(); + await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click(); + const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteStoredProcedureButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted stored procedure ${storedProcedureName}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/sql/scripts/trigger.spec.ts b/test/sql/scripts/trigger.spec.ts new file mode 100644 index 000000000..6874c2aac --- /dev/null +++ b/test/sql/scripts/trigger.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Triggers", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const triggerBody = `function validateToDoItemTimestamp() { + var context = getContext(); + var request = context.getRequest(); + + var itemToCreate = request.getBody(); + + if (!("timestamp" in itemToCreate)) { + var ts = new Date(); + itemToCreate["timestamp"] = ts.getTime(); + } + + request.setBody(itemToCreate); + }`; + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add and delete trigger", async ({ page }, testInfo) => { + // Open container context menu and click New Trigger + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Trigger").click(); + + // Assign Trigger id + const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id"); + const triggerId: string = `validateItemTimestamp-${testInfo.testId}`; + await triggerIdTextBox.fill(triggerId); + + // Create Trigger body that validates item timestamp + const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await triggerBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(triggerBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, { + timeout: 2 * ONE_MINUTE_MS, + }); + + // Delete Trigger + await containerNode.expand(); + const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`); + await triggersNode.expand(); + const triggerNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Triggers/${triggerId}`, + ); + + await triggerNode.openContextMenu(); + await triggerNode.contextMenuItem("Delete Trigger").click(); + const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteTriggerButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scripts/userDefinedFunction.spec.ts b/test/sql/scripts/userDefinedFunction.spec.ts new file mode 100644 index 000000000..911b1f4ce --- /dev/null +++ b/test/sql/scripts/userDefinedFunction.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("User Defined Functions", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const udfBody: string = `function extractDocumentId(doc) { + return { + id: doc.id + }; + }`; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add, execute, and delete user defined function", async ({ page }, testInfo) => { + // Open container context menu and click New UDF + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New UDF").click(); + + // Assign UDF id + const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id"); + const udfId: string = `extractDocumentId-${testInfo.testId}`; + await udfIdTextBox.fill(udfId); + + // Create UDF body that extracts the document id from a document + const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await udfBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(udfBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created user defined function ${udfId}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Delete UDF + await containerNode.expand(); + const udfsNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions`, + ); + await udfsNode.expand(); + const udfNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`, + ); + await udfNode.openContextMenu(); + await udfNode.contextMenuItem("Delete User Defined Function").click(); + const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteUserDefinedFunctionButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted user defined function ${udfId}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index b440f565c..7e5a1f26c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -37,27 +37,35 @@ export interface PartitionKey { value: string | null; } -const partitionCount = 4; +export const partitionCount = 4; // If we increase this number, we need to split bulk creates into multiple batches. // Bulk operations are limited to 100 items per partition. -const itemsPerPartition = 100; +export const itemsPerPartition = 100; function createTestItems(): TestItem[] { const items: TestItem[] = []; for (let i = 0; i < partitionCount; i++) { for (let j = 0; j < itemsPerPartition; j++) { - const id = crypto.randomBytes(32).toString("base64"); + const id = createSafeRandomString(32); items.push({ id, partitionKey: `partition_${i}`, - randomData: crypto.randomBytes(32).toString("base64"), + randomData: createSafeRandomString(32), }); } } return items; } +// Document IDs cannot contain '/', '\', or '#' +function createSafeRandomString(byteLength: number): string { + return crypto + .randomBytes(byteLength) + .toString("base64") + .replace(/[/\\#]/g, "_"); +} + export const TestData: TestItem[] = createTestItems(); export class TestContainerContext { From e6461cf07965dededcaedfb94c7fb8ec6d3212fb Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Mon, 12 Jan 2026 08:46:47 +0530 Subject: [PATCH 06/20] Refactor container copy migration type selection from checkbox to radio buttons (#2307) * replace migration type checkbox with radio button selection * use force: true to bypass label interception --- .../ContainerCopy/ContainerCopyMessages.ts | 13 +- .../Components/MigrationType.test.tsx | 241 ++++++++++++++++++ .../Components/MigrationType.tsx | 77 ++++++ .../Components/MigrationTypeCheckbox.test.tsx | 72 ------ .../Components/MigrationTypeCheckbox.tsx | 33 --- .../__snapshots__/MigrationType.test.tsx.snap | 109 ++++++++ .../MigrationTypeCheckbox.test.tsx.snap | 82 ------ .../SelectAccount/SelectAccount.test.tsx | 87 +------ .../Screens/SelectAccount/SelectAccount.tsx | 17 +- .../__snapshots__/SelectAccount.test.tsx.snap | 9 +- .../ContainerCopy/containerCopyStyles.less | 11 + test/sql/containercopy.spec.ts | 26 +- 12 files changed, 482 insertions(+), 295 deletions(-) create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx delete mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx delete mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx create mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap delete mode 100644 src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index 27175de68..d63f0cfad 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -25,7 +25,18 @@ export default { subscriptionDropdownPlaceholder: "Select a subscription", sourceAccountDropdownLabel: "Account", sourceAccountDropdownPlaceholder: "Select an account", - migrationTypeCheckboxLabel: "Copy container in offline mode", + migrationTypeOptions: { + offline: { + title: "Offline mode", + description: + "Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).", + }, + online: { + title: "Online mode", + description: + "Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).", + }, + }, // Select Source and Target Containers Screen selectSourceAndTargetContainersDescription: diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx new file mode 100644 index 000000000..50fff3f72 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.test.tsx @@ -0,0 +1,241 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { useCopyJobContext } from "../../../../Context/CopyJobContext"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; +import { MigrationType } from "./MigrationType"; + +jest.mock("../../../../Context/CopyJobContext", () => ({ + useCopyJobContext: jest.fn(), +})); + +describe("MigrationType", () => { + const mockSetCopyJobState = jest.fn(); + + const defaultContextValue = { + copyJobState: { + jobName: "", + migrationType: CopyJobMigrationType.Online, + source: { + subscription: null as any, + account: null as any, + databaseId: "", + containerId: "", + }, + target: { + subscriptionId: "", + account: null as any, + databaseId: "", + containerId: "", + }, + sourceReadAccessFromTarget: false, + }, + setCopyJobState: mockSetCopyJobState, + flow: { currentScreen: "selectAccount" }, + setFlow: jest.fn(), + contextError: "", + setContextError: jest.fn(), + explorer: {} as any, + resetCopyJobState: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue); + }); + + describe("Component Rendering", () => { + it("should render migration type component with radio buttons", () => { + const { container } = render(); + + expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument(); + expect(screen.getByRole("radiogroup")).toBeInTheDocument(); + + const offlineRadio = screen.getByRole("radio", { + name: ContainerCopyMessages.migrationTypeOptions.offline.title, + }); + const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }); + + expect(offlineRadio).toBeInTheDocument(); + expect(onlineRadio).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it("should render with online mode selected by default", () => { + render(); + + const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }); + const offlineRadio = screen.getByRole("radio", { + name: ContainerCopyMessages.migrationTypeOptions.offline.title, + }); + + expect(onlineRadio).toBeChecked(); + expect(offlineRadio).not.toBeChecked(); + }); + + it("should render with offline mode selected when state is offline", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }, + }); + + render(); + + const offlineRadio = screen.getByRole("radio", { + name: ContainerCopyMessages.migrationTypeOptions.offline.title, + }); + const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }); + + expect(offlineRadio).toBeChecked(); + expect(onlineRadio).not.toBeChecked(); + }); + }); + + describe("Descriptions and Learn More Links", () => { + it("should render online description and learn more link when online is selected", () => { + const { container } = render(); + + expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument(); + + const learnMoreLink = screen.getByRole("link", { + name: "online copy jobs", + }); + expect(learnMoreLink).toBeInTheDocument(); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started", + ); + expect(learnMoreLink).toHaveAttribute("target", "_blank"); + }); + + it("should render offline description and learn more link when offline is selected", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }, + }); + + const { container } = render(); + + expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument(); + + const learnMoreLink = screen.getByRole("link", { + name: "offline copy jobs", + }); + expect(learnMoreLink).toBeInTheDocument(); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql", + ); + }); + }); + + describe("User Interactions", () => { + it("should call setCopyJobState when offline radio button is clicked", () => { + render(); + + const offlineRadio = screen.getByRole("radio", { + name: ContainerCopyMessages.migrationTypeOptions.offline.title, + }); + fireEvent.click(offlineRadio); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const updateFunction = mockSetCopyJobState.mock.calls[0][0]; + const result = updateFunction(defaultContextValue.copyJobState); + + expect(result).toEqual({ + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }); + }); + + it("should call setCopyJobState when online radio button is clicked", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }, + }); + + render(); + + const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }); + fireEvent.click(onlineRadio); + + expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); + + const updateFunction = mockSetCopyJobState.mock.calls[0][0]; + const result = updateFunction({ + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Offline, + }); + + expect(result).toEqual({ + ...defaultContextValue.copyJobState, + migrationType: CopyJobMigrationType.Online, + }); + }); + }); + + describe("Accessibility", () => { + it("should have proper ARIA attributes", () => { + render(); + + const choiceGroup = screen.getByRole("radiogroup"); + expect(choiceGroup).toBeInTheDocument(); + expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup"); + }); + + it("should have proper radio button labels", () => { + render(); + + expect( + screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }), + ).toBeInTheDocument(); + expect( + screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }), + ).toBeInTheDocument(); + }); + }); + + describe("Edge Cases", () => { + it("should handle undefined migration type gracefully", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: { + ...defaultContextValue.copyJobState, + migrationType: undefined, + }, + }); + + const { container } = render(); + + expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument(); + expect( + screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }), + ).toBeInTheDocument(); + expect( + screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }), + ).toBeInTheDocument(); + }); + + it("should handle null copyJobState gracefully", () => { + (useCopyJobContext as jest.Mock).mockReturnValue({ + ...defaultContextValue, + copyJobState: null, + }); + + const { container } = render(); + + expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx new file mode 100644 index 000000000..35c2d6a63 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationType.tsx @@ -0,0 +1,77 @@ +/* eslint-disable react/prop-types */ +/* eslint-disable react/display-name */ +import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react"; +import MarkdownRender from "@nteract/markdown"; +import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext"; +import React from "react"; +import ContainerCopyMessages from "../../../../ContainerCopyMessages"; +import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums"; + +interface MigrationTypeProps {} +const options: IChoiceGroupOption[] = [ + { + key: CopyJobMigrationType.Offline, + text: ContainerCopyMessages.migrationTypeOptions.offline.title, + styles: { root: { width: "33%" } }, + }, + { + key: CopyJobMigrationType.Online, + text: ContainerCopyMessages.migrationTypeOptions.online.title, + styles: { root: { width: "33%" } }, + }, +]; + +const choiceGroupStyles = { + flexContainer: { display: "flex" as const }, + root: { + selectors: { + ".ms-ChoiceField": { + color: "var(--colorNeutralForeground1)", + }, + ".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": { + color: "var(--colorNeutralForeground1)", + }, + }, + }, +}; + +export const MigrationType: React.FC = React.memo(() => { + const { copyJobState, setCopyJobState } = useCopyJobContext(); + const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => { + if (option) { + setCopyJobState((prevState) => ({ + ...prevState, + migrationType: option.key as CopyJobMigrationType, + })); + } + }; + + const selectedKey = copyJobState?.migrationType ?? ""; + const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions; + const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase]; + + return ( + + + + + {selectedKeyContent && ( + + + + + + )} + + ); +}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx deleted file mode 100644 index 67289fe39..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import React from "react"; -import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox"; - -describe("MigrationTypeCheckbox", () => { - const mockOnChange = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("Component Rendering", () => { - it("should render with default props (unchecked state)", () => { - const { container } = render(); - - expect(container.firstChild).toMatchSnapshot(); - }); - - it("should render in checked state", () => { - const { container } = render(); - - expect(container.firstChild).toMatchSnapshot(); - }); - - it("should display the correct label text", () => { - render(); - - const checkbox = screen.getByRole("checkbox"); - expect(checkbox).toBeInTheDocument(); - - const label = screen.getByText("Copy container in offline mode"); - expect(label).toBeInTheDocument(); - }); - - it("should have correct accessibility attributes when checked", () => { - render(); - - const checkbox = screen.getByRole("checkbox"); - expect(checkbox).toBeChecked(); - expect(checkbox).toHaveAttribute("checked"); - }); - }); - - describe("FluentUI Integration", () => { - it("should render FluentUI Checkbox component correctly", () => { - render(); - - const checkbox = screen.getByRole("checkbox"); - expect(checkbox).toBeInTheDocument(); - expect(checkbox).toHaveAttribute("type", "checkbox"); - }); - - it("should render FluentUI Stack component correctly", () => { - render(); - - const stackContainer = document.querySelector(".migrationTypeRow"); - expect(stackContainer).toBeInTheDocument(); - }); - - it("should apply FluentUI Stack horizontal alignment correctly", () => { - const { container } = render(); - - const stackContainer = container.querySelector(".migrationTypeRow"); - expect(stackContainer).toBeInTheDocument(); - }); - }); -}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx deleted file mode 100644 index 4e8ae6946..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/MigrationTypeCheckbox.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable react/prop-types */ -/* eslint-disable react/display-name */ -import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react"; -import React from "react"; -import ContainerCopyMessages from "../../../../ContainerCopyMessages"; - -interface MigrationTypeCheckboxProps { - checked: boolean; - onChange: (_ev?: React.FormEvent, checked?: boolean) => void; -} - -const checkboxStyles: ICheckboxStyles = { - text: { color: "var(--colorNeutralForeground1)" }, - checkbox: { borderColor: "var(--colorNeutralStroke1)" }, - root: { - selectors: { - ":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" }, - }, - }, -}; - -export const MigrationTypeCheckbox: React.FC = React.memo(({ checked, onChange }) => { - return ( - - - - ); -}); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap new file mode 100644 index 000000000..1986f7540 --- /dev/null +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationType.test.tsx.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = ` +
+
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ +
+

+ Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the + + All Versions and Delete + + change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about + + online copy jobs + + . +

+
+
+
+
+
+`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap deleted file mode 100644 index db0a71b75..000000000 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/Components/__snapshots__/MigrationTypeCheckbox.test.tsx.snap +++ /dev/null @@ -1,82 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = ` -
-
- - -
-
-`; - -exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = ` -
-
- - -
-
-`; diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx index 5fb556c3c..65529338e 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import React from "react"; import { useCopyJobContext } from "../../../Context/CopyJobContext"; import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; @@ -18,19 +18,8 @@ jest.mock("./Components/AccountDropdown", () => ({ AccountDropdown: jest.fn(() =>
Account Dropdown
), })); -jest.mock("./Components/MigrationTypeCheckbox", () => ({ - MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => ( -
- - Copy container in offline mode -
- )), +jest.mock("./Components/MigrationType", () => ({ + MigrationType: jest.fn(() =>
Migration Type
), })); describe("SelectAccount", () => { @@ -83,7 +72,7 @@ describe("SelectAccount", () => { expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument(); expect(screen.getByTestId("account-dropdown")).toBeInTheDocument(); - expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument(); + expect(screen.getByTestId("migration-type")).toBeInTheDocument(); }); it("should render correctly with snapshot", () => { @@ -93,78 +82,20 @@ describe("SelectAccount", () => { }); describe("Migration Type Functionality", () => { - it("should display migration type checkbox as unchecked when migrationType is Online", () => { - (useCopyJobContext as jest.Mock).mockReturnValue({ - ...defaultContextValue, - copyJobState: { - ...defaultContextValue.copyJobState, - migrationType: CopyJobMigrationType.Online, - }, - }); - + it("should render migration type component", () => { render(); - const checkbox = screen.getByTestId("migration-checkbox-input"); - expect(checkbox).not.toBeChecked(); - }); - - it("should display migration type checkbox as checked when migrationType is Offline", () => { - (useCopyJobContext as jest.Mock).mockReturnValue({ - ...defaultContextValue, - copyJobState: { - ...defaultContextValue.copyJobState, - migrationType: CopyJobMigrationType.Offline, - }, - }); - - render(); - - const checkbox = screen.getByTestId("migration-checkbox-input"); - expect(checkbox).toBeChecked(); - }); - - it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => { - (useCopyJobContext as jest.Mock).mockReturnValue({ - ...defaultContextValue, - copyJobState: { - ...defaultContextValue.copyJobState, - migrationType: CopyJobMigrationType.Offline, - }, - }); - - render(); - - const checkbox = screen.getByTestId("migration-checkbox-input"); - fireEvent.click(checkbox); - - expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function)); - - const updateFunction = mockSetCopyJobState.mock.calls[0][0]; - const previousState = { - ...defaultContextValue.copyJobState, - migrationType: CopyJobMigrationType.Offline, - }; - const result = updateFunction(previousState); - - expect(result).toEqual({ - ...previousState, - migrationType: CopyJobMigrationType.Online, - }); + const migrationTypeComponent = screen.getByTestId("migration-type"); + expect(migrationTypeComponent).toBeInTheDocument(); }); }); describe("Performance and Optimization", () => { - it("should maintain referential equality of handler functions between renders", async () => { + it("should render without performance issues", () => { const { rerender } = render(); - - const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock; - const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; - rerender(); - const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange; - - expect(firstRenderHandler).toBe(secondRenderHandler); + expect(screen.getByTestId("migration-type")).toBeInTheDocument(); }); }); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx index 1d7715f48..f4a0dcee3 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/SelectAccount.tsx @@ -1,24 +1,11 @@ import { Stack, Text } from "@fluentui/react"; import React from "react"; import ContainerCopyMessages from "../../../ContainerCopyMessages"; -import { useCopyJobContext } from "../../../Context/CopyJobContext"; -import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums"; import { AccountDropdown } from "./Components/AccountDropdown"; -import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox"; +import { MigrationType } from "./Components/MigrationType"; import { SubscriptionDropdown } from "./Components/SubscriptionDropdown"; const SelectAccount = React.memo(() => { - const { copyJobState, setCopyJobState } = useCopyJobContext(); - - const handleMigrationTypeChange = (_ev?: React.FormEvent, checked?: boolean) => { - setCopyJobState((prevState) => ({ - ...prevState, - migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online, - })); - }; - - const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline; - return ( {ContainerCopyMessages.selectAccountDescription} @@ -27,7 +14,7 @@ const SelectAccount = React.memo(() => { - + ); }); diff --git a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap index b84b677cc..0b540eba6 100644 --- a/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap +++ b/src/Explorer/ContainerCopy/CreateCopyJob/Screens/SelectAccount/__snapshots__/SelectAccount.test.tsx.snap @@ -21,14 +21,9 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot Account Dropdown
- - Copy container in offline mode + Migration Type
`; diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index c86986c62..6f99f4055 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -138,6 +138,14 @@ color: var(--colorNeutralForeground1); } } + .migrationTypeDescription { + p { + color: var(--colorNeutralForeground1); + } + a { + color: var(--colorBrandForeground1); + } + } } .create-container-link-btn { padding: 0; @@ -181,6 +189,9 @@ background-color: var(--colorNeutralBackground3); } } + .ms-DetailsHeader-cellTitle { + padding-left: 20px; + } } .ms-DetailsRow { diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts index c019b99b7..eff5faca1 100644 --- a/test/sql/containercopy.spec.ts +++ b/test/sql/containercopy.spec.ts @@ -83,22 +83,33 @@ test.describe("Container Copy", () => { ); await accountItem.click(); - // Verifying online or offline checkbox functionality + // Verifying online or offline migration functionality /** - * This test verifies the functionality of the migration type checkbox that toggles between + * This test verifies the functionality of the migration type radio 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(); + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + 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(); + + const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); + await offlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); @@ -284,8 +295,9 @@ test.describe("Container Copy", () => { throw new Error("No dropdown items available after filtering"); } - const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox"); - await fluentUiCheckboxContainer.click(); + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); await panel.getByRole("button", { name: "Next" }).click(); From 896b3e974e30f870d6b51e9b1ced7d3aa9a8783a Mon Sep 17 00:00:00 2001 From: sunghyunkang1111 <114709653+sunghyunkang1111@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:15:26 -0600 Subject: [PATCH 07/20] Monitor telemetry (#2326) * Adding more telemetries for monitoring * Adding more telemetries for monitoring --- src/Metrics/ScenarioMonitor.ts | 88 ++++++++++++++++------ src/Shared/Telemetry/TelemetryConstants.ts | 1 + 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index a982cca3e..73e3fbcf7 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -1,6 +1,7 @@ import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; import { configContext } from "../ConfigContext"; -import { trackEvent } from "../Shared/appInsights"; +import { Action } from "../Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../UserContext"; import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents"; import { scenarioConfigs } from "./MetricScenarioConfigs"; @@ -83,6 +84,13 @@ class ScenarioMonitor { ctx.phases.set(phase, { startMarkName: phaseStartMarkName }); }); + traceMark(Action.MetricsScenario, { + event: "scenario_start", + scenario, + requiredPhases: config.requiredPhases.join(","), + timeoutMs: config.timeoutMs, + }); + ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs); this.contexts.set(scenario, ctx); } @@ -96,6 +104,12 @@ class ScenarioMonitor { const startMarkName = `scenario_${scenario}_${phase}_start`; performance.mark(startMarkName); ctx.phases.set(phase, { startMarkName }); + + traceStart(Action.MetricsScenario, { + event: "phase_start", + scenario, + phase, + }); } completePhase(scenario: MetricScenario, phase: MetricPhase) { @@ -110,6 +124,22 @@ class ScenarioMonitor { phaseCtx.endMarkName = endMarkName; ctx.completed.add(phase); + const navigationStart = performance.timeOrigin; + const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0]; + const endEntry = performance.getEntriesByName(endMarkName)[0]; + const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined; + const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined; + + traceSuccess(Action.MetricsScenario, { + event: "phase_complete", + scenario, + phase, + endTimeISO, + durationMs, + completedCount: ctx.completed.size, + requiredCount: ctx.config.requiredPhases.length, + }); + this.tryEmitIfReady(ctx); } @@ -133,6 +163,14 @@ class ScenarioMonitor { // Build a snapshot with failure info const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false }); + traceFailure(Action.MetricsScenario, { + event: "phase_fail", + scenario, + phase, + failedPhases: Array.from(ctx.failed).join(","), + completedPhases: Array.from(ctx.completed).join(","), + }); + // Emit unhealthy immediately this.emit(ctx, false, false, failureSnapshot); } @@ -191,27 +229,22 @@ class ScenarioMonitor { // Build snapshot if not provided const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut }); - // Emit enriched telemetry with performance data - // TODO: Call portal backend metrics endpoint - trackEvent( - { name: "MetricScenarioComplete" }, - { - scenario: ctx.scenario, - healthy: healthy.toString(), - timedOut: timedOut.toString(), - platform, - api, - durationMs: finalSnapshot.durationMs.toString(), - completedPhases: finalSnapshot.completed.join(","), - failedPhases: finalSnapshot.failedPhases?.join(","), - lcp: finalSnapshot.vitals?.lcp?.toString(), - inp: finalSnapshot.vitals?.inp?.toString(), - cls: finalSnapshot.vitals?.cls?.toString(), - fcp: finalSnapshot.vitals?.fcp?.toString(), - ttfb: finalSnapshot.vitals?.ttfb?.toString(), - phaseTimings: JSON.stringify(finalSnapshot.phaseTimings), - }, - ); + traceMark(Action.MetricsScenario, { + event: "scenario_end", + scenario: ctx.scenario, + healthy, + timedOut, + platform, + api, + durationMs: finalSnapshot.durationMs, + completedPhases: finalSnapshot.completed.join(","), + failedPhases: finalSnapshot.failedPhases?.join(","), + lcp: finalSnapshot.vitals?.lcp, + inp: finalSnapshot.vitals?.inp, + cls: finalSnapshot.vitals?.cls, + fcp: finalSnapshot.vitals?.fcp, + ttfb: finalSnapshot.vitals?.ttfb, + }); // Call portal backend health metrics endpoint if (healthy && !timedOut) { @@ -227,9 +260,16 @@ class ScenarioMonitor { private cleanupPerformanceEntries(ctx: InternalScenarioContext) { performance.clearMarks(ctx.startMarkName); ctx.config.requiredPhases.forEach((phase) => { - performance.clearMarks(`scenario_${ctx.scenario}_${phase}`); + const phaseCtx = ctx.phases.get(phase); + if (phaseCtx?.startMarkName) { + performance.clearMarks(phaseCtx.startMarkName); + } + if (phaseCtx?.endMarkName) { + performance.clearMarks(phaseCtx.endMarkName); + } + performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`); + performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`); }); - performance.clearMeasures(`scenario_${ctx.scenario}_total`); } private buildSnapshot( diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index bee104eea..4c2b8cf16 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -2,6 +2,7 @@ // Some of the enums names are used in Fabric. Please do not rename them. export enum Action { CollapseTreeNode, + MetricsScenario, CreateCollection, // Used in Fabric. Please do not rename. CreateGlobalSecondaryIndex, CreateDocument, // Used in Fabric. Please do not rename. From 375ec350dcfcd89c8ebe1d24e8d8ad1c12999503 Mon Sep 17 00:00:00 2001 From: jawelton74 <103591340+jawelton74@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:10:54 -0800 Subject: [PATCH 08/20] Re-enable use of RBAC for Mongo and Cassandra playwright tests. (#2324) --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac26c32d..d91f15973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,18 +201,18 @@ jobs: GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN" echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN" - # echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN" - # echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN" - # echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" - # echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN" + echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN" + echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN" + echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" + echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV - name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list - name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} From b922086cc0b844732c4476db554485305cd746af Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Fri, 16 Jan 2026 11:44:56 +0530 Subject: [PATCH 09/20] show confirmation dialogs for canceling or confirming jobs (#2323) --- .../ContainerCopy/ContainerCopyMessages.ts | 5 + .../Components/CopyJobActionMenu.test.tsx | 304 ++++++++++- .../Components/CopyJobActionMenu.tsx | 53 +- src/Main.tsx | 1 + test/sql/containercopy.spec.ts | 505 ------------------ .../containercopy/offlineMigration.spec.ts | 258 +++++++++ .../sql/containercopy/onlineMigration.spec.ts | 185 +++++++ .../containercopy/permissionsScreen.spec.ts | 270 ++++++++++ 8 files changed, 1049 insertions(+), 532 deletions(-) delete mode 100644 test/sql/containercopy.spec.ts create mode 100644 test/sql/containercopy/offlineMigration.spec.ts create mode 100644 test/sql/containercopy/onlineMigration.spec.ts create mode 100644 test/sql/containercopy/permissionsScreen.spec.ts diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index d63f0cfad..65b308b9b 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -184,5 +184,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 eff5faca1..000000000 --- a/test/sql/containercopy.spec.ts +++ /dev/null @@ -1,505 +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 migration functionality - /** - * This test verifies the functionality of the migration type radio 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 migrationTypeContainer = panel.getByTestId("migration-type"); - const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); - await onlineCopyRadioButton.click({ force: true }); - - await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); - - 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(); - - const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); - await offlineCopyRadioButton.click({ force: true }); - - await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); - - 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 migrationTypeContainer = panel.getByTestId("migration-type"); - const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); - await onlineCopyRadioButton.click({ force: true }); - - 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..de9597d49 --- /dev/null +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -0,0 +1,258 @@ +/* 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 migrationTypeContainer = panel.getByTestId("migration-type"); + + // First test online mode (should show permissions screen) + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + 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(); + + const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); + await offlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); + + 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..3914f6002 --- /dev/null +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -0,0 +1,185 @@ +/* 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 migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + 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..fa7d3e199 --- /dev/null +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -0,0 +1,270 @@ +/* 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 migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + 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(); + }); +}); From 90f3c3a79e6bdef33b73e89ef1963af10bb99bef Mon Sep 17 00:00:00 2001 From: sakshigupta12feb Date: Mon, 19 Jan 2026 21:16:28 +0530 Subject: [PATCH 10/20] Reverted the header sub header logic (#2337) * updated the text * updated margin --------- Co-authored-by: Sakshi Gupta --- src/Explorer/SplashScreen/SplashScreen.less | 2 +- src/Explorer/SplashScreen/SplashScreen.tsx | 24 ++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Explorer/SplashScreen/SplashScreen.less b/src/Explorer/SplashScreen/SplashScreen.less index b9474c6c3..4c1cefe77 100644 --- a/src/Explorer/SplashScreen/SplashScreen.less +++ b/src/Explorer/SplashScreen/SplashScreen.less @@ -54,6 +54,6 @@ .mainButtonsContainer { display: flex; gap: 0 16px; - margin-bottom: 10px + margin: 40px auto } \ No newline at end of file diff --git a/src/Explorer/SplashScreen/SplashScreen.tsx b/src/Explorer/SplashScreen/SplashScreen.tsx index f87bd3ee8..7376f5c8e 100644 --- a/src/Explorer/SplashScreen/SplashScreen.tsx +++ b/src/Explorer/SplashScreen/SplashScreen.tsx @@ -164,6 +164,23 @@ export const SplashScreen: React.FC = ({ explorer }) => { const container = explorer; const subscriptions: Array<{ dispose: () => void }> = []; + let title: string; + let subtitle: string; + + switch (userContext.apiType) { + case "Postgres": + title = "Welcome to Azure Cosmos DB for PostgreSQL"; + subtitle = "Get started with our sample datasets, documentation, and additional tools."; + break; + case "VCoreMongo": + title = "Welcome to Azure DocumentDB (with MongoDB compatibility)"; + subtitle = "Get started with our sample datasets, documentation, and additional tools."; + break; + default: + title = "Welcome to Azure Cosmos DB"; + subtitle = "Globally distributed, multi-model database service for any scale"; + } + React.useEffect(() => { subscriptions.push( { @@ -902,10 +919,11 @@ export const SplashScreen: React.FC = ({ explorer }) => { return (
-

- Welcome to Azure Cosmos DB +

+ {title} +

-
Globally distributed, multi-model database service for any scale
+
{subtitle}
{getSplashScreenButtons()} {useCarousel.getState().showCoachMark && ( Date: Tue, 20 Jan 2026 14:15:53 +0100 Subject: [PATCH 11/20] feat: Enhance restore container functionality and UI updates (#2217) --- src/Contracts/DataExplorerMessagesContract.ts | 4 ++++ src/Explorer/ContextMenuButtonFactory.tsx | 14 +++++++++++++- src/Platform/Fabric/FabricUtil.ts | 6 ++++++ src/Platform/Hosted/extractFeatures.ts | 2 ++ src/Utils/AuthorizationUtils.test.ts | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Contracts/DataExplorerMessagesContract.ts b/src/Contracts/DataExplorerMessagesContract.ts index de81ea2ea..405f4e5d5 100644 --- a/src/Contracts/DataExplorerMessagesContract.ts +++ b/src/Contracts/DataExplorerMessagesContract.ts @@ -46,6 +46,10 @@ export type DataExploreMessageV3 = params: { updateType: "created" | "deleted" | "settings"; }; + } + | { + type: FabricMessageTypes.RestoreContainer; + params: []; }; export interface GetCosmosTokenMessageOptions { verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"; diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 3f6a795ee..76b75dda8 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -7,7 +7,7 @@ import { AddGlobalSecondaryIndexPanelProps, } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { useDatabases } from "Explorer/useDatabases"; -import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; +import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil"; import { Action } from "Shared/Telemetry/TelemetryConstants"; import { traceOpen } from "Shared/Telemetry/TelemetryProcessor"; import { ReactTabKind, useTabs } from "hooks/useTabs"; @@ -35,6 +35,7 @@ import StoredProcedure from "./Tree/StoredProcedure"; import Trigger from "./Tree/Trigger"; import UserDefinedFunction from "./Tree/UserDefinedFunction"; import { useSelectedNode } from "./useSelectedNode"; +import { extractFeatures } from "../Platform/Hosted/extractFeatures"; export interface CollectionContextMenuButtonParams { databaseId: string; @@ -60,6 +61,17 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin }, ]; + if (isFabricNative() && !userContext.fabricContext?.isReadOnly) { + const features = extractFeatures(); + if (features?.enableRestoreContainer) { + items.push({ + iconSrc: AddCollectionIcon, + onClick: () => openRestoreContainerDialog(), + label: `Restore ${getCollectionName()}`, + }); + } + } + if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) { items.push({ iconSrc: DeleteDatabaseIcon, diff --git a/src/Platform/Fabric/FabricUtil.ts b/src/Platform/Fabric/FabricUtil.ts index 22ac2603a..52e3a645d 100644 --- a/src/Platform/Fabric/FabricUtil.ts +++ b/src/Platform/Fabric/FabricUtil.ts @@ -105,6 +105,12 @@ const requestAndStoreAccessToken = async (): Promise => { }); }; +export const openRestoreContainerDialog = (): void => { + if (isFabricNative()) { + sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []); + } +}; + /** * Check token validity and schedule a refresh if necessary * @param tokenTimestamp diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index b5e324116..2eecd5033 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -40,6 +40,7 @@ export type Features = { readonly disableConnectionStringLogin: boolean; readonly enableContainerCopy: boolean; readonly enableCloudShell: boolean; + readonly enableRestoreContainer: boolean; // only for Fabric // can be set via both flight and feature flag autoscaleDefault: boolean; @@ -111,6 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), enableContainerCopy: "true" === get("enablecontainercopy"), + enableRestoreContainer: "true" === get("enablerestorecontainer"), enableCloudShell: true, }; } diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index 650f2ed17..781edec60 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -43,6 +43,7 @@ describe("AuthorizationUtils", () => { partitionKeyDefault: false, partitionKeyDefault2: false, notebooksDownBanner: false, + enableRestoreContainer: false, }, }); }; From f02611c90e21a4387d4dacc96396ae5cc15f0d84 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 21 Jan 2026 11:55:15 +0530 Subject: [PATCH 12/20] Fix: Disable Complete action when any job update is in progress (#2335) * Fix: Disable Complete action when any job update is in progress - Updated CopyJobActionMenu to disable Complete button when any action is being performed - Added comprehensive tests to verify Complete button is disabled during updates - Improved user experience by preventing concurrent actions on copy jobs This PR was generated by GitHub Copilot CLI * Fix lint error: Remove unused variable in CopyJobActionMenu (#2336) * Initial plan * Fix lint error: Remove unused variable 'updatingAction' in CopyJobActionMenu.tsx Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --- .../Components/CopyJobActionMenu.test.tsx | 30 +++++++++++++++++-- .../Components/CopyJobActionMenu.tsx | 3 +- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx index abb614042..a0d6035a4 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx @@ -516,7 +516,7 @@ describe("CopyJobActionMenu", () => { expect(screen.getByText("Cancel")).toBeInTheDocument(); }); - it("should handle complete action disabled state for online jobs", () => { + it("should disable complete action when job is being updated", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress, Mode: CopyJobMigrationType.Online, @@ -530,8 +530,34 @@ describe("CopyJobActionMenu", () => { const completeButton = screen.getByText("Complete"); fireEvent.click(completeButton); + // Simulate dialog confirmation to trigger state update + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + fireEvent.click(actionButton); - expect(screen.getByText("Complete")).toBeInTheDocument(); + const completeButtonAfterClick = screen.getByText("Complete").closest("button"); + expect(completeButtonAfterClick).toBeInTheDocument(); + expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true"); + }); + + it("should disable complete action when any other action is being performed", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + fireEvent.click(actionButton); + + const completeButtonAfterClick = screen.getByText("Complete").closest("button"); + expect(completeButtonAfterClick).toBeInTheDocument(); + expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true"); }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx index 058a717bb..682e20c9a 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -61,7 +61,6 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick const getMenuItems = (): IContextualMenuProps["items"] => { const isThisJobUpdating = updatingJobAction?.jobName === job.Name; - const updatingAction = updatingJobAction?.action; const baseItems = [ { @@ -105,7 +104,7 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick text: ContainerCopyMessages.MonitorJobs.Actions.complete, iconProps: { iconName: "CheckMark" }, onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete, + disabled: isThisJobUpdating, }); } return filteredItems; From 80ad5f10d4f5a23bbb91503f27d42ba2630ac9b7 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Wed, 21 Jan 2026 15:59:26 +0530 Subject: [PATCH 13/20] update args type for FT data creation (#2330) --- test/sql/indexAdvisor.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sql/indexAdvisor.spec.ts b/test/sql/indexAdvisor.spec.ts index 4d9ac6aa2..dc6ee978c 100644 --- a/test/sql/indexAdvisor.spec.ts +++ b/test/sql/indexAdvisor.spec.ts @@ -10,7 +10,7 @@ let CONTAINER_ID: string; // Set up test database and container with data before all tests test.beforeAll(async () => { - testContainer = await createTestSQLContainer(true); + testContainer = await createTestSQLContainer({ includeTestData: true }); DATABASE_ID = testContainer.database.id; CONTAINER_ID = testContainer.container.id; }); From 6dce2632c82f6053054854752089e95e03391eb3 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Wed, 21 Jan 2026 17:28:08 +0100 Subject: [PATCH 14/20] fix: prevent race condition by initializing scenario tracking after config is set (#2339) Co-authored-by: Laurent Nguyen --- src/Main.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Main.tsx b/src/Main.tsx index af3d462a7..c90f016ac 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -105,9 +105,12 @@ const App = (): JSX.Element => { // Scenario-based health tracking: start ApplicationLoad and complete phases. const { startScenario, completePhase } = useMetricScenario(); React.useEffect(() => { - startScenario(MetricScenario.ApplicationLoad); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Only start scenario after config is initialized to avoid race conditions + // with message handlers that depend on configContext.platform + if (config) { + startScenario(MetricScenario.ApplicationLoad); + } + }, [config, startScenario]); React.useEffect(() => { if (explorer) { From 31385950dd555159e7c5fe6631c523f1a270ac86 Mon Sep 17 00:00:00 2001 From: sakshigupta12feb Date: Thu, 22 Jan 2026 00:07:06 +0530 Subject: [PATCH 15/20] removed NotebookViewer file (#2281) Co-authored-by: Sakshi Gupta --- src/NotebookViewer/NotebookViewer.tsx | 88 --------------------------- 1 file changed, 88 deletions(-) delete mode 100644 src/NotebookViewer/NotebookViewer.tsx diff --git a/src/NotebookViewer/NotebookViewer.tsx b/src/NotebookViewer/NotebookViewer.tsx deleted file mode 100644 index 2bb1441b2..000000000 --- a/src/NotebookViewer/NotebookViewer.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { initializeIcons } from "@fluentui/react"; -import "bootstrap/dist/css/bootstrap.css"; -import React from "react"; -import * as ReactDOM from "react-dom"; -import { configContext, initializeConfiguration } from "../ConfigContext"; -import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent"; -import { GalleryTab } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; -import { - NotebookViewerComponent, - NotebookViewerComponentProps, -} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent"; -import * as FileSystemUtil from "../Explorer/Notebook/FileSystemUtil"; -import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; -import * as GalleryUtils from "../Utils/GalleryUtils"; - -const onInit = async () => { - initializeIcons(); - await initializeConfiguration(); - const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search); - const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search); - let backNavigationText: string; - let onBackClick: () => void; - if (galleryViewerProps.selectedTab !== undefined) { - backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); - onBackClick = () => - (window.location.href = `${configContext.hostedExplorerURL}gallery.html?tab=${ - GalleryTab[galleryViewerProps.selectedTab] - }`); - } - const hideInputs = notebookViewerProps.hideInputs; - - const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl); - - const galleryItemId = notebookViewerProps.galleryItemId; - let galleryItem: IGalleryItem; - - if (galleryItemId) { - const junoClient = new JunoClient(); - const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId); - galleryItem = galleryItemJunoResponse.data; - } - - // The main purpose of hiding the prompt is to hide everything when hiding inputs. - // It is generally not very useful to just hide the prompt. - const hidePrompts = hideInputs; - - render(notebookUrl, backNavigationText, hideInputs, hidePrompts, galleryItem, onBackClick); -}; - -const render = ( - notebookUrl: string, - backNavigationText: string, - hideInputs?: boolean, - hidePrompts?: boolean, - galleryItem?: IGalleryItem, - onBackClick?: () => void, -) => { - const props: NotebookViewerComponentProps = { - junoClient: galleryItem ? new JunoClient() : undefined, - notebookUrl, - galleryItem, - backNavigationText, - hideInputs, - hidePrompts, - onBackClick: onBackClick, - onTagClick: undefined, - }; - - if (galleryItem) { - document.title = FileSystemUtil.stripExtension(galleryItem.name, "ipynb"); - } - - const element = ( - <> -
- -
-
- -
- - ); - - ReactDOM.render(element, document.getElementById("notebookContent")); -}; - -// Entry point -window.addEventListener("load", onInit); From 2ff01c637975428cb4595896affeb7374dddeff3 Mon Sep 17 00:00:00 2001 From: sakshigupta12feb Date: Thu, 22 Jan 2026 20:02:29 +0530 Subject: [PATCH 16/20] updated the feature value to hardcode true (#2346) Co-authored-by: Sakshi Gupta --- src/Platform/Hosted/extractFeatures.ts | 2 +- src/Utils/AuthorizationUtils.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index 2eecd5033..e9c4fc6b1 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -94,7 +94,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear notebookBasePath: get("notebookbasepath"), notebookServerToken: get("notebookservertoken"), notebookServerUrl: get("notebookserverurl"), - sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"), + sandboxNotebookOutputs: true, selfServeType: get("selfservetype"), showMinRUSurvey: "true" === get("showminrusurvey"), ttl90Days: "true" === get("ttl90days"), diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index 781edec60..305b1cc9d 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -27,7 +27,7 @@ describe("AuthorizationUtils", () => { enableKoResourceTree: false, enableThroughputBuckets: false, hostedDataExplorer: false, - sandboxNotebookOutputs: false, + sandboxNotebookOutputs: true, showMinRUSurvey: false, ttl90Days: false, enableThroughputCap: false, From f83a2c44421893dfcf4d1ecf2cf763122d3055a0 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 23 Jan 2026 07:37:45 +0100 Subject: [PATCH 17/20] feat: redact sensitive information from BadRequest errors in telemetry logging (#2321) Co-authored-by: Laurent Nguyen --- src/Common/ErrorHandlingUtils.ts | 19 +- .../dataAccess/queryDocumentsPage.test.ts | 171 ++++++++++++++++++ src/Common/dataAccess/queryDocumentsPage.ts | 52 +++++- 3 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 src/Common/dataAccess/queryDocumentsPage.test.ts diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index bfa321fe9..ea25d8e34 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -7,16 +7,27 @@ import { HttpStatusCodes } from "./Constants"; import { logError } from "./Logger"; import { sendMessage } from "./MessageHandler"; -export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => { +export interface HandleErrorOptions { + /** Optional redacted error to use for telemetry logging instead of the original error */ + redactedError?: string | ARMError | Error; +} + +export const handleError = ( + error: string | ARMError | Error, + area: string, + consoleErrorPrefix?: string, + options?: HandleErrorOptions, +): void => { const errorMessage = getErrorMessage(error); const errorCode = error instanceof ARMError ? error.code : undefined; - // logs error to data explorer console + // logs error to data explorer console (always shows original, non-redacted message) const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage; logConsoleError(consoleErrorMessage); - // logs error to both app insight and kusto - logError(errorMessage, area, errorCode); + // logs error to both app insight and kusto (use redacted message if provided) + const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage; + logError(telemetryErrorMessage, area, errorCode); // checks for errors caused by firewall and sends them to portal to handle sendNotificationForError(errorMessage, errorCode); diff --git a/src/Common/dataAccess/queryDocumentsPage.test.ts b/src/Common/dataAccess/queryDocumentsPage.test.ts new file mode 100644 index 000000000..adf68bd02 --- /dev/null +++ b/src/Common/dataAccess/queryDocumentsPage.test.ts @@ -0,0 +1,171 @@ +import { redactSyntaxErrorMessage } from "./queryDocumentsPage"; + +/* Typical error to redact looks like this (the message property contains a JSON string with nested structure): +{ + "message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}" +} +*/ + +// Helper to create the nested error structure that matches what the SDK returns +const createNestedError = ( + errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>, + activityId: string = "test-activity-id", +): { message: string } => { + const innerErrorsJson = JSON.stringify({ errors }); + const innerMessage = `${innerErrorsJson}\r\n${activityId}`; + const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage }); + return { message: outerJson }; +}; + +// Helper to parse the redacted result +const parseRedactedResult = (result: { message: string }) => { + const outerParsed = JSON.parse(result.message); + const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n"); + const innerErrors = JSON.parse(innerErrorsJson); + return { outerParsed, innerErrors, activityIdPart }; +}; + +describe("redactSyntaxErrorMessage", () => { + it("should redact SC1001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 5 }, + code: "SC1001", + message: "Syntax error, incorrect syntax near 'Crazy'.", + }, + ], + "ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17"); + }); + + it("should redact SC2001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 10 }, + code: "SC2001", + message: "Some sensitive syntax error message.", + }, + ], + "ActivityId: abc123", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: abc123"); + }); + + it("should redact multiple errors with SC1001 and SC2001 codes", () => { + const error = createNestedError( + [ + { severity: "Error", code: "SC1001", message: "First error" }, + { severity: "Error", code: "SC2001", message: "Second error" }, + ], + "ActivityId: xyz", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(innerErrors.errors[1].message).toBe("__REDACTED__"); + }); + + it("should not redact errors with other codes", () => { + const error = createNestedError( + [{ severity: "Error", code: "SC9999", message: "This should not be redacted." }], + "ActivityId: test123", + ); + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); // Should return original error unchanged + }); + + it("should not modify non-BadRequest errors", () => { + const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] }); + const error = { + message: JSON.stringify({ code: "NotFound", message: innerMessage }), + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle errors without message property", () => { + const error = { code: "BadRequest" }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle non-object errors", () => { + const stringError = "Simple string error"; + const nullError: null = null; + const undefinedError: undefined = undefined; + + expect(redactSyntaxErrorMessage(stringError)).toBe(stringError); + expect(redactSyntaxErrorMessage(nullError)).toBe(nullError); + expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError); + }); + + it("should handle malformed JSON in message", () => { + const error = { + message: "not valid json", + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle message without ActivityId suffix", () => { + const innerErrorsJson = JSON.stringify({ + errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }], + }); + const error = { + message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }), + }; + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); + + it("should preserve other error properties", () => { + const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test"); + const error = { + ...baseError, + statusCode: 400, + additionalInfo: "extra data", + }; + + const result = redactSyntaxErrorMessage(error) as { + message: string; + statusCode: number; + additionalInfo: string; + }; + + expect(result.statusCode).toBe(400); + expect(result.additionalInfo).toBe("extra data"); + + const { innerErrors } = parseRedactedResult(result); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); +}); diff --git a/src/Common/dataAccess/queryDocumentsPage.ts b/src/Common/dataAccess/queryDocumentsPage.ts index 556ed290c..b5ec9c684 100644 --- a/src/Common/dataAccess/queryDocumentsPage.ts +++ b/src/Common/dataAccess/queryDocumentsPage.ts @@ -4,6 +4,51 @@ import { getEntityName } from "../DocumentUtility"; import { handleError } from "../ErrorHandlingUtils"; import { MinimalQueryIterator, nextPage } from "../IteratorUtilities"; +// Redact sensitive information from BadRequest errors with specific codes +export const redactSyntaxErrorMessage = (error: unknown): unknown => { + const codesToRedact = ["SC1001", "SC2001"]; + + try { + // Handle error objects with a message property + if (error && typeof error === "object" && "message" in error) { + const errorObj = error as { code?: string; message?: string }; + if (typeof errorObj.message === "string") { + // Parse the inner JSON from the message + const innerJson = JSON.parse(errorObj.message); + if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") { + const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n"); + const innerErrorsObj = JSON.parse(innerErrorsJson); + if (Array.isArray(innerErrorsObj.errors)) { + let modified = false; + innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => { + if (err.code && codesToRedact.includes(err.code)) { + modified = true; + return { ...err, message: "__REDACTED__" }; + } + return err; + }); + + if (modified) { + // Reconstruct the message with the redacted content + const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`; + const redactedError = { + ...error, + message: JSON.stringify({ ...innerJson, message: redactedMessage }), + body: undefined as unknown, // Clear body to avoid sensitive data + }; + return redactedError; + } + } + } + } + } + } catch { + // If parsing fails, return the original error + } + + return error; +}; + export const queryDocumentsPage = async ( resourceName: string, documentsIterator: MinimalQueryIterator, @@ -18,7 +63,12 @@ export const queryDocumentsPage = async ( logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`); return result; } catch (error) { - handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`); + // Redact sensitive information for telemetry while showing original in console + const redactedError = redactSyntaxErrorMessage(error); + + handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, { + redactedError: redactedError as Error, + }); throw error; } finally { clearMessage(); From 703218debf6d032156edb512305f44f0bccb9894 Mon Sep 17 00:00:00 2001 From: Laurent Nguyen Date: Fri, 23 Jan 2026 07:38:04 +0100 Subject: [PATCH 18/20] Fix error message in bulk delete to reflect total document count (#2331) * Fix error message in bulk delete to reflect total document count * Fix format --- src/Common/dataAccess/deleteDocument.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index 9d4b8a396..b8a4a0430 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -44,7 +44,8 @@ export const deleteDocuments = async ( documentIds: DocumentId[], abortSignal: AbortSignal, ): Promise => { - const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); + const totalCount = documentIds.length; + const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`); try { const v2Container = await client().database(collection.databaseId).container(collection.id()); @@ -83,11 +84,7 @@ export const deleteDocuments = async ( const flatAllResult = Array.prototype.concat.apply([], allResult); return flatAllResult; } catch (error) { - handleError( - error, - "DeleteDocuments", - `Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`, - ); + handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`); throw error; } finally { clearMessage(); From 05407b3e0fd7e7c7a0ee0c6491cab577f836a9f5 Mon Sep 17 00:00:00 2001 From: BChoudhury-ms Date: Fri, 23 Jan 2026 20:58:46 +0530 Subject: [PATCH 19/20] Add search/filter support to Copy Jobs list with pagination updates (#2343) * search the copy job * remove timeout * Update src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix pagination race condition when filtering copy jobs (#2351) * Initial plan * Fix pagination race condition by resetting startIndex synchronously Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: BChoudhury-ms <201893606+BChoudhury-ms@users.noreply.github.com> --- .../Components/CopyJobsList.test.tsx | 201 +++++++++++++++++- .../Components/CopyJobsList.tsx | 57 ++++- .../ContainerCopy/containerCopyStyles.less | 46 ++-- .../containercopy/offlineMigration.spec.ts | 10 +- .../sql/containercopy/onlineMigration.spec.ts | 12 +- .../containercopy/permissionsScreen.spec.ts | 8 +- 6 files changed, 294 insertions(+), 40 deletions(-) diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx index c3b723265..64778f2ff 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx @@ -11,9 +11,17 @@ jest.mock("../../Actions/CopyJobActions", () => ({ jest.mock("./CopyJobColumns", () => ({ getColumns: jest.fn(() => [ + { + key: "LastUpdatedTime", + name: "Date & time", + fieldName: "LastUpdatedTime", + minWidth: 140, + maxWidth: 300, + isResizable: true, + }, { key: "Name", - name: "Name", + name: "Job name", fieldName: "Name", minWidth: 140, maxWidth: 300, @@ -165,6 +173,165 @@ describe("CopyJobsList", () => { expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument(); expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument(); }); + + it("renders filter TextField with data-test attribute", () => { + render(); + + const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]'); + expect(filterTextField).toBeInTheDocument(); + }); + + it("renders search TextField with correct placeholder", () => { + render(); + + const searchInput = screen.getByPlaceholderText("Search jobs..."); + expect(searchInput).toBeInTheDocument(); + }); + }); + + describe("Filtering", () => { + it("filters jobs by Name when text is entered", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs case-insensitively", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "test job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + }); + + it("shows all jobs when filter text is empty", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + + fireEvent.change(filterInput, { target: { value: "" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("filters jobs by Status across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs by Mode across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Offline" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("shows no results when filter matches no jobs", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "NonExistentJob" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters by partial text match", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Test" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("resets pagination when filter changes", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + // Navigate to page 2 + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument(); + }); + + // Apply filter - should reset to page 1 + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + // Filtered results show from the beginning + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + }); + }); + + it("updates filtered count in pager", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Alpha" } }); + + await waitFor(() => { + expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument(); + // Pager should not be visible since filtered results (5) are less than page size (10) + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + }); + }); }); describe("Pagination", () => { @@ -342,7 +509,7 @@ describe("CopyJobsList", () => { describe("Component Props", () => { it("uses default page size when not provided", () => { - const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({ + const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({ ...mockJobs[0], ID: `job-${i + 1}`, Name: `Test Job ${i + 1}`, @@ -351,7 +518,7 @@ describe("CopyJobsList", () => { render(); expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); - expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument(); }); it("passes correct props to getColumns function", async () => { @@ -440,7 +607,33 @@ describe("CopyJobsList", () => { render(); }).not.toThrow(); - expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument(); + }); + + it("handles filtering with null or undefined values gracefully", async () => { + const jobsWithNullValues: CopyJobType[] = [ + { + ...mockJobs[0], + ID: "job-with-values", + Name: "Valid Job", + }, + { + ...mockJobs[1], + ID: "job-null-name", + Name: undefined as unknown as string, + }, + ]; + + expect(() => { + render(); + }).not.toThrow(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Valid" } }); + + await waitFor(() => { + expect(screen.getByText("Valid Job")).toBeInTheDocument(); + }); }); }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx index a263ac137..dcdfd1033 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -12,8 +12,9 @@ import { Stack, Sticky, StickyPositionType, + TextField, } from "@fluentui/react"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import Pager from "../../../../Common/Pager"; import { useThemeStore } from "../../../../hooks/useTheme"; import { getThemeTokens } from "../../../Theme/ThemeUtil"; @@ -30,9 +31,15 @@ interface CopyJobsListProps { const styles = { container: { height: "100%" } as React.CSSProperties, stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, + filterContainer: { + margin: "15px 5px", + }, }; -const PAGE_SIZE = 10; +const PAGE_SIZE = 15; + +// Columns to search across +const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"]; const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -41,6 +48,23 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa const [sortedJobs, setSortedJobs] = React.useState(jobs); const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined); const [isSortedDescending, setIsSortedDescending] = React.useState(false); + const [filterText, setFilterText] = React.useState(""); + + const filteredJobs = useMemo(() => { + if (!filterText) { + return sortedJobs; + } + const lowerFilterText = filterText.toLowerCase(); + return sortedJobs.filter((job: any) => { + return searchableFields.some((field) => { + const value = job[field]; + if (value === undefined || value === null) { + return false; + } + return String(value).toLowerCase().includes(lowerFilterText); + }); + }); + }, [sortedJobs, filterText]); useEffect(() => { setSortedJobs(jobs); @@ -64,7 +88,15 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa setStartIndex(0); }; - const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + + const handleFilterTextChange = ( + _event: React.FormEvent, + newValue?: string, + ) => { + setFilterText(newValue || ""); + setStartIndex(0); + }; const _handleRowClick = (job: CopyJobType) => { openCopyJobDetailsPanel(job); @@ -81,14 +113,25 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa return (
+ +
+ +
+
= ({ jobs, handleActionClick, pa /> - {sortedJobs.length > pageSize && ( + {filteredJobs.length > pageSize && ( { setStartIndex(startIdx); diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 6f99f4055..9cc625860 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -1,5 +1,27 @@ @import "../../../less/Common/Constants.less"; +.themedTextFieldStyles() { + .ms-TextField { + .ms-TextField-fieldGroup { + background-color: var(--colorNeutralBackground1); + border-color: var(--colorNeutralStroke1); + } + + .ms-TextField-field { + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + + &::placeholder { + color: var(--colorNeutralForeground4); + } + } + + .ms-Label { + color: var(--colorNeutralForeground1); + } + } +} + // Common theme-aware classes .themeText { color: var(--colorNeutralForeground1); @@ -119,25 +141,8 @@ filter: invert(1); } - .ms-TextField { - .ms-TextField-fieldGroup { - background-color: var(--colorNeutralBackground1); - border-color: var(--colorNeutralStroke1); - } + .themedTextFieldStyles(); - .ms-TextField-field { - color: var(--colorNeutralForeground1); - background-color: var(--colorNeutralBackground1); - - &::placeholder { - color: var(--colorNeutralForeground4); - } - } - - .ms-Label { - color: var(--colorNeutralForeground1); - } - } .migrationTypeDescription { p { color: var(--colorNeutralForeground1); @@ -173,6 +178,11 @@ width: 100%; max-width: 100%; margin: 0 auto; + + body.isDarkMode & { + .themedTextFieldStyles(); + } + .ms-DetailsList { width: 100%; diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts index de9597d49..e99610cb4 100644 --- a/test/sql/containercopy/offlineMigration.spec.ts +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -246,13 +246,17 @@ test.describe("Container Copy - Offline Migration", () => { expect(response.ok()).toBe(true); // Verify panel closes and job appears in the list - await expect(panel).not.toBeVisible({ timeout: 5000 }); + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(validJobName); const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + await jobsListContainer.waitFor({ state: "visible" }); const jobItem = jobsListContainer.getByText(validJobName); - await jobItem.waitFor({ state: "visible", timeout: 5000 }); + await jobItem.waitFor({ state: "visible" }); await expect(jobItem).toBeVisible(); }); }); diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts index 3914f6002..e11b3decd 100644 --- a/test/sql/containercopy/onlineMigration.spec.ts +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -120,18 +120,22 @@ test.describe("Container Copy - Online Migration", () => { expect(response.ok()).toBe(true); // Verify panel closes and job appears in the list - await expect(panel).not.toBeVisible({ timeout: 5000 }); + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(onlineMigrationJobName); const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible", timeout: 5000 }); + await jobsListContainer.waitFor({ state: "visible" }); 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 }); + await jobRow.waitFor({ state: "visible" }); // Verify job status changes to queued state - await expect(statusCell).toContainText(/running|queued|pending/i, { timeout: 5000 }); + await expect(statusCell).toContainText(/running|queued|pending/i); // Test job lifecycle management through action menu actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts index fa7d3e199..f592bf4c7 100644 --- a/test/sql/containercopy/permissionsScreen.spec.ts +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -134,7 +134,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); await expect(pitrBtn).toBeVisible(); - await pitrBtn.click(); + await pitrBtn.click({ force: true }); // Verify new page opens with correct URL pattern page.context().on("page", async (newPage) => { @@ -246,7 +246,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); await expect(toggleButton).toBeVisible(); - await toggleButton.click(); + await toggleButton.click({ force: true }); // Verify popover functionality const popover = frame.locator("[data-test='popover-container']"); @@ -257,7 +257,7 @@ test.describe("Container Copy - Permission Screen Verification", () => { await expect(yesButton).toBeVisible(); await expect(noButton).toBeVisible(); - await yesButton.click(); + await yesButton.click({ force: true }); // Verify loading states await expect(loadingOverlay).toBeVisible(); @@ -265,6 +265,6 @@ test.describe("Container Copy - Permission Screen Verification", () => { await expect(popover).toBeHidden({ timeout: 10 * 1000 }); // Cancel the panel to clean up - await panel.getByRole("button", { name: "Cancel" }).click(); + await panel.getByRole("button", { name: "Cancel" }).click({ force: true }); }); }); From 2998f14d528a10e44bbd47119a6809b10a028f71 Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Mon, 26 Jan 2026 12:37:07 -0800 Subject: [PATCH 20/20] Fix playwright tests (#2342) * dont refresh tree when opening scale & settings * disable offline/online migration tests * delete db after each test * DEBUG: expand console for mongo testing * find first execute button for stored procedure * DEBUG: wait for editor to process changes * increase wait time to 5s * verify document text was set * keep document spec as original * debug new document and save document count * when loading a document, wait for document text to appear then click new document * wait for document to be loaded * remove debug statement * wait for results to attach * do forced wait instead * cleanup tests * uncomment container copy tests * run test account cleanup every 12 hours * change cleanup frequency to once a day --------- Co-authored-by: Asier Isayas --- .github/workflows/cleanup.yml | 4 ++-- test/fx.ts | 13 +++---------- test/mongo/document.spec.ts | 14 ++++++++------ test/sql/document.spec.ts | 4 +--- test/sql/query.spec.ts | 9 +++------ .../scaleAndSettings/changePartitionKey.spec.ts | 9 +++------ test/sql/scaleAndSettings/scale.spec.ts | 4 +--- test/sql/scaleAndSettings/settings.spec.ts | 9 +++------ test/sql/scripts/storedProcedure.spec.ts | 2 +- test/sql/scripts/trigger.spec.ts | 8 +++----- test/sql/scripts/userDefinedFunction.spec.ts | 8 +++----- 11 files changed, 31 insertions(+), 53 deletions(-) diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index ece8c8dba..80991db2a 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -6,8 +6,8 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: schedule: - # Once every two hours - - cron: "0 */2 * * *" + # Once every day at 7 AM PST + - cron: "0 13 * * *" permissions: id-token: write diff --git a/test/fx.ts b/test/fx.ts index 1de8be90d..f9313b6ca 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -378,9 +378,11 @@ type PanelOpenOptions = { export enum CommandBarButton { Save = "Save", + Delete = "Delete", Execute = "Execute", ExecuteQuery = "Execute Query", UploadItem = "Upload Item", + NewDocument = "New Document", } /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ @@ -478,7 +480,7 @@ export class DataExplorer { return await this.waitForNode(`${databaseId}/${containerId}/Documents`); } - async waitForCommandBarButton(label: string, timeout?: number): Promise { + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { const commandBar = this.commandBarButton(label); await commandBar.waitFor({ state: "visible", timeout }); return commandBar; @@ -515,15 +517,6 @@ export class DataExplorer { const containerNode = await this.waitForContainerNode(context.database.id, context.container.id); await containerNode.expand(); - // refresh tree to remove deleted database - const consoleMessages = await this.getNotificationConsoleMessages(); - const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton"); - await refreshButton.click(); - await expect(consoleMessages).toContainText("Successfully refreshed databases", { - timeout: ONE_MINUTE_MS, - }); - await this.collapseNotificationConsole(); - const scaleAndSettingsButton = this.frame.getByTestId( `TreeNode:${context.database.id}/${context.container.id}/Scale & Settings`, ); diff --git a/test/mongo/document.spec.ts b/test/mongo/document.spec.ts index b6703c49a..cf98ebcc7 100644 --- a/test/mongo/document.spec.ts +++ b/test/mongo/document.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { setupCORSBypass } from "../CORSBypass"; -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; +import { CommandBarButton, DataExplorer, DocumentsTab, TestAccount } from "../fx"; import { retry, serializeMongoToJson, setPartitionKeys } from "../testData"; import { documentTestCases } from "./testCases"; @@ -48,19 +48,20 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { expect(resultData?._id).not.toBeNull(); expect(resultData?._id).toEqual(docId); }); - test(`should be able to create and delete new document from ${docId}`, async () => { + test(`should be able to create and delete new document from ${docId}`, async ({ page }) => { const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); await span.waitFor(); await expect(span).toBeVisible(); await span.click(); + await page.waitForTimeout(5000); // wait for 5 seconds to ensure document is fully loaded. waitforTimeout is not recommended generally but here we are working around flakiness in the test env + let newDocumentId; await retry(async () => { - const newDocumentButton = await explorer.waitForCommandBarButton("New Document", 5000); + const newDocumentButton = await explorer.waitForCommandBarButton(CommandBarButton.NewDocument, 5000); await expect(newDocumentButton).toBeVisible(); await expect(newDocumentButton).toBeEnabled(); await newDocumentButton.click(); - await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); newDocumentId = `${Date.now().toString()}-delete`; @@ -71,8 +72,9 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { }; await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); - const saveButton = await explorer.waitForCommandBarButton("Save", 5000); + const saveButton = await explorer.waitForCommandBarButton(CommandBarButton.Save, 5000); await saveButton.click({ timeout: 5000 }); + await expect(saveButton).toBeHidden({ timeout: 5000 }); }, 3); @@ -84,7 +86,7 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { await newSpan.click(); await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); - const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000); + const deleteButton = await explorer.waitForCommandBarButton(CommandBarButton.Delete, 5000); await deleteButton.click(); const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000); diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 5d17c22c3..a093da376 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -136,9 +136,7 @@ test.describe.serial("Upload Item", () => { if (existsSync(uploadDocumentDirPath)) { rmdirSync(uploadDocumentDirPath); } - if (!process.env.CI) { - await context?.dispose(); - } + await context?.dispose(); }); test.afterEach("Close Upload Items panel if still open", async () => { diff --git a/test/sql/query.spec.ts b/test/sql/query.spec.ts index f9dfc80f9..6368c4327 100644 --- a/test/sql/query.spec.ts +++ b/test/sql/query.spec.ts @@ -30,12 +30,9 @@ test.beforeEach("Open new query tab", async ({ page }) => { await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor(); }); -// Delete database only if not running in CI -if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); -} +test.afterAll("Delete Test Database", async () => { + await context?.dispose(); +}); test("Query results", async () => { // Run the query and verify the results diff --git a/test/sql/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index 1f23d3154..b92d65ee7 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -23,12 +23,9 @@ test.describe("Change Partition Key", () => { await PartitionKeyTab.click(); }); - // Delete database only if not running in CI - if (!process.env.CI) { - test.afterEach("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterEach("Delete Test Database", async () => { + await context?.dispose(); + }); test("Change partition key path", async ({ page }) => { await expect(explorer.frame.getByText("/partitionKey")).toBeVisible(); diff --git a/test/sql/scaleAndSettings/scale.spec.ts b/test/sql/scaleAndSettings/scale.spec.ts index d12db999c..d886b2def 100644 --- a/test/sql/scaleAndSettings/scale.spec.ts +++ b/test/sql/scaleAndSettings/scale.spec.ts @@ -118,7 +118,5 @@ async function openScaleTab(browser: Browser): Promise { } async function cleanup({ context }: Partial) { - if (!process.env.CI) { - await context?.dispose(); - } + await context?.dispose(); } diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index 3f14422eb..f60889574 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -17,12 +17,9 @@ test.describe("Settings under Scale & Settings", () => { await settingsTab.click(); }); - // Delete database only if not running in CI - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Update TTL to On (no default)", async () => { const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); diff --git a/test/sql/scripts/storedProcedure.spec.ts b/test/sql/scripts/storedProcedure.spec.ts index 35fb4e0f8..9b53f384d 100644 --- a/test/sql/scripts/storedProcedure.spec.ts +++ b/test/sql/scripts/storedProcedure.spec.ts @@ -43,7 +43,7 @@ test.describe("Stored Procedures", () => { ); // Execute stored procedure - const executeButton = explorer.commandBarButton(CommandBarButton.Execute); + const executeButton = explorer.commandBarButton(CommandBarButton.Execute).first(); await executeButton.click(); const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton"); await executeSidePanelButton.click(); diff --git a/test/sql/scripts/trigger.spec.ts b/test/sql/scripts/trigger.spec.ts index 6874c2aac..9792466d5 100644 --- a/test/sql/scripts/trigger.spec.ts +++ b/test/sql/scripts/trigger.spec.ts @@ -26,11 +26,9 @@ test.describe("Triggers", () => { explorer = await DataExplorer.open(page, TestAccount.SQL); }); - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Add and delete trigger", async ({ page }, testInfo) => { // Open container context menu and click New Trigger diff --git a/test/sql/scripts/userDefinedFunction.spec.ts b/test/sql/scripts/userDefinedFunction.spec.ts index 911b1f4ce..c46b19989 100644 --- a/test/sql/scripts/userDefinedFunction.spec.ts +++ b/test/sql/scripts/userDefinedFunction.spec.ts @@ -19,11 +19,9 @@ test.describe("User Defined Functions", () => { explorer = await DataExplorer.open(page, TestAccount.SQL); }); - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Add, execute, and delete user defined function", async ({ page }, testInfo) => { // Open container context menu and click New UDF