Compare commits

..

1 Commits

Author SHA1 Message Date
Bikram Choudhury
8a26c6ae65 show confirmation dialogs for canceling or confirming jobs 2026-01-09 18:48:32 +05:30
43 changed files with 4642 additions and 2691 deletions

4091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@
"@nteract/commutable": "7.5.1",
"@nteract/connected-components": "6.8.2",
"@nteract/core": "15.1.9",
"@nteract/data-explorer": "8.2.12",
"@nteract/data-explorer": "8.0.3",
"@nteract/directory-listing": "2.0.6",
"@nteract/dropdown-menu": "1.0.1",
"@nteract/editor": "10.1.12",
@@ -177,7 +177,7 @@
"jest-html-loader": "1.0.0",
"jest-react-hooks-shallow": "1.5.1",
"jest-trx-results-processor": "3.0.2",
"less": "3.13.1",
"less": "3.8.1",
"less-loader": "11.1.3",
"less-vars-loader": "1.1.0",
"mini-css-extract-plugin": "2.1.0",
@@ -195,7 +195,7 @@
"typedoc": "0.26.2",
"typescript": "4.9.5",
"url-loader": "4.1.1",
"wait-on": "9.0.3",
"wait-on": "4.0.2",
"webpack": "5.88.2",
"webpack-bundle-analyzer": "4.9.1",
"webpack-cli": "5.1.4",

View File

@@ -9,7 +9,7 @@ import {
SqlStoredProcedureCreateUpdateParameters,
SqlStoredProcedureResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createStoredProcedure(
): Promise<StoredProcedureDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`);
try {
let resource: StoredProcedureDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,16 +60,14 @@ export async function createStoredProcedure(
storedProcedure.id,
createSprocParams,
);
resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource);
}
logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.storedProcedures.create(storedProcedure);
return response?.resource;
} catch (error) {
handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`);
throw error;

View File

@@ -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 { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -14,7 +14,6 @@ export async function createTrigger(
): Promise<TriggerDefinition | SqlTriggerResource> {
const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`);
try {
let resource: SqlTriggerResource | TriggerDefinition;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -53,16 +52,14 @@ export async function createTrigger(
trigger.id,
createTriggerParams,
);
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;
return rpResponse && rpResponse.properties?.resource;
}
logConsoleInfo(`Successfully created trigger ${trigger.id}`);
return 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;
} catch (error) {
handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`);
throw error;

View File

@@ -9,7 +9,7 @@ import {
SqlUserDefinedFunctionCreateUpdateParameters,
SqlUserDefinedFunctionResource,
} from "../../Utils/arm/generatedClients/cosmos/types";
import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { client } from "../CosmosClient";
import { handleError } from "../ErrorHandlingUtils";
@@ -20,7 +20,6 @@ export async function createUserDefinedFunction(
): Promise<UserDefinedFunctionDefinition & Resource> {
const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`);
try {
let resource: UserDefinedFunctionDefinition & Resource;
if (
userContext.authType === AuthType.AAD &&
!userContext.features.enableSDKoperations &&
@@ -61,17 +60,14 @@ export async function createUserDefinedFunction(
userDefinedFunction.id,
createUDFParams,
);
resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
} else {
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
resource = response.resource;
return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource);
}
logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`);
return resource;
const response = await client()
.database(databaseId)
.container(collectionId)
.scripts.userDefinedFunctions.create(userDefinedFunction);
return response?.resource;
} catch (error) {
handleError(
error,

View File

@@ -28,7 +28,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -24,7 +24,6 @@ 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;

View File

@@ -173,5 +173,10 @@ export default {
Skipped: "Cancelled",
Cancelled: "Cancelled",
},
dialog: {
heading: "",
confirmButtonText: "Confirm",
cancelButtonText: "Cancel",
},
},
};

View File

@@ -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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
@@ -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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<TestComponentWrapper job={job} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={testHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
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(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
rerender(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument();
expect(() => unmount()).not.toThrow();
});
});
});

View File

@@ -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) => (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
You are about to cancel <b>{jobName}</b> copy job.
</Stack.Item>
<Stack.Item>Cancelling will stop the job immediately.</Stack.Item>
</Stack>
),
[CopyJobActions.complete]: (jobName: string) => (
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
You are about to complete <b>{jobName}</b> copy job.
</Stack.Item>
<Stack.Item>
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.
</Stack.Item>
</Stack>
),
};
const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick }) => {
const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null);
if (
@@ -22,6 +45,20 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ 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<CopyJobActionMenuProps> = ({ 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<CopyJobActionMenuProps> = ({ 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<CopyJobActionMenuProps> = ({ 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}
/>

View File

@@ -1,8 +1,5 @@
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";
@@ -447,49 +444,3 @@ 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(<SettingsComponent {...baseProps} />);
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);
});
});

View File

@@ -13,7 +13,6 @@ 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";
@@ -74,6 +73,7 @@ import {
parseConflictResolutionMode,
parseConflictResolutionProcedure,
} from "./SettingsUtils";
interface SettingsV2TabInfo {
tab: SettingsV2TabTypes;
content: JSX.Element;
@@ -182,7 +182,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private totalThroughputUsed: number;
private throughputBucketsEnabled: boolean;
public mongoDBCollectionResource: MongoDBCollectionResource;
private unsubscribe: () => void;
constructor(props: SettingsComponentProps) {
super(props);
@@ -312,13 +312,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
if (this.isCollectionSettingsTab) {
this.refreshIndexTransformationProgress();
this.loadMongoIndexes();
this.unsubscribe = useIndexingPolicyStore.subscribe(
() => {
this.refreshCollectionData();
},
(state) => state.indexingPolicies[this.collection?.id()],
);
this.refreshCollectionData();
}
this.setBaseline();
@@ -326,11 +319,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}
}
componentWillUnmount(): void {
if (this.unsubscribe) {
this.unsubscribe();
}
}
componentDidUpdate(): void {
if (this.props.settingsTab.isActive()) {
useCommandBar.getState().setContextButtons(this.getTabsButtons());
@@ -860,6 +849,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
{ name: "name_of_property", query: "query_to_compute_property" },
] as DataModels.ComputedProperties;
}
const throughputBuckets = this.offer?.throughputBuckets;
return {
@@ -1019,31 +1009,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
startKey,
);
};
private refreshCollectionData = async (): Promise<void> => {
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<void> => {
const newCollection: DataModels.Collection = { ...this.collection.rawDataModel };
if (
this.state.isSubSettingsSaveable ||
this.state.isContainerPolicyDirty ||
@@ -1283,6 +1252,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
onScaleDiscardableChange: this.onScaleDiscardableChange,
throughputError: this.state.throughputError,
};
if (!this.isCollectionSettingsTab) {
return (
<div className="settingsV2MainContainer">

View File

@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
</Link>
&#160; about how to define computed properties and how to use them.
</Text>
<div
className="settingsV2Editor"
tabIndex={0}
ref={this.computedPropertiesDiv}
data-test="computed-properties-editor"
></div>
<div className="settingsV2Editor" tabIndex={0} ref={this.computedPropertiesDiv}></div>
</Stack>
);
}

View File

@@ -1,10 +1,10 @@
import { MessageBar, MessageBarType } from "@fluentui/react";
import * as React from "react";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { MessageBar, MessageBarType } from "@fluentui/react";
import {
mongoIndexTransformationRefreshingMessage,
renderMongoIndexTransformationRefreshMessage,
} from "../../SettingsRenderUtils";
import { handleError } from "../../../../../Common/ErrorHandlingUtils";
import { isIndexTransforming } from "../../SettingsUtils";
export interface IndexingPolicyRefreshComponentProps {

View File

@@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
);
private geoSpatialConfigTypeChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: GeospatialConfigType.Geography, text: "Geography", ariaLabel: "geography-option" },
{ key: GeospatialConfigType.Geometry, text: "Geometry", ariaLabel: "geometry-option" },
{ key: GeospatialConfigType.Geography, text: "Geography" },
{ key: GeospatialConfigType.Geometry, text: "Geometry" },
];
private getGeoSpatialComponent = (): JSX.Element => (

View File

@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
</Text>
<div
className="settingsV2Editor"
data-test="computed-properties-editor"
tabIndex={0}
/>
</Stack>

View File

@@ -167,12 +167,10 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -654,12 +652,10 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1228,12 +1224,10 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -1766,12 +1760,10 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},
@@ -2338,12 +2330,10 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "geography-option",
"key": "Geography",
"text": "Geography",
},
{
"ariaLabel": "geometry-option",
"key": "Geometry",
"text": "Geometry",
},

View File

@@ -153,16 +153,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -274,16 +264,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -496,16 +476,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{
@@ -683,16 +653,6 @@ exports[`SettingsComponent renders 1`] = `
"partitionKey",
],
"rawDataModel": {
"indexingPolicy": {
"automatic": true,
"compositeIndexes": [],
"excludedPaths": [],
"fullTextIndexes": [],
"includedPaths": [],
"indexingMode": "consistent",
"spatialIndexes": [],
"vectorIndexes": [],
},
"uniqueKeyPolicy": {
"uniqueKeys": [
{

View File

@@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent<UploadItemsPaneProps> = ({ 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 && (
<div className="fileUploadSummaryContainer" data-test="file-upload-status">
<div className="fileUploadSummaryContainer">
<b style={{ color: "var(--colorNeutralForeground1)" }}>File upload status</b>
<DetailsList
items={uploadFileData}

View File

@@ -1,107 +0,0 @@
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 (
<div className={style.indexAdvisorImpactDots}>
{Array.from({ length: count }).map((_, i) => (
<CircleFilled key={i} className={style.indexAdvisorImpactDot} />
))}
</div>
);
};

View File

@@ -3,21 +3,18 @@ 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<void>;
queryEditorContent?: string;
databaseId?: string;
containerId?: string;
}
interface QueryResultProps extends ResultsViewProps {
@@ -52,8 +49,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults,
executeQueryDocumentsPage,
isExecuting,
databaseId,
containerId,
}: QueryResultProps): JSX.Element => {
const styles = useQueryTabStyles();
const maybeSubQuery = queryEditorContent && /.*\(.*SELECT.*\)/i.test(queryEditorContent);
@@ -96,9 +91,6 @@ export const QueryResultSection: React.FC<QueryResultProps> = ({
queryResults={queryResults}
executeQueryDocumentsPage={executeQueryDocumentsPage}
isMongoDB={isMongoDB}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
) : (
<ExecuteQueryCallToAction />

View File

@@ -52,9 +52,8 @@ describe("QueryTabComponent", () => {
copilotVersion: "v3.0",
},
});
const propsMock: Readonly<IQueryTabComponentProps> = {
collection: { databaseId: "CopilotSampleDB", id: () => "CopilotContainer" },
collection: { databaseId: "CopilotSampleDB" },
onTabAccessor: () => jest.fn(),
isExecutionError: false,
tabId: "mockTabId",

View File

@@ -28,7 +28,6 @@ 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";
@@ -58,20 +57,6 @@ 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<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));
enum ToggleState {
Result,
QueryMetrics,
@@ -279,10 +264,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
}
public onExecuteQueryClick = async (): Promise<void> => {
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 () => {
@@ -799,8 +780,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.props.copilotStore?.errors}
isExecuting={this.props.copilotStore?.isExecuting}
queryResults={this.props.copilotStore?.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
QueryDocumentsPerPage(
firstItemIndex,
@@ -816,8 +795,6 @@ class QueryTabComponentImpl extends React.Component<QueryTabComponentImplProps,
errors={this.state.errors}
isExecuting={this.state.isExecuting}
queryResults={this.state.queryResults}
databaseId={this.props.collection.databaseId}
containerId={this.props.collection.id()}
executeQueryDocumentsPage={(firstItemIndex: number) =>
this._executeQueryDocumentsPage(firstItemIndex)
}

View File

@@ -1,170 +0,0 @@
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(
<IndexAdvisorTab queryEditorContent="SELECT * FROM c" databaseId="db1" containerId="col1" />,
);
expect(container).toBeTruthy();
});
test("renders component and handles missing parameters", () => {
const { container } = render(<IndexAdvisorTab />);
expect(container).toBeTruthy();
// Should not crash when parameters are missing
});
test("fetches index metrics with query results", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockFetchAll).toHaveBeenCalled());
});
test("displays content after loading", async () => {
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
// 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(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockLogConsoleProgress).toHaveBeenCalled());
});
test("handles error when fetch fails", async () => {
mockFetchAll.mockRejectedValueOnce(new Error("fetch failed"));
render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="db1"
containerId="col1"
/>,
);
await waitFor(() => expect(mockHandleError).toHaveBeenCalled(), { timeout: 3000 });
});
test("renders with all required props", () => {
const { container } = render(
<IndexAdvisorTab
queryResults={mockQueryResults}
queryEditorContent="SELECT * FROM c"
databaseId="testDb"
containerId="testContainer"
/>,
);
expect(container).toBeTruthy();
expect(container.firstChild).toBeTruthy();
});
});

View File

@@ -1,8 +1,5 @@
import type { CompositePath, IndexingPolicy } from "@azure/cosmos";
import { FontIcon } from "@fluentui/react";
import {
Button,
Checkbox,
DataGrid,
DataGridBody,
DataGridCell,
@@ -11,45 +8,28 @@ import {
DataGridRow,
SelectTabData,
SelectTabEvent,
Spinner,
Tab,
TabList,
Table,
TableBody,
TableCell,
TableColumnDefinition,
TableHeader,
TableRow,
createTableColumn,
} from "@fluentui/react-components";
import { ArrowDownloadRegular, ChevronDown20Regular, ChevronRight20Regular, CopyRegular } from "@fluentui/react-icons";
import copy from "clipboard-copy";
import { ArrowDownloadRegular, CopyRegular } from "@fluentui/react-icons";
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 { logConsoleProgress } from "Utils/NotificationConsoleUtils";
import create from "zustand";
import { client } from "../../../Common/CosmosClient";
import { handleError } from "../../../Common/ErrorHandlingUtils";
import { sampleDataClient } from "../../../Common/SampleDataClient";
import copy from "clipboard-copy";
import React, { useCallback, useState } from "react";
import { ResultsViewProps } from "./QueryResultSection";
import { useIndexAdvisorStyles } from "./StylesAdvisor";
enum ResultsTabs {
Results = "results",
QueryStats = "queryStats",
IndexAdvisor = "indexadv",
}
const ResultsTab: React.FC<ResultsViewProps> = ({ queryResults, isMongoDB, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
/* eslint-disable react/prop-types */
@@ -543,331 +523,14 @@ const QueryStatsTab: React.FC<Pick<ResultsViewProps, "queryResults">> = ({ query
);
};
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<IndexMetricsResponse | null>(null);
const [showIncluded, setShowIncluded] = useState(true);
const [showNotIncluded, setShowNotIncluded] = useState(true);
const [selectedIndexes, setSelectedIndexes] = useState<IIndexMetric[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [updateMessageShown, setUpdateMessageShown] = useState(false);
const [included, setIncludedIndexes] = useState<IIndexMetric[]>([]);
const [notIncluded, setNotIncludedIndexes] = useState<IIndexMetric[]>([]);
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 (
<TableRow key={index}>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
{isNotIncluded ? (
<Checkbox
checked={selectedIndexes.some((selected) => selected.index === item.index)}
onChange={(_, data) => handleCheckboxChange(item, data.checked === true)}
/>
) : isHeader && item.index === "Not Included in Current Policy" && notIncluded.length > 0 ? (
<Checkbox checked={selectAll} onChange={(_, data) => handleSelectAll(data.checked === true)} />
) : (
<div className={style.indexAdvisorCheckboxSpacer}></div>
)}
{isHeader ? (
<span
style={{ cursor: "pointer" }}
onClick={() => {
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 ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)
) : showNotIncluded ? (
<ChevronDown20Regular />
) : (
<ChevronRight20Regular />
)}
</span>
) : (
<div className={style.indexAdvisorChevronSpacer}></div>
)}
<div className={isHeader ? style.indexAdvisorRowBold : style.indexAdvisorRowNormal}>{item.index}</div>
<div className={isHeader ? style.indexAdvisorRowImpactHeader : style.indexAdvisorRowImpact}>
{!isHeader && item.impact}
</div>
<div>{!isHeader && renderImpactDots(item.impact)}</div>
</div>
</TableCell>
</TableRow>
);
};
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 (
<div>
<Spinner
size="small"
style={
{
"--spinner-size": "16px",
"--spinner-thickness": "2px",
"--spinner-color": "#0078D4",
} as React.CSSProperties
}
/>
</div>
);
}
return (
<div>
<div className={style.indexAdvisorMessage}>
{updateMessageShown ? (
<>
<span className={style.indexAdvisorSuccessIcon}>
<FontIcon iconName="CheckMark" style={{ color: "white", fontSize: 12 }} />
</span>
<span>
Your indexing policy has been updated with the new included paths. You may review the changes in Scale &
Settings.
</span>
</>
) : (
<>
<span>
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.{" "}
<a href={indexingMetricsDocLink} target="_blank" rel="noopener noreferrer">
Learn more about Indexing Metrics
</a>
.{" "}
</span>
</>
)}
</div>
<div className={style.indexAdvisorTitle}>Indexes analysis</div>
<Table className={style.indexAdvisorTable}>
<TableHeader>
<TableRow>
<TableCell colSpan={2}>
<div className={style.indexAdvisorGrid}>
<div className={style.indexAdvisorCheckboxSpacer}></div>
<div className={style.indexAdvisorChevronSpacer}></div>
<div>Index</div>
<div>
<span style={{ whiteSpace: "nowrap" }}>Estimated Impact</span>
</div>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{indexMetricItems.map(renderRow)}</TableBody>
</Table>
{selectedIndexes.length > 0 && (
<div className={style.indexAdvisorButtonBar}>
{isUpdating ? (
<div className={style.indexAdvisorButtonSpinner}>
<Spinner size="tiny" />{" "}
</div>
) : (
<button onClick={handleUpdatePolicy} className={style.indexAdvisorButton}>
Update Indexing Policy with selected index(es)
</button>
)}
</div>
)}
</div>
);
};
export const ResultsView: React.FC<ResultsViewProps> = ({
isMongoDB,
queryResults,
executeQueryDocumentsPage,
queryEditorContent,
databaseId,
containerId,
}) => {
export const ResultsView: React.FC<ResultsViewProps> = ({ isMongoDB, queryResults, executeQueryDocumentsPage }) => {
const styles = useQueryTabStyles();
const [activeTab, setActiveTab] = useState<ResultsTabs>(ResultsTabs.Results);
const onTabSelect = useCallback((event: SelectTabEvent, data: SelectTabData) => {
setActiveTab(data.value as ResultsTabs);
}, []);
return (
<div data-test="QueryTab/ResultsPane/ResultsView" className={styles.queryResultsTabPanel}>
<TabList selectedValue={activeTab} onTabSelect={onTabSelect}>
@@ -885,13 +548,6 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
>
Query Stats
</Tab>
<Tab
data-test="QueryTab/ResultsPane/ResultsView/IndexAdvisorTab"
id={ResultsTabs.IndexAdvisor}
value={ResultsTabs.IndexAdvisor}
>
Index Advisor
</Tab>
</TabList>
<div className={styles.queryResultsTabContentContainer}>
{activeTab === ResultsTabs.Results && (
@@ -902,30 +558,7 @@ export const ResultsView: React.FC<ResultsViewProps> = ({
/>
)}
{activeTab === ResultsTabs.QueryStats && <QueryStatsTab queryResults={queryResults} />}
{activeTab === ResultsTabs.IndexAdvisor && (
<IndexAdvisorTab
queryResults={queryResults}
queryEditorContent={queryEditorContent}
databaseId={databaseId}
containerId={containerId}
/>
)}
</div>
</div>
);
};
export interface IndexingPolicyStore {
indexingPolicies: { [containerId: string]: IndexingPolicy };
setIndexingPolicyFor: (containerId: string, indexingPolicy: IndexingPolicy) => void;
}
export const useIndexingPolicyStore = create<IndexingPolicyStore>((set) => ({
indexingPolicies: {},
setIndexingPolicyFor: (containerId, indexingPolicy) =>
set((state) => ({
indexingPolicies: {
...state.indexingPolicies,
[containerId]: { ...indexingPolicy },
},
})),
}));

View File

@@ -1,95 +0,0 @@
import { makeStyles } from "@fluentui/react-components";
export type IndexAdvisorStyles = ReturnType<typeof useIndexAdvisorStyles>;
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",
},
},
});

View File

@@ -1,15 +0,0 @@
import create from "zustand";
interface QueryMetadataStore {
userQuery: string;
databaseId: string;
containerId: string;
setMetadata: (query1: string, db: string, container: string) => void;
}
export const useQueryMetadataStore = create<QueryMetadataStore>((set) => ({
userQuery: "",
databaseId: "",
containerId: "",
setMetadata: (query1, db, container) => set({ userQuery: query1, databaseId: db, containerId: container }),
}));

View File

@@ -435,6 +435,7 @@ export default class StoredProcedureTabComponent extends React.Component<
});
useCommandBar.getState().setContextButtons(this.getTabsButtons());
}, 100);
return createdResource;
},
(createError) => {

View File

@@ -128,6 +128,7 @@ const App = (): JSX.Element => {
<>
<ContainerCopyPanel explorer={explorer} />
<SidePanel />
<Dialog />
</>
) : (
<DivExplorer explorer={explorer} />

View File

@@ -378,9 +378,7 @@ 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 */

View File

@@ -1,493 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../fx";
import { createMultipleTestContainers } from "../testData";
let page: Page;
let wrapper: Locator = null!;
let panel: Locator = null!;
let frame: Frame = null!;
let expectedCopyJobNameInitial: string = null!;
let expectedJobName: string = "";
let targetAccountName: string = "";
let expectedSourceAccountName: string = "";
let expectedSubscriptionName: string = "";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe.configure({ mode: "serial" });
test.describe("Container Copy", () => {
test.beforeAll("Container Copy - Before All", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Container Copy - After Each", async () => {
await page.unroute(/.*/, (route) => route.continue());
});
test("Loading and verifying the content of the page", async () => {
expect(wrapper).not.toBeNull();
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
});
test("Successfully create a copy job for offline migration", async () => {
expect(wrapper).not.toBeNull();
// Loading and verifying subscription & account dropdown
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await page.waitForTimeout(10 * 1000);
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Load account dropdown based on selected subscription
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Verifying online or offline checkbox functionality
/**
* This test verifies the functionality of the migration type checkbox that toggles between
* online and offline container copy modes. It ensures that:
* 1. When online mode is selected, the user is directed to a permissions screen
* 2. When offline mode is selected, the user bypasses the permissions screen
* 3. The UI correctly reflects the selected migration type throughout the workflow
*/
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Previous" }).click();
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Verifying source and target container selection
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Reselect target container to be different from source container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verifying the preview of the copy job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Testing API request interception with duplicate job name
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Testing API request success with valid job name and verifying copy job creation
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
await expect(panel).not.toBeVisible({ timeout: 10000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible" });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible" });
await expect(jobItem).toBeVisible();
});
test("Verify Online or Offline Container Copy Permissions Panel", async () => {
expect(wrapper).not.toBeNull();
// Opening the Create Copy Job panel again to verify initial state
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// select different account dropdown
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verifying Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Verify Point-in-Time Restore timer and refresh button workflow
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click();
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible();
await expect(pitrBtn).not.toBeVisible();
// Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
// Get the actual response and merge with mock data
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
await expect(permissionScreen).toBeVisible();
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click();
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click();
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
await panel.getByRole("button", { name: "Cancel" }).click();
});
test.afterAll("Container Copy - After All", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
});

View File

@@ -0,0 +1,251 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
interceptAndInspectApiRequest,
TestAccount,
waitForApiResponse,
} from "../../fx";
import { createMultipleTestContainers } from "../../testData";
test.describe("Container Copy - Offline Migration", () => {
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let expectedJobName: string;
let targetAccountName: string;
let expectedSubscriptionName: string;
let expectedCopyJobNameInitial: string;
test.beforeEach("Setup for offline migration test", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `offline_test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after offline migration test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test("Successfully create and manage offline migration copy job", async () => {
expect(wrapper).not.toBeNull();
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
// Open Create Copy Job panel
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
// Reduced wait time for better performance
await page.waitForTimeout(2000);
// Setup subscription and account
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
const subscriptionItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedSubscriptionName },
{ ariaLabel: "Subscription" },
);
await subscriptionItem.click();
// Select account
const accountDropdown = panel.getByTestId("account-dropdown");
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
await accountDropdown.click();
const accountItem = await getDropdownItemByNameOrPosition(
frame,
{ name: expectedAccountName },
{ ariaLabel: "Account" },
);
await accountItem.click();
// Test offline migration mode toggle functionality
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
// First test online mode (should show permissions screen)
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
// Go back and switch to offline mode
await panel.getByRole("button", { name: "Previous" }).click();
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify we skip permissions screen in offline mode
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
// Test source and target container selection with validation
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
expect(sourceContainerDropdown).toBeVisible();
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Select source database first (containers are disabled until database is selected)
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
// Now container dropdown should be enabled
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
// Test target container selection
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
expect(targetContainerDropdown).toBeVisible();
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await targetContainerDropdown.click();
// First try selecting the same container (should show error)
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem1.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify validation error for same source and target containers
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
// Select different target container
await targetContainerDropdown.click();
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem2.click();
// Generate expected job name based on selections
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
const selectedSourceContainer = await sourceContainerDropdown.innerText();
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
const selectedTargetContainer = await targetContainerDropdown.innerText();
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
selectedSourceContainer,
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
await panel.getByRole("button", { name: "Next" }).click();
// Error should disappear and preview should be visible
await expect(errorContainer).not.toBeVisible();
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
// Verify job preview details
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer).toBeVisible();
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Test invalid job name validation (spaces not allowed)
await jobNameInput.fill("test job name");
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
// Test duplicate job name error handling
const duplicateJobName = "test-job-name-1";
await jobNameInput.fill(duplicateJobName);
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
await interceptAndInspectApiRequest(
page,
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
"PUT",
new Error(expectedErrorMessage),
(url?: string) => url?.includes(duplicateJobName) ?? false,
);
let errorThrown = false;
try {
await copyButton.click();
await page.waitForTimeout(2000);
} catch (error: any) {
errorThrown = true;
expect(error.message).toContain("not allowed");
}
if (!errorThrown) {
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
await expect(errorContainer).toBeVisible();
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
}
await expect(panel).toBeVisible();
// Test successful job creation with valid job name
const validJobName = expectedJobName;
const copyJobCreationPromise = waitForApiResponse(
page,
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
"PUT",
);
await jobNameInput.fill(validJobName);
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
await copyButton.click();
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
// Verify panel closes and job appears in the list
await expect(panel).not.toBeVisible({ timeout: 5000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible", timeout: 5000 });
const jobItem = jobsListContainer.getByText(validJobName);
await jobItem.waitFor({ state: "visible", timeout: 5000 });
await expect(jobItem).toBeVisible();
});
});

View File

@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import {
ContainerCopy,
getAccountName,
getDropdownItemByNameOrPosition,
TestAccount,
waitForApiResponse,
} from "../../fx";
import { createMultipleTestContainers } from "../../testData";
test.describe("Container Copy - Online Migration", () => {
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let targetAccountName: string;
test.beforeEach("Setup for online migration test", async ({ browser }) => {
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after online migration test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test("Successfully create and manage online migration copy job", async () => {
expect(wrapper).not.toBeNull();
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
// Open Create Copy Job panel
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
// Reduced wait time for better performance
await page.waitForTimeout(1000);
// Enable online migration mode
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify permissions screen is shown for online migration
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
// Skip permissions setup and proceed to container selection
await panel.getByRole("button", { name: "Next" }).click();
// Configure source and target containers for online migration
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
await sourceDatabaseDropdown.click();
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await sourceDbDropdownItem.click();
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
await sourceContainerDropdown.click();
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Container" },
);
await sourceContainerDropdownItem.click();
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
await targetDatabaseDropdown.click();
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 0 },
{ ariaLabel: "Database" },
);
await targetDbDropdownItem.click();
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
await targetContainerDropdown.click();
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(
frame,
{ position: 1 },
{ ariaLabel: "Container" },
);
await targetContainerDropdownItem.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify job preview and create the online migration job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
const onlineMigrationJobName = await jobNameInput.inputValue();
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
const copyJobCreationPromise = waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
"PUT",
);
await copyButton.click();
await page.waitForTimeout(1000); // Reduced wait time
const response = await copyJobCreationPromise;
expect(response.ok()).toBe(true);
// Verify panel closes and job appears in the list
await expect(panel).not.toBeVisible({ timeout: 5000 });
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
await jobsListContainer.waitFor({ state: "visible", timeout: 5000 });
let jobRow, statusCell, actionMenuButton;
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await jobRow.waitFor({ state: "visible", timeout: 5000 });
// Verify job status changes to queued state
await expect(statusCell).toContainText(/running|queued|pending/i, { timeout: 5000 });
// Test job lifecycle management through action menu
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
// Test pause functionality
const pauseAction = frame.locator(".ms-ContextualMenu-list button:has-text('Pause')");
await pauseAction.click();
const pauseResponse = await waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`,
"POST",
);
expect(pauseResponse.ok()).toBe(true);
// Verify job status changes to paused
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
await jobRow.waitFor({ state: "visible", timeout: 5000 });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await expect(statusCell).toContainText(/paused/i, { timeout: 5000 });
await page.waitForTimeout(1000);
// Test cancel job functionality
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
// Verify cancellation confirmation dialog
await expect(frame.locator(".ms-Dialog-main")).toBeVisible({ timeout: 2000 });
await expect(frame.locator(".ms-Dialog-main")).toContainText(onlineMigrationJobName);
const cancelDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Cancel");
await expect(cancelDialogButton).toBeVisible();
await cancelDialogButton.click();
await expect(frame.locator(".ms-Dialog-main")).not.toBeVisible();
actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`);
await actionMenuButton.click();
await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click();
const confirmDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Confirm");
await expect(confirmDialogButton).toBeVisible();
await confirmDialogButton.click();
// Verify final job status is cancelled
jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName });
statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']");
await expect(statusCell).toContainText(/cancelled/i, { timeout: 5000 });
});
});

View File

@@ -0,0 +1,267 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { expect, Frame, Locator, Page, test } from "@playwright/test";
import { set } from "lodash";
import { ContainerCopy, getAccountName, TestAccount } from "../../fx";
const VISIBLE_TIMEOUT_MS = 30 * 1000;
test.describe("Container Copy - Permission Screen Verification", () => {
let page: Page;
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let targetAccountName: string;
let expectedSourceAccountName: string;
test.beforeEach("Setup for each test", async ({ browser }) => {
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after each test", async () => {
await page.unroute(/.*/, (route) => route.continue());
await page.close();
});
test("Verify online container copy permissions panel functionality", async () => {
expect(wrapper).not.toBeNull();
// Verify all command bar buttons are visible
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS });
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
await expect(createCopyJobButton).toBeVisible();
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible();
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible();
// Open the Create Copy Job panel
await createCopyJobButton.click();
panel = frame.getByTestId("Panel:Create copy job");
await expect(panel).toBeVisible();
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
// Select a different account for cross-account testing
const accountDropdown = panel.getByTestId("account-dropdown");
await accountDropdown.click();
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
const filteredItems = [];
for (const item of allDropdownItems) {
const testContent = (await item.textContent()) ?? "";
if (testContent.trim() !== targetAccountName.trim()) {
filteredItems.push(item);
}
}
if (filteredItems.length > 0) {
const firstDropdownItem = filteredItems[0];
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
await firstDropdownItem.click();
} else {
throw new Error("No dropdown items available after filtering");
}
// Enable online migration mode
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
await panel.getByRole("button", { name: "Next" }).click();
// Verify Assign Permissions panel for online copy
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
await expect(permissionScreen).toBeVisible();
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
// Setup API mocking for the source account
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
// Verify Point-in-Time Restore functionality
const expandedOnlineAccordionHeader = permissionScreen
.getByTestId("permission-group-container-onlineConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedOnlineAccordionHeader).toBeVisible();
const accordionItem = expandedOnlineAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const accordionPanel = accordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
// Install clock mock and test PITR functionality
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
await expect(pitrBtn).toBeVisible();
await pitrBtn.click();
// Verify new page opens with correct URL pattern
page.context().on("page", async (newPage) => {
const expectedUrlEndPattern = new RegExp(
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
});
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
await expect(loadingOverlay).toBeVisible();
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
await expect(refreshBtn).not.toBeVisible();
// Fast forward time by 11 minutes
await page.clock.fastForward(11 * 60 * 1000);
await expect(refreshBtn).toBeVisible({ timeout: 5000 });
await expect(pitrBtn).not.toBeVisible();
// Setup additional API mocks for role assignments and permissions
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
},
],
}),
});
},
);
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
principalId: "00-11-22-33",
},
properties: {
defaultIdentity: "SystemAssignedIdentity",
backupPolicy: {
type: "Continuous",
},
capabilities: [{ name: "EnableOnlineContainerCopy" }],
},
};
if (route.request().method() === "PATCH") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "Succeeded" }),
});
} else if (route.request().method() === "GET") {
const response = await route.fetch();
const actualData = await response.json();
const mergedData = { ...actualData };
set(mergedData, "identity", mockData.identity);
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mergedData),
});
} else {
await route.continue();
}
});
// Verify cross-account permissions functionality
const expandedCrossAccordionHeader = permissionScreen
.getByTestId("permission-group-container-crossAccountConfigs")
.locator("button[aria-expanded='true']");
await expect(expandedCrossAccordionHeader).toBeVisible();
const crossAccordionItem = expandedCrossAccordionHeader
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
.first();
const crossAccordionPanel = crossAccordionItem
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
.first();
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
await expect(toggleButton).toBeVisible();
await toggleButton.click();
// Verify popover functionality
const popover = frame.locator("[data-test='popover-container']");
await expect(popover).toBeVisible();
const yesButton = popover.getByRole("button", { name: /Yes/i });
const noButton = popover.getByRole("button", { name: /No/i });
await expect(yesButton).toBeVisible();
await expect(noButton).toBeVisible();
await yesButton.click();
// Verify loading states
await expect(loadingOverlay).toBeVisible();
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
// Cancel the panel to clean up
await panel.getByRole("button", { name: "Cancel" }).click();
});
});

View File

@@ -1,18 +1,7 @@
import { expect, test } from "@playwright/test";
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 { DataExplorer, DocumentsTab, TestAccount } from "../fx";
import { retry, setPartitionKeys } from "../testData";
import { documentTestCases } from "./testCases";
let explorer: DataExplorer = null!;
@@ -106,105 +95,3 @@ 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,
});
});
});

View File

@@ -1,145 +0,0 @@
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();
});

View File

@@ -1,108 +0,0 @@
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<void> => {
// 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");
}
};
});

View File

@@ -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 Settings tab
// Click Scale & Settings and open Scale tab
await explorer.openScaleAndSettings(context);
const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab");
await settingsTab.click();
@@ -53,28 +53,4 @@ 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,
},
);
});
});

View File

@@ -1,78 +0,0 @@
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,
},
);
});
});

View File

@@ -1,80 +0,0 @@
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,
});
});
});

View File

@@ -1,82 +0,0 @@
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,
},
);
});
});

View File

@@ -37,35 +37,27 @@ export interface PartitionKey {
value: string | null;
}
export const partitionCount = 4;
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.
export const itemsPerPartition = 100;
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 = createSafeRandomString(32);
const id = crypto.randomBytes(32).toString("base64");
items.push({
id,
partitionKey: `partition_${i}`,
randomData: createSafeRandomString(32),
randomData: crypto.randomBytes(32).toString("base64"),
});
}
}
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 {