mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-10 04:56:56 +00:00
Compare commits
1 Commits
dependabot
...
user/bchou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a26c6ae65 |
4091
package-lock.json
generated
4091
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -173,5 +173,10 @@ export default {
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
dialog: {
|
||||
heading: "",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -155,12 +155,7 @@ export class ComputedPropertiesComponent extends React.Component<
|
||||
</Link>
|
||||
  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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -31,7 +31,6 @@ exports[`ComputedPropertiesComponent renders 1`] = `
|
||||
</Text>
|
||||
<div
|
||||
className="settingsV2Editor"
|
||||
data-test="computed-properties-editor"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -435,6 +435,7 @@ export default class StoredProcedureTabComponent extends React.Component<
|
||||
});
|
||||
useCommandBar.getState().setContextButtons(this.getTabsButtons());
|
||||
}, 100);
|
||||
|
||||
return createdResource;
|
||||
},
|
||||
(createError) => {
|
||||
|
||||
@@ -128,6 +128,7 @@ const App = (): JSX.Element => {
|
||||
<>
|
||||
<ContainerCopyPanel explorer={explorer} />
|
||||
<SidePanel />
|
||||
<Dialog />
|
||||
</>
|
||||
) : (
|
||||
<DivExplorer explorer={explorer} />
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
251
test/sql/containercopy/offlineMigration.spec.ts
Normal file
251
test/sql/containercopy/offlineMigration.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
181
test/sql/containercopy/onlineMigration.spec.ts
Normal file
181
test/sql/containercopy/onlineMigration.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
267
test/sql/containercopy/permissionsScreen.spec.ts
Normal file
267
test/sql/containercopy/permissionsScreen.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user