mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-01-24 04:04:13 +00:00
Compare commits
2 Commits
master
...
users/jawe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8904abf288 | ||
|
|
3d182977d6 |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -201,18 +201,18 @@ jobs:
|
||||
GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN"
|
||||
echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN"
|
||||
# echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
# MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken)
|
||||
# echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN"
|
||||
# echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV
|
||||
- name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list
|
||||
- name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}}
|
||||
|
||||
@@ -7,27 +7,16 @@ import { HttpStatusCodes } from "./Constants";
|
||||
import { logError } from "./Logger";
|
||||
import { sendMessage } from "./MessageHandler";
|
||||
|
||||
export interface HandleErrorOptions {
|
||||
/** Optional redacted error to use for telemetry logging instead of the original error */
|
||||
redactedError?: string | ARMError | Error;
|
||||
}
|
||||
|
||||
export const handleError = (
|
||||
error: string | ARMError | Error,
|
||||
area: string,
|
||||
consoleErrorPrefix?: string,
|
||||
options?: HandleErrorOptions,
|
||||
): void => {
|
||||
export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorCode = error instanceof ARMError ? error.code : undefined;
|
||||
|
||||
// logs error to data explorer console (always shows original, non-redacted message)
|
||||
// logs error to data explorer console
|
||||
const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage;
|
||||
logConsoleError(consoleErrorMessage);
|
||||
|
||||
// logs error to both app insight and kusto (use redacted message if provided)
|
||||
const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage;
|
||||
logError(telemetryErrorMessage, area, errorCode);
|
||||
// logs error to both app insight and kusto
|
||||
logError(errorMessage, area, errorCode);
|
||||
|
||||
// checks for errors caused by firewall and sends them to portal to handle
|
||||
sendNotificationForError(errorMessage, errorCode);
|
||||
|
||||
@@ -44,8 +44,7 @@ export const deleteDocuments = async (
|
||||
documentIds: DocumentId[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<IBulkDeleteResult[]> => {
|
||||
const totalCount = documentIds.length;
|
||||
const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`);
|
||||
const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`);
|
||||
try {
|
||||
const v2Container = await client().database(collection.databaseId).container(collection.id());
|
||||
|
||||
@@ -84,7 +83,11 @@ export const deleteDocuments = async (
|
||||
const flatAllResult = Array.prototype.concat.apply([], allResult);
|
||||
return flatAllResult;
|
||||
} catch (error) {
|
||||
handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`);
|
||||
handleError(
|
||||
error,
|
||||
"DeleteDocuments",
|
||||
`Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { redactSyntaxErrorMessage } from "./queryDocumentsPage";
|
||||
|
||||
/* Typical error to redact looks like this (the message property contains a JSON string with nested structure):
|
||||
{
|
||||
"message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}"
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper to create the nested error structure that matches what the SDK returns
|
||||
const createNestedError = (
|
||||
errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>,
|
||||
activityId: string = "test-activity-id",
|
||||
): { message: string } => {
|
||||
const innerErrorsJson = JSON.stringify({ errors });
|
||||
const innerMessage = `${innerErrorsJson}\r\n${activityId}`;
|
||||
const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage });
|
||||
return { message: outerJson };
|
||||
};
|
||||
|
||||
// Helper to parse the redacted result
|
||||
const parseRedactedResult = (result: { message: string }) => {
|
||||
const outerParsed = JSON.parse(result.message);
|
||||
const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n");
|
||||
const innerErrors = JSON.parse(innerErrorsJson);
|
||||
return { outerParsed, innerErrors, activityIdPart };
|
||||
};
|
||||
|
||||
describe("redactSyntaxErrorMessage", () => {
|
||||
it("should redact SC1001 error message", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{
|
||||
severity: "Error",
|
||||
location: { start: 0, end: 5 },
|
||||
code: "SC1001",
|
||||
message: "Syntax error, incorrect syntax near 'Crazy'.",
|
||||
},
|
||||
],
|
||||
"ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
|
||||
|
||||
expect(outerParsed.code).toBe("BadRequest");
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17");
|
||||
});
|
||||
|
||||
it("should redact SC2001 error message", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{
|
||||
severity: "Error",
|
||||
location: { start: 0, end: 10 },
|
||||
code: "SC2001",
|
||||
message: "Some sensitive syntax error message.",
|
||||
},
|
||||
],
|
||||
"ActivityId: abc123",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result);
|
||||
|
||||
expect(outerParsed.code).toBe("BadRequest");
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(activityIdPart).toContain("ActivityId: abc123");
|
||||
});
|
||||
|
||||
it("should redact multiple errors with SC1001 and SC2001 codes", () => {
|
||||
const error = createNestedError(
|
||||
[
|
||||
{ severity: "Error", code: "SC1001", message: "First error" },
|
||||
{ severity: "Error", code: "SC2001", message: "Second error" },
|
||||
],
|
||||
"ActivityId: xyz",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
expect(innerErrors.errors[1].message).toBe("__REDACTED__");
|
||||
});
|
||||
|
||||
it("should not redact errors with other codes", () => {
|
||||
const error = createNestedError(
|
||||
[{ severity: "Error", code: "SC9999", message: "This should not be redacted." }],
|
||||
"ActivityId: test123",
|
||||
);
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error); // Should return original error unchanged
|
||||
});
|
||||
|
||||
it("should not modify non-BadRequest errors", () => {
|
||||
const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] });
|
||||
const error = {
|
||||
message: JSON.stringify({ code: "NotFound", message: innerMessage }),
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle errors without message property", () => {
|
||||
const error = { code: "BadRequest" };
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle non-object errors", () => {
|
||||
const stringError = "Simple string error";
|
||||
const nullError: null = null;
|
||||
const undefinedError: undefined = undefined;
|
||||
|
||||
expect(redactSyntaxErrorMessage(stringError)).toBe(stringError);
|
||||
expect(redactSyntaxErrorMessage(nullError)).toBe(nullError);
|
||||
expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError);
|
||||
});
|
||||
|
||||
it("should handle malformed JSON in message", () => {
|
||||
const error = {
|
||||
message: "not valid json",
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error);
|
||||
|
||||
expect(result).toBe(error);
|
||||
});
|
||||
|
||||
it("should handle message without ActivityId suffix", () => {
|
||||
const innerErrorsJson = JSON.stringify({
|
||||
errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }],
|
||||
});
|
||||
const error = {
|
||||
message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }),
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as { message: string };
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
});
|
||||
|
||||
it("should preserve other error properties", () => {
|
||||
const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test");
|
||||
const error = {
|
||||
...baseError,
|
||||
statusCode: 400,
|
||||
additionalInfo: "extra data",
|
||||
};
|
||||
|
||||
const result = redactSyntaxErrorMessage(error) as {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
additionalInfo: string;
|
||||
};
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.additionalInfo).toBe("extra data");
|
||||
|
||||
const { innerErrors } = parseRedactedResult(result);
|
||||
expect(innerErrors.errors[0].message).toBe("__REDACTED__");
|
||||
});
|
||||
});
|
||||
@@ -4,51 +4,6 @@ import { getEntityName } from "../DocumentUtility";
|
||||
import { handleError } from "../ErrorHandlingUtils";
|
||||
import { MinimalQueryIterator, nextPage } from "../IteratorUtilities";
|
||||
|
||||
// Redact sensitive information from BadRequest errors with specific codes
|
||||
export const redactSyntaxErrorMessage = (error: unknown): unknown => {
|
||||
const codesToRedact = ["SC1001", "SC2001"];
|
||||
|
||||
try {
|
||||
// Handle error objects with a message property
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const errorObj = error as { code?: string; message?: string };
|
||||
if (typeof errorObj.message === "string") {
|
||||
// Parse the inner JSON from the message
|
||||
const innerJson = JSON.parse(errorObj.message);
|
||||
if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") {
|
||||
const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n");
|
||||
const innerErrorsObj = JSON.parse(innerErrorsJson);
|
||||
if (Array.isArray(innerErrorsObj.errors)) {
|
||||
let modified = false;
|
||||
innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => {
|
||||
if (err.code && codesToRedact.includes(err.code)) {
|
||||
modified = true;
|
||||
return { ...err, message: "__REDACTED__" };
|
||||
}
|
||||
return err;
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
// Reconstruct the message with the redacted content
|
||||
const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`;
|
||||
const redactedError = {
|
||||
...error,
|
||||
message: JSON.stringify({ ...innerJson, message: redactedMessage }),
|
||||
body: undefined as unknown, // Clear body to avoid sensitive data
|
||||
};
|
||||
return redactedError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return the original error
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
export const queryDocumentsPage = async (
|
||||
resourceName: string,
|
||||
documentsIterator: MinimalQueryIterator,
|
||||
@@ -63,12 +18,7 @@ export const queryDocumentsPage = async (
|
||||
logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Redact sensitive information for telemetry while showing original in console
|
||||
const redactedError = redactSyntaxErrorMessage(error);
|
||||
|
||||
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, {
|
||||
redactedError: redactedError as Error,
|
||||
});
|
||||
handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`);
|
||||
throw error;
|
||||
} finally {
|
||||
clearMessage();
|
||||
|
||||
@@ -46,10 +46,6 @@ export type DataExploreMessageV3 =
|
||||
params: {
|
||||
updateType: "created" | "deleted" | "settings";
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: FabricMessageTypes.RestoreContainer;
|
||||
params: [];
|
||||
};
|
||||
export interface GetCosmosTokenMessageOptions {
|
||||
verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace";
|
||||
|
||||
@@ -25,18 +25,7 @@ export default {
|
||||
subscriptionDropdownPlaceholder: "Select a subscription",
|
||||
sourceAccountDropdownLabel: "Account",
|
||||
sourceAccountDropdownPlaceholder: "Select an account",
|
||||
migrationTypeOptions: {
|
||||
offline: {
|
||||
title: "Offline mode",
|
||||
description:
|
||||
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
|
||||
},
|
||||
online: {
|
||||
title: "Online mode",
|
||||
description:
|
||||
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
|
||||
},
|
||||
},
|
||||
migrationTypeCheckboxLabel: "Copy container in offline mode",
|
||||
|
||||
// Select Source and Target Containers Screen
|
||||
selectSourceAndTargetContainersDescription:
|
||||
@@ -184,10 +173,5 @@ export default {
|
||||
Skipped: "Cancelled",
|
||||
Cancelled: "Cancelled",
|
||||
},
|
||||
dialog: {
|
||||
heading: "",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
import { MigrationType } from "./MigrationType";
|
||||
|
||||
jest.mock("../../../../Context/CopyJobContext", () => ({
|
||||
useCopyJobContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MigrationType", () => {
|
||||
const mockSetCopyJobState = jest.fn();
|
||||
|
||||
const defaultContextValue = {
|
||||
copyJobState: {
|
||||
jobName: "",
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
source: {
|
||||
subscription: null as any,
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
target: {
|
||||
subscriptionId: "",
|
||||
account: null as any,
|
||||
databaseId: "",
|
||||
containerId: "",
|
||||
},
|
||||
sourceReadAccessFromTarget: false,
|
||||
},
|
||||
setCopyJobState: mockSetCopyJobState,
|
||||
flow: { currentScreen: "selectAccount" },
|
||||
setFlow: jest.fn(),
|
||||
contextError: "",
|
||||
setContextError: jest.fn(),
|
||||
explorer: {} as any,
|
||||
resetCopyJobState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render migration type component with radio buttons", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeInTheDocument();
|
||||
expect(onlineRadio).toBeInTheDocument();
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render with online mode selected by default", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
|
||||
expect(onlineRadio).toBeChecked();
|
||||
expect(offlineRadio).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should render with offline mode selected when state is offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
|
||||
expect(offlineRadio).toBeChecked();
|
||||
expect(onlineRadio).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Descriptions and Learn More Links", () => {
|
||||
it("should render online description and learn more link when online is selected", () => {
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type-description-online']")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "online copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should render offline description and learn more link when offline is selected", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type-description-offline']")).toBeInTheDocument();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "offline copy jobs",
|
||||
});
|
||||
expect(learnMoreLink).toBeInTheDocument();
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
it("should call setCopyJobState when offline radio button is clicked", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const offlineRadio = screen.getByRole("radio", {
|
||||
name: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
});
|
||||
fireEvent.click(offlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction(defaultContextValue.copyJobState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call setCopyJobState when online radio button is clicked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<MigrationType />);
|
||||
|
||||
const onlineRadio = screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title });
|
||||
fireEvent.click(onlineRadio);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const result = updateFunction({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper ARIA attributes", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
const choiceGroup = screen.getByRole("radiogroup");
|
||||
expect(choiceGroup).toBeInTheDocument();
|
||||
expect(choiceGroup).toHaveAttribute("aria-labelledby", "migrationTypeChoiceGroup");
|
||||
});
|
||||
|
||||
it("should have proper radio button labels", () => {
|
||||
render(<MigrationType />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle undefined migration type gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.offline.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("radio", { name: ContainerCopyMessages.migrationTypeOptions.online.title }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null copyJobState gracefully", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: null,
|
||||
});
|
||||
|
||||
const { container } = render(<MigrationType />);
|
||||
|
||||
expect(container.querySelector("[data-test='migration-type']")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
|
||||
import MarkdownRender from "@nteract/markdown";
|
||||
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
|
||||
|
||||
interface MigrationTypeProps {}
|
||||
const options: IChoiceGroupOption[] = [
|
||||
{
|
||||
key: CopyJobMigrationType.Offline,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
{
|
||||
key: CopyJobMigrationType.Online,
|
||||
text: ContainerCopyMessages.migrationTypeOptions.online.title,
|
||||
styles: { root: { width: "33%" } },
|
||||
},
|
||||
];
|
||||
|
||||
const choiceGroupStyles = {
|
||||
flexContainer: { display: "flex" as const },
|
||||
root: {
|
||||
selectors: {
|
||||
".ms-ChoiceField": {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationType: React.FC<MigrationTypeProps> = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
const handleChange = (_ev?: React.FormEvent, option?: IChoiceGroupOption) => {
|
||||
if (option) {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: option.key as CopyJobMigrationType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedKey = copyJobState?.migrationType ?? "";
|
||||
const selectedKeyLowercase = selectedKey.toLowerCase() as keyof typeof ContainerCopyMessages.migrationTypeOptions;
|
||||
const selectedKeyContent = ContainerCopyMessages.migrationTypeOptions[selectedKeyLowercase];
|
||||
|
||||
return (
|
||||
<Stack data-test="migration-type" className="migrationTypeContainer">
|
||||
<Stack.Item>
|
||||
<ChoiceGroup
|
||||
selectedKey={selectedKey}
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
ariaLabelledBy="migrationTypeChoiceGroup"
|
||||
styles={choiceGroupStyles}
|
||||
/>
|
||||
</Stack.Item>
|
||||
{selectedKeyContent && (
|
||||
<Stack.Item styles={{ root: { marginTop: 10 } }}>
|
||||
<Text
|
||||
variant="small"
|
||||
className="migrationTypeDescription"
|
||||
data-test={`migration-type-description-${selectedKeyLowercase}`}
|
||||
>
|
||||
<MarkdownRender source={selectedKeyContent.description} linkTarget="_blank" />
|
||||
</Text>
|
||||
</Stack.Item>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
|
||||
|
||||
describe("MigrationTypeCheckbox", () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
it("should render with default props (unchecked state)", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render in checked state", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should display the correct label text", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
const label = screen.getByText("Copy container in offline mode");
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have correct accessibility attributes when checked", () => {
|
||||
render(<MigrationTypeCheckbox checked={true} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(checkbox).toHaveAttribute("checked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FluentUI Integration", () => {
|
||||
it("should render FluentUI Checkbox component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toHaveAttribute("type", "checkbox");
|
||||
});
|
||||
|
||||
it("should render FluentUI Stack component correctly", () => {
|
||||
render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = document.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply FluentUI Stack horizontal alignment correctly", () => {
|
||||
const { container } = render(<MigrationTypeCheckbox checked={false} onChange={mockOnChange} />);
|
||||
|
||||
const stackContainer = container.querySelector(".migrationTypeRow");
|
||||
expect(stackContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* eslint-disable react/display-name */
|
||||
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
|
||||
|
||||
interface MigrationTypeCheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
|
||||
}
|
||||
|
||||
const checkboxStyles: ICheckboxStyles = {
|
||||
text: { color: "var(--colorNeutralForeground1)" },
|
||||
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
|
||||
root: {
|
||||
selectors: {
|
||||
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MigrationTypeCheckbox: React.FC<MigrationTypeCheckboxProps> = React.memo(({ checked, onChange }) => {
|
||||
return (
|
||||
<Stack horizontal horizontalAlign="space-between" className="migrationTypeRow" data-test="migration-type-checkbox">
|
||||
<Checkbox
|
||||
label={ContainerCopyMessages.migrationTypeCheckboxLabel}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
styles={checkboxStyles}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationType Component Rendering should render migration type component with radio buttons 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ms-Stack migrationTypeContainer css-109"
|
||||
data-test="migration-type"
|
||||
>
|
||||
<div
|
||||
class="ms-StackItem css-110"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup root-111"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="migrationTypeChoiceGroup"
|
||||
role="radiogroup"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceFieldGroup-flexContainer flexContainer-112"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-offline"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field field-115"
|
||||
for="ChoiceGroup0-offline"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-offline"
|
||||
>
|
||||
Offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-ChoiceField root-113"
|
||||
>
|
||||
<div
|
||||
class="ms-ChoiceField-wrapper"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="ms-ChoiceField-input input-114"
|
||||
id="ChoiceGroup0-online"
|
||||
name="ChoiceGroup0"
|
||||
type="radio"
|
||||
/>
|
||||
<label
|
||||
class="ms-ChoiceField-field is-checked field-120"
|
||||
for="ChoiceGroup0-online"
|
||||
>
|
||||
<span
|
||||
class="ms-ChoiceFieldLabel"
|
||||
id="ChoiceGroupLabel1-online"
|
||||
>
|
||||
Online mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="ms-StackItem css-123"
|
||||
>
|
||||
<span
|
||||
class="migrationTypeDescription css-124"
|
||||
data-test="migration-type-description-online"
|
||||
>
|
||||
<div
|
||||
class="markdown-body "
|
||||
>
|
||||
<p>
|
||||
Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview"
|
||||
target="_blank"
|
||||
>
|
||||
All Versions and Delete
|
||||
</a>
|
||||
change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about
|
||||
<a
|
||||
href="https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started"
|
||||
target="_blank"
|
||||
>
|
||||
online copy jobs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,82 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render in checked state 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-checked is-enabled root-119"
|
||||
>
|
||||
<input
|
||||
checked=""
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-1"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-1"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-120"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-122"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MigrationTypeCheckbox Component Rendering should render with default props (unchecked state) 1`] = `
|
||||
<div
|
||||
class="ms-Stack migrationTypeRow css-109"
|
||||
data-test="migration-type-checkbox"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox is-enabled root-110"
|
||||
>
|
||||
<input
|
||||
class="input-111"
|
||||
data-ktp-execute-target="true"
|
||||
id="checkbox-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
class="ms-Checkbox-label label-112"
|
||||
for="checkbox-0"
|
||||
>
|
||||
<div
|
||||
class="ms-Checkbox-checkbox checkbox-113"
|
||||
data-ktp-target="true"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="ms-Checkbox-checkmark checkmark-118"
|
||||
data-icon-name="CheckMark"
|
||||
>
|
||||
|
||||
</i>
|
||||
</div>
|
||||
<span
|
||||
class="ms-Checkbox-text text-115"
|
||||
>
|
||||
Copy container in offline mode
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
@@ -18,8 +18,19 @@ jest.mock("./Components/AccountDropdown", () => ({
|
||||
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</div>),
|
||||
}));
|
||||
|
||||
jest.mock("./Components/MigrationType", () => ({
|
||||
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
|
||||
jest.mock("./Components/MigrationTypeCheckbox", () => ({
|
||||
MigrationTypeCheckbox: jest.fn(({ checked, onChange }: { checked: boolean; onChange: () => void }) => (
|
||||
<div data-testid="migration-type-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
data-testid="migration-checkbox-input"
|
||||
aria-label="Migration Type Checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
describe("SelectAccount", () => {
|
||||
@@ -72,7 +83,7 @@ describe("SelectAccount", () => {
|
||||
|
||||
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correctly with snapshot", () => {
|
||||
@@ -82,20 +93,78 @@ describe("SelectAccount", () => {
|
||||
});
|
||||
|
||||
describe("Migration Type Functionality", () => {
|
||||
it("should render migration type component", () => {
|
||||
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const migrationTypeComponent = screen.getByTestId("migration-type");
|
||||
expect(migrationTypeComponent).toBeInTheDocument();
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should display migration type checkbox as checked when migrationType is Offline", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it("should call setCopyJobState with Online migration type when checkbox is unchecked", () => {
|
||||
(useCopyJobContext as jest.Mock).mockReturnValue({
|
||||
...defaultContextValue,
|
||||
copyJobState: {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
},
|
||||
});
|
||||
|
||||
render(<SelectAccount />);
|
||||
|
||||
const checkbox = screen.getByTestId("migration-checkbox-input");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockSetCopyJobState).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
const updateFunction = mockSetCopyJobState.mock.calls[0][0];
|
||||
const previousState = {
|
||||
...defaultContextValue.copyJobState,
|
||||
migrationType: CopyJobMigrationType.Offline,
|
||||
};
|
||||
const result = updateFunction(previousState);
|
||||
|
||||
expect(result).toEqual({
|
||||
...previousState,
|
||||
migrationType: CopyJobMigrationType.Online,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Optimization", () => {
|
||||
it("should render without performance issues", () => {
|
||||
it("should maintain referential equality of handler functions between renders", async () => {
|
||||
const { rerender } = render(<SelectAccount />);
|
||||
|
||||
const migrationCheckbox = (await import("./Components/MigrationTypeCheckbox")).MigrationTypeCheckbox as jest.Mock;
|
||||
const firstRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
rerender(<SelectAccount />);
|
||||
|
||||
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
|
||||
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
|
||||
|
||||
expect(firstRenderHandler).toBe(secondRenderHandler);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { Stack, Text } from "@fluentui/react";
|
||||
import React from "react";
|
||||
import ContainerCopyMessages from "../../../ContainerCopyMessages";
|
||||
import { useCopyJobContext } from "../../../Context/CopyJobContext";
|
||||
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
|
||||
import { AccountDropdown } from "./Components/AccountDropdown";
|
||||
import { MigrationType } from "./Components/MigrationType";
|
||||
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
|
||||
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
|
||||
|
||||
const SelectAccount = React.memo(() => {
|
||||
const { copyJobState, setCopyJobState } = useCopyJobContext();
|
||||
|
||||
const handleMigrationTypeChange = (_ev?: React.FormEvent<HTMLElement>, checked?: boolean) => {
|
||||
setCopyJobState((prevState) => ({
|
||||
...prevState,
|
||||
migrationType: checked ? CopyJobMigrationType.Offline : CopyJobMigrationType.Online,
|
||||
}));
|
||||
};
|
||||
|
||||
const migrationTypeChecked = copyJobState?.migrationType === CopyJobMigrationType.Offline;
|
||||
|
||||
return (
|
||||
<Stack data-test="Panel:SelectAccountContainer" className="selectAccountContainer" tokens={{ childrenGap: 15 }}>
|
||||
<Text className="themeText">{ContainerCopyMessages.selectAccountDescription}</Text>
|
||||
@@ -14,7 +27,7 @@ const SelectAccount = React.memo(() => {
|
||||
|
||||
<AccountDropdown />
|
||||
|
||||
<MigrationType />
|
||||
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,9 +21,14 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
|
||||
Account Dropdown
|
||||
</div>
|
||||
<div
|
||||
data-testid="migration-type"
|
||||
data-testid="migration-type-checkbox"
|
||||
>
|
||||
Migration Type
|
||||
<input
|
||||
aria-label="Migration Type Checkbox"
|
||||
data-testid="migration-checkbox-input"
|
||||
type="checkbox"
|
||||
/>
|
||||
Copy container in offline mode
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable jest/no-conditional-expect */
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
@@ -6,20 +5,6 @@ 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: {
|
||||
@@ -33,11 +18,6 @@ jest.mock("../../ContainerCopyMessages", () => ({
|
||||
cancel: "Cancel",
|
||||
complete: "Complete",
|
||||
},
|
||||
dialog: {
|
||||
heading: "Confirm Action",
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -70,9 +50,6 @@ describe("CopyJobActionMenu", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockShowOkCancelModalDialog.mockClear();
|
||||
mockCloseDialog.mockClear();
|
||||
mockOpenDialog.mockClear();
|
||||
});
|
||||
|
||||
describe("Component Rendering", () => {
|
||||
@@ -289,29 +266,7 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("should call handleClick when cancel action is clicked", () => {
|
||||
const job = createMockJob({ Status: CopyJobStatusType.InProgress });
|
||||
|
||||
render(<CopyJobActionMenu job={job} handleClick={mockHandleClick} />);
|
||||
@@ -322,9 +277,6 @@ 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));
|
||||
});
|
||||
|
||||
@@ -342,33 +294,7 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function));
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("should call handleClick when complete action is clicked", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
@@ -382,87 +308,10 @@ 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;
|
||||
@@ -490,13 +339,8 @@ describe("CopyJobActionMenu", () => {
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButtonAfterClick = screen.getByText("Pause").closest("button");
|
||||
const pauseButtonAfterClick = screen.getByText("Pause");
|
||||
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", () => {
|
||||
@@ -516,7 +360,23 @@ describe("CopyJobActionMenu", () => {
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable complete action when job is being updated", () => {
|
||||
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,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
@@ -530,34 +390,8 @@ describe("CopyJobActionMenu", () => {
|
||||
const completeButton = screen.getByText("Complete");
|
||||
fireEvent.click(completeButton);
|
||||
|
||||
// Simulate dialog confirmation to trigger state update
|
||||
const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0];
|
||||
onOkCallback();
|
||||
|
||||
fireEvent.click(actionButton);
|
||||
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
|
||||
expect(completeButtonAfterClick).toBeInTheDocument();
|
||||
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should disable complete action when any other action is being performed", () => {
|
||||
const job = createMockJob({
|
||||
Status: CopyJobStatusType.InProgress,
|
||||
Mode: CopyJobMigrationType.Online,
|
||||
});
|
||||
|
||||
render(<TestComponentWrapper job={job} />);
|
||||
|
||||
const actionButton = screen.getByRole("button", { name: "Actions" });
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const pauseButton = screen.getByText("Pause");
|
||||
fireEvent.click(pauseButton);
|
||||
fireEvent.click(actionButton);
|
||||
|
||||
const completeButtonAfterClick = screen.getByText("Complete").closest("button");
|
||||
expect(completeButtonAfterClick).toBeInTheDocument();
|
||||
expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("Complete")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -628,7 +462,6 @@ 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();
|
||||
@@ -775,129 +608,4 @@ 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,6 +1,5 @@
|
||||
import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react";
|
||||
import { IconButton, IContextualMenuProps } 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";
|
||||
@@ -10,28 +9,6 @@ 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 (
|
||||
@@ -45,22 +22,9 @@ 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;
|
||||
|
||||
const baseItems = [
|
||||
{
|
||||
@@ -68,21 +32,21 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.pause,
|
||||
iconProps: { iconName: "Pause" },
|
||||
onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating,
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.cancel,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.cancel,
|
||||
iconProps: { iconName: "Cancel" },
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel),
|
||||
disabled: isThisJobUpdating,
|
||||
onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel,
|
||||
},
|
||||
{
|
||||
key: CopyJobActions.resume,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.resume,
|
||||
iconProps: { iconName: "Play" },
|
||||
onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating,
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -103,8 +67,8 @@ const CopyJobActionMenu: React.FC<CopyJobActionMenuProps> = ({ job, handleClick
|
||||
key: CopyJobActions.complete,
|
||||
text: ContainerCopyMessages.MonitorJobs.Actions.complete,
|
||||
iconProps: { iconName: "CheckMark" },
|
||||
onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete),
|
||||
disabled: isThisJobUpdating,
|
||||
onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction),
|
||||
disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete,
|
||||
});
|
||||
}
|
||||
return filteredItems;
|
||||
@@ -122,8 +86,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(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }}
|
||||
menuIconProps={{ iconName: "", className: "hidden" }}
|
||||
menuProps={{ items: getMenuItems() }}
|
||||
menuIconProps={{ iconName: "" }}
|
||||
ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
title={ContainerCopyMessages.MonitorJobs.Columns.actions}
|
||||
/>
|
||||
|
||||
@@ -11,17 +11,9 @@ jest.mock("../../Actions/CopyJobActions", () => ({
|
||||
|
||||
jest.mock("./CopyJobColumns", () => ({
|
||||
getColumns: jest.fn(() => [
|
||||
{
|
||||
key: "LastUpdatedTime",
|
||||
name: "Date & time",
|
||||
fieldName: "LastUpdatedTime",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
isResizable: true,
|
||||
},
|
||||
{
|
||||
key: "Name",
|
||||
name: "Job name",
|
||||
name: "Name",
|
||||
fieldName: "Name",
|
||||
minWidth: 140,
|
||||
maxWidth: 300,
|
||||
@@ -173,165 +165,6 @@ describe("CopyJobsList", () => {
|
||||
expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders filter TextField with data-test attribute", () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]');
|
||||
expect(filterTextField).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders search TextField with correct placeholder", () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search jobs...");
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filtering", () => {
|
||||
it("filters jobs by Name when text is entered", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs case-insensitively", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "test job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows all jobs when filter text is empty", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(filterInput, { target: { value: "" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs by Status across all columns", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters jobs by Mode across all columns", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Offline" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no results when filter matches no jobs", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "NonExistentJob" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by partial text match", async () => {
|
||||
render(<CopyJobsList jobs={mockJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Test" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Job 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets pagination when filter changes", async () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: `Test Job ${i + 1}`,
|
||||
}));
|
||||
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
|
||||
|
||||
// Navigate to page 2
|
||||
fireEvent.click(screen.getByLabelText("Go to next page"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Apply filter - should reset to page 1
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Job 1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
// Filtered results show from the beginning
|
||||
expect(screen.getByText("Test Job 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("updates filtered count in pager", async () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`,
|
||||
}));
|
||||
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} pageSize={10} />);
|
||||
|
||||
expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Alpha" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument();
|
||||
// Pager should not be visible since filtered results (5) are less than page size (10)
|
||||
expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pagination", () => {
|
||||
@@ -509,7 +342,7 @@ describe("CopyJobsList", () => {
|
||||
|
||||
describe("Component Props", () => {
|
||||
it("uses default page size when not provided", () => {
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({
|
||||
...mockJobs[0],
|
||||
ID: `job-${i + 1}`,
|
||||
Name: `Test Job ${i + 1}`,
|
||||
@@ -518,7 +351,7 @@ describe("CopyJobsList", () => {
|
||||
render(<CopyJobsList jobs={manyJobs} handleActionClick={mockHandleActionClick} />);
|
||||
|
||||
expect(screen.getByLabelText("Go to next page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes correct props to getColumns function", async () => {
|
||||
@@ -607,33 +440,7 @@ describe("CopyJobsList", () => {
|
||||
render(<CopyJobsList jobs={largeJobsList} handleActionClick={mockHandleActionClick} />);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles filtering with null or undefined values gracefully", async () => {
|
||||
const jobsWithNullValues: CopyJobType[] = [
|
||||
{
|
||||
...mockJobs[0],
|
||||
ID: "job-with-values",
|
||||
Name: "Valid Job",
|
||||
},
|
||||
{
|
||||
...mockJobs[1],
|
||||
ID: "job-null-name",
|
||||
Name: undefined as unknown as string,
|
||||
},
|
||||
];
|
||||
|
||||
expect(() => {
|
||||
render(<CopyJobsList jobs={jobsWithNullValues} handleActionClick={mockHandleActionClick} />);
|
||||
}).not.toThrow();
|
||||
|
||||
const filterInput = screen.getByPlaceholderText("Search jobs...");
|
||||
fireEvent.change(filterInput, { target: { value: "Valid" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Valid Job")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,8 @@ import {
|
||||
Stack,
|
||||
Sticky,
|
||||
StickyPositionType,
|
||||
TextField,
|
||||
} from "@fluentui/react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import Pager from "../../../../Common/Pager";
|
||||
import { useThemeStore } from "../../../../hooks/useTheme";
|
||||
import { getThemeTokens } from "../../../Theme/ThemeUtil";
|
||||
@@ -31,15 +30,9 @@ interface CopyJobsListProps {
|
||||
const styles = {
|
||||
container: { height: "100%" } as React.CSSProperties,
|
||||
stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties,
|
||||
filterContainer: {
|
||||
margin: "15px 5px",
|
||||
},
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
// Columns to search across
|
||||
const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => {
|
||||
const isDarkMode = useThemeStore((state) => state.isDarkMode);
|
||||
@@ -48,23 +41,6 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
const [sortedJobs, setSortedJobs] = React.useState<CopyJobType[]>(jobs);
|
||||
const [sortedColumnKey, setSortedColumnKey] = React.useState<string | undefined>(undefined);
|
||||
const [isSortedDescending, setIsSortedDescending] = React.useState<boolean>(false);
|
||||
const [filterText, setFilterText] = React.useState<string>("");
|
||||
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!filterText) {
|
||||
return sortedJobs;
|
||||
}
|
||||
const lowerFilterText = filterText.toLowerCase();
|
||||
return sortedJobs.filter((job: any) => {
|
||||
return searchableFields.some((field) => {
|
||||
const value = job[field];
|
||||
if (value === undefined || value === null) {
|
||||
return false;
|
||||
}
|
||||
return String(value).toLowerCase().includes(lowerFilterText);
|
||||
});
|
||||
});
|
||||
}, [sortedJobs, filterText]);
|
||||
|
||||
useEffect(() => {
|
||||
setSortedJobs(jobs);
|
||||
@@ -88,15 +64,7 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
setStartIndex(0);
|
||||
};
|
||||
|
||||
const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
|
||||
|
||||
const handleFilterTextChange = (
|
||||
_event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
newValue?: string,
|
||||
) => {
|
||||
setFilterText(newValue || "");
|
||||
setStartIndex(0);
|
||||
};
|
||||
const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending);
|
||||
|
||||
const _handleRowClick = (job: CopyJobType) => {
|
||||
openCopyJobDetailsPanel(job);
|
||||
@@ -113,25 +81,14 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<Stack verticalFill={true}>
|
||||
<Stack.Item>
|
||||
<div style={styles.filterContainer}>
|
||||
<TextField
|
||||
data-test="CopyJobsList/FilterTextField"
|
||||
placeholder="Search jobs..."
|
||||
ariaLabel="Search jobs"
|
||||
value={filterText}
|
||||
onChange={handleFilterTextChange}
|
||||
/>
|
||||
</div>
|
||||
</Stack.Item>
|
||||
<Stack.Item verticalFill={true} grow={1} shrink={1} style={styles.stackItem}>
|
||||
<ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
|
||||
<ShimmeredDetailsList
|
||||
className="CopyJobListContainer"
|
||||
onRenderRow={_onRenderRow}
|
||||
checkboxVisibility={2}
|
||||
columns={sortableColumns}
|
||||
items={filteredJobs.slice(startIndex, startIndex + pageSize)}
|
||||
columns={columns}
|
||||
items={sortedJobs.slice(startIndex, startIndex + pageSize)}
|
||||
enableShimmer={false}
|
||||
constrainMode={ConstrainMode.unconstrained}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
@@ -160,12 +117,12 @@ const CopyJobsList: React.FC<CopyJobsListProps> = ({ jobs, handleActionClick, pa
|
||||
/>
|
||||
</ScrollablePane>
|
||||
</Stack.Item>
|
||||
{filteredJobs.length > pageSize && (
|
||||
{sortedJobs.length > pageSize && (
|
||||
<Stack.Item>
|
||||
<Pager
|
||||
disabled={false}
|
||||
startIndex={startIndex}
|
||||
totalCount={filteredJobs.length}
|
||||
totalCount={sortedJobs.length}
|
||||
pageSize={pageSize}
|
||||
onLoadPage={(startIdx /* pageSize */) => {
|
||||
setStartIndex(startIdx);
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
@import "../../../less/Common/Constants.less";
|
||||
|
||||
.themedTextFieldStyles() {
|
||||
.ms-TextField {
|
||||
.ms-TextField-fieldGroup {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
border-color: var(--colorNeutralStroke1);
|
||||
}
|
||||
|
||||
.ms-TextField-field {
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--colorNeutralForeground4);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-Label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common theme-aware classes
|
||||
.themeText {
|
||||
color: var(--colorNeutralForeground1);
|
||||
@@ -141,14 +119,23 @@
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.themedTextFieldStyles();
|
||||
|
||||
.migrationTypeDescription {
|
||||
p {
|
||||
color: var(--colorNeutralForeground1);
|
||||
.ms-TextField {
|
||||
.ms-TextField-fieldGroup {
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
border-color: var(--colorNeutralStroke1);
|
||||
}
|
||||
a {
|
||||
color: var(--colorBrandForeground1);
|
||||
|
||||
.ms-TextField-field {
|
||||
color: var(--colorNeutralForeground1);
|
||||
background-color: var(--colorNeutralBackground1);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--colorNeutralForeground4);
|
||||
}
|
||||
}
|
||||
|
||||
.ms-Label {
|
||||
color: var(--colorNeutralForeground1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,11 +165,6 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
body.isDarkMode & {
|
||||
.themedTextFieldStyles();
|
||||
}
|
||||
|
||||
.ms-DetailsList {
|
||||
width: 100%;
|
||||
|
||||
@@ -199,9 +181,6 @@
|
||||
background-color: var(--colorNeutralBackground3);
|
||||
}
|
||||
}
|
||||
.ms-DetailsHeader-cellTitle {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ms-DetailsRow {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AddGlobalSecondaryIndexPanelProps,
|
||||
} from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel";
|
||||
import { useDatabases } from "Explorer/useDatabases";
|
||||
import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil";
|
||||
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||
import { Action } from "Shared/Telemetry/TelemetryConstants";
|
||||
import { traceOpen } from "Shared/Telemetry/TelemetryProcessor";
|
||||
import { ReactTabKind, useTabs } from "hooks/useTabs";
|
||||
@@ -35,7 +35,6 @@ import StoredProcedure from "./Tree/StoredProcedure";
|
||||
import Trigger from "./Tree/Trigger";
|
||||
import UserDefinedFunction from "./Tree/UserDefinedFunction";
|
||||
import { useSelectedNode } from "./useSelectedNode";
|
||||
import { extractFeatures } from "../Platform/Hosted/extractFeatures";
|
||||
|
||||
export interface CollectionContextMenuButtonParams {
|
||||
databaseId: string;
|
||||
@@ -61,17 +60,6 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin
|
||||
},
|
||||
];
|
||||
|
||||
if (isFabricNative() && !userContext.fabricContext?.isReadOnly) {
|
||||
const features = extractFeatures();
|
||||
if (features?.enableRestoreContainer) {
|
||||
items.push({
|
||||
iconSrc: AddCollectionIcon,
|
||||
onClick: () => openRestoreContainerDialog(),
|
||||
label: `Restore ${getCollectionName()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) {
|
||||
items.push({
|
||||
iconSrc: DeleteDatabaseIcon,
|
||||
|
||||
@@ -54,6 +54,6 @@
|
||||
.mainButtonsContainer {
|
||||
display: flex;
|
||||
gap: 0 16px;
|
||||
margin: 40px auto
|
||||
margin-bottom: 10px
|
||||
}
|
||||
|
||||
@@ -164,23 +164,6 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
const container = explorer;
|
||||
const subscriptions: Array<{ dispose: () => void }> = [];
|
||||
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
|
||||
switch (userContext.apiType) {
|
||||
case "Postgres":
|
||||
title = "Welcome to Azure Cosmos DB for PostgreSQL";
|
||||
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
||||
break;
|
||||
case "VCoreMongo":
|
||||
title = "Welcome to Azure DocumentDB (with MongoDB compatibility)";
|
||||
subtitle = "Get started with our sample datasets, documentation, and additional tools.";
|
||||
break;
|
||||
default:
|
||||
title = "Welcome to Azure Cosmos DB";
|
||||
subtitle = "Globally distributed, multi-model database service for any scale";
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
subscriptions.push(
|
||||
{
|
||||
@@ -919,11 +902,10 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
|
||||
return (
|
||||
<div className={styles.splashScreenContainer}>
|
||||
<div className={styles.splashScreen}>
|
||||
<h2 className={styles.title} role="heading" aria-label={title}>
|
||||
{title}
|
||||
<span className="activePatch"></span>
|
||||
<h2 className={styles.title} role="heading" aria-label="Welcome to Azure Cosmos DB">
|
||||
Welcome to Azure Cosmos DB<span className="activePatch"></span>
|
||||
</h2>
|
||||
<div className={styles.subtitle}>{subtitle}</div>
|
||||
<div className={styles.subtitle}>Globally distributed, multi-model database service for any scale</div>
|
||||
{getSplashScreenButtons()}
|
||||
{useCarousel.getState().showCoachMark && (
|
||||
<Coachmark
|
||||
|
||||
10
src/Main.tsx
10
src/Main.tsx
@@ -105,12 +105,9 @@ const App = (): JSX.Element => {
|
||||
// Scenario-based health tracking: start ApplicationLoad and complete phases.
|
||||
const { startScenario, completePhase } = useMetricScenario();
|
||||
React.useEffect(() => {
|
||||
// Only start scenario after config is initialized to avoid race conditions
|
||||
// with message handlers that depend on configContext.platform
|
||||
if (config) {
|
||||
startScenario(MetricScenario.ApplicationLoad);
|
||||
}
|
||||
}, [config, startScenario]);
|
||||
startScenario(MetricScenario.ApplicationLoad);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (explorer) {
|
||||
@@ -131,7 +128,6 @@ const App = (): JSX.Element => {
|
||||
<>
|
||||
<ContainerCopyPanel explorer={explorer} />
|
||||
<SidePanel />
|
||||
<Dialog />
|
||||
</>
|
||||
) : (
|
||||
<DivExplorer explorer={explorer} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
|
||||
import { configContext } from "../ConfigContext";
|
||||
import { Action } from "../Shared/Telemetry/TelemetryConstants";
|
||||
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
|
||||
import { trackEvent } from "../Shared/appInsights";
|
||||
import { userContext } from "../UserContext";
|
||||
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
|
||||
import { scenarioConfigs } from "./MetricScenarioConfigs";
|
||||
@@ -84,13 +83,6 @@ class ScenarioMonitor {
|
||||
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
|
||||
});
|
||||
|
||||
traceMark(Action.MetricsScenario, {
|
||||
event: "scenario_start",
|
||||
scenario,
|
||||
requiredPhases: config.requiredPhases.join(","),
|
||||
timeoutMs: config.timeoutMs,
|
||||
});
|
||||
|
||||
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
|
||||
this.contexts.set(scenario, ctx);
|
||||
}
|
||||
@@ -104,12 +96,6 @@ class ScenarioMonitor {
|
||||
const startMarkName = `scenario_${scenario}_${phase}_start`;
|
||||
performance.mark(startMarkName);
|
||||
ctx.phases.set(phase, { startMarkName });
|
||||
|
||||
traceStart(Action.MetricsScenario, {
|
||||
event: "phase_start",
|
||||
scenario,
|
||||
phase,
|
||||
});
|
||||
}
|
||||
|
||||
completePhase(scenario: MetricScenario, phase: MetricPhase) {
|
||||
@@ -124,22 +110,6 @@ class ScenarioMonitor {
|
||||
phaseCtx.endMarkName = endMarkName;
|
||||
ctx.completed.add(phase);
|
||||
|
||||
const navigationStart = performance.timeOrigin;
|
||||
const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0];
|
||||
const endEntry = performance.getEntriesByName(endMarkName)[0];
|
||||
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
|
||||
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
|
||||
|
||||
traceSuccess(Action.MetricsScenario, {
|
||||
event: "phase_complete",
|
||||
scenario,
|
||||
phase,
|
||||
endTimeISO,
|
||||
durationMs,
|
||||
completedCount: ctx.completed.size,
|
||||
requiredCount: ctx.config.requiredPhases.length,
|
||||
});
|
||||
|
||||
this.tryEmitIfReady(ctx);
|
||||
}
|
||||
|
||||
@@ -163,14 +133,6 @@ class ScenarioMonitor {
|
||||
// Build a snapshot with failure info
|
||||
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
|
||||
|
||||
traceFailure(Action.MetricsScenario, {
|
||||
event: "phase_fail",
|
||||
scenario,
|
||||
phase,
|
||||
failedPhases: Array.from(ctx.failed).join(","),
|
||||
completedPhases: Array.from(ctx.completed).join(","),
|
||||
});
|
||||
|
||||
// Emit unhealthy immediately
|
||||
this.emit(ctx, false, false, failureSnapshot);
|
||||
}
|
||||
@@ -229,22 +191,27 @@ class ScenarioMonitor {
|
||||
// Build snapshot if not provided
|
||||
const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut });
|
||||
|
||||
traceMark(Action.MetricsScenario, {
|
||||
event: "scenario_end",
|
||||
scenario: ctx.scenario,
|
||||
healthy,
|
||||
timedOut,
|
||||
platform,
|
||||
api,
|
||||
durationMs: finalSnapshot.durationMs,
|
||||
completedPhases: finalSnapshot.completed.join(","),
|
||||
failedPhases: finalSnapshot.failedPhases?.join(","),
|
||||
lcp: finalSnapshot.vitals?.lcp,
|
||||
inp: finalSnapshot.vitals?.inp,
|
||||
cls: finalSnapshot.vitals?.cls,
|
||||
fcp: finalSnapshot.vitals?.fcp,
|
||||
ttfb: finalSnapshot.vitals?.ttfb,
|
||||
});
|
||||
// Emit enriched telemetry with performance data
|
||||
// TODO: Call portal backend metrics endpoint
|
||||
trackEvent(
|
||||
{ name: "MetricScenarioComplete" },
|
||||
{
|
||||
scenario: ctx.scenario,
|
||||
healthy: healthy.toString(),
|
||||
timedOut: timedOut.toString(),
|
||||
platform,
|
||||
api,
|
||||
durationMs: finalSnapshot.durationMs.toString(),
|
||||
completedPhases: finalSnapshot.completed.join(","),
|
||||
failedPhases: finalSnapshot.failedPhases?.join(","),
|
||||
lcp: finalSnapshot.vitals?.lcp?.toString(),
|
||||
inp: finalSnapshot.vitals?.inp?.toString(),
|
||||
cls: finalSnapshot.vitals?.cls?.toString(),
|
||||
fcp: finalSnapshot.vitals?.fcp?.toString(),
|
||||
ttfb: finalSnapshot.vitals?.ttfb?.toString(),
|
||||
phaseTimings: JSON.stringify(finalSnapshot.phaseTimings),
|
||||
},
|
||||
);
|
||||
|
||||
// Call portal backend health metrics endpoint
|
||||
if (healthy && !timedOut) {
|
||||
@@ -260,16 +227,9 @@ class ScenarioMonitor {
|
||||
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
|
||||
performance.clearMarks(ctx.startMarkName);
|
||||
ctx.config.requiredPhases.forEach((phase) => {
|
||||
const phaseCtx = ctx.phases.get(phase);
|
||||
if (phaseCtx?.startMarkName) {
|
||||
performance.clearMarks(phaseCtx.startMarkName);
|
||||
}
|
||||
if (phaseCtx?.endMarkName) {
|
||||
performance.clearMarks(phaseCtx.endMarkName);
|
||||
}
|
||||
performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`);
|
||||
performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`);
|
||||
performance.clearMarks(`scenario_${ctx.scenario}_${phase}`);
|
||||
});
|
||||
performance.clearMeasures(`scenario_${ctx.scenario}_total`);
|
||||
}
|
||||
|
||||
private buildSnapshot(
|
||||
|
||||
88
src/NotebookViewer/NotebookViewer.tsx
Normal file
88
src/NotebookViewer/NotebookViewer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { initializeIcons } from "@fluentui/react";
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { configContext, initializeConfiguration } from "../ConfigContext";
|
||||
import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent";
|
||||
import { GalleryTab } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent";
|
||||
import {
|
||||
NotebookViewerComponent,
|
||||
NotebookViewerComponentProps,
|
||||
} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent";
|
||||
import * as FileSystemUtil from "../Explorer/Notebook/FileSystemUtil";
|
||||
import { IGalleryItem, JunoClient } from "../Juno/JunoClient";
|
||||
import * as GalleryUtils from "../Utils/GalleryUtils";
|
||||
|
||||
const onInit = async () => {
|
||||
initializeIcons();
|
||||
await initializeConfiguration();
|
||||
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
|
||||
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search);
|
||||
let backNavigationText: string;
|
||||
let onBackClick: () => void;
|
||||
if (galleryViewerProps.selectedTab !== undefined) {
|
||||
backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
|
||||
onBackClick = () =>
|
||||
(window.location.href = `${configContext.hostedExplorerURL}gallery.html?tab=${
|
||||
GalleryTab[galleryViewerProps.selectedTab]
|
||||
}`);
|
||||
}
|
||||
const hideInputs = notebookViewerProps.hideInputs;
|
||||
|
||||
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);
|
||||
|
||||
const galleryItemId = notebookViewerProps.galleryItemId;
|
||||
let galleryItem: IGalleryItem;
|
||||
|
||||
if (galleryItemId) {
|
||||
const junoClient = new JunoClient();
|
||||
const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId);
|
||||
galleryItem = galleryItemJunoResponse.data;
|
||||
}
|
||||
|
||||
// The main purpose of hiding the prompt is to hide everything when hiding inputs.
|
||||
// It is generally not very useful to just hide the prompt.
|
||||
const hidePrompts = hideInputs;
|
||||
|
||||
render(notebookUrl, backNavigationText, hideInputs, hidePrompts, galleryItem, onBackClick);
|
||||
};
|
||||
|
||||
const render = (
|
||||
notebookUrl: string,
|
||||
backNavigationText: string,
|
||||
hideInputs?: boolean,
|
||||
hidePrompts?: boolean,
|
||||
galleryItem?: IGalleryItem,
|
||||
onBackClick?: () => void,
|
||||
) => {
|
||||
const props: NotebookViewerComponentProps = {
|
||||
junoClient: galleryItem ? new JunoClient() : undefined,
|
||||
notebookUrl,
|
||||
galleryItem,
|
||||
backNavigationText,
|
||||
hideInputs,
|
||||
hidePrompts,
|
||||
onBackClick: onBackClick,
|
||||
onTagClick: undefined,
|
||||
};
|
||||
|
||||
if (galleryItem) {
|
||||
document.title = FileSystemUtil.stripExtension(galleryItem.name, "ipynb");
|
||||
}
|
||||
|
||||
const element = (
|
||||
<>
|
||||
<header>
|
||||
<GalleryHeaderComponent />
|
||||
</header>
|
||||
<div style={{ marginLeft: 120, marginRight: 120 }}>
|
||||
<NotebookViewerComponent {...props} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
ReactDOM.render(element, document.getElementById("notebookContent"));
|
||||
};
|
||||
|
||||
// Entry point
|
||||
window.addEventListener("load", onInit);
|
||||
@@ -105,12 +105,6 @@ const requestAndStoreAccessToken = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
export const openRestoreContainerDialog = (): void => {
|
||||
if (isFabricNative()) {
|
||||
sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check token validity and schedule a refresh if necessary
|
||||
* @param tokenTimestamp
|
||||
|
||||
@@ -40,7 +40,6 @@ export type Features = {
|
||||
readonly disableConnectionStringLogin: boolean;
|
||||
readonly enableContainerCopy: boolean;
|
||||
readonly enableCloudShell: boolean;
|
||||
readonly enableRestoreContainer: boolean; // only for Fabric
|
||||
|
||||
// can be set via both flight and feature flag
|
||||
autoscaleDefault: boolean;
|
||||
@@ -94,7 +93,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
||||
notebookBasePath: get("notebookbasepath"),
|
||||
notebookServerToken: get("notebookservertoken"),
|
||||
notebookServerUrl: get("notebookserverurl"),
|
||||
sandboxNotebookOutputs: true,
|
||||
sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"),
|
||||
selfServeType: get("selfservetype"),
|
||||
showMinRUSurvey: "true" === get("showminrusurvey"),
|
||||
ttl90Days: "true" === get("ttl90days"),
|
||||
@@ -112,7 +111,6 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
|
||||
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
|
||||
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
|
||||
enableContainerCopy: "true" === get("enablecontainercopy"),
|
||||
enableRestoreContainer: "true" === get("enablerestorecontainer"),
|
||||
enableCloudShell: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Some of the enums names are used in Fabric. Please do not rename them.
|
||||
export enum Action {
|
||||
CollapseTreeNode,
|
||||
MetricsScenario,
|
||||
CreateCollection, // Used in Fabric. Please do not rename.
|
||||
CreateGlobalSecondaryIndex,
|
||||
CreateDocument, // Used in Fabric. Please do not rename.
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("AuthorizationUtils", () => {
|
||||
enableKoResourceTree: false,
|
||||
enableThroughputBuckets: false,
|
||||
hostedDataExplorer: false,
|
||||
sandboxNotebookOutputs: true,
|
||||
sandboxNotebookOutputs: false,
|
||||
showMinRUSurvey: false,
|
||||
ttl90Days: false,
|
||||
enableThroughputCap: false,
|
||||
@@ -43,7 +43,6 @@ describe("AuthorizationUtils", () => {
|
||||
partitionKeyDefault: false,
|
||||
partitionKeyDefault2: false,
|
||||
notebooksDownBanner: false,
|
||||
enableRestoreContainer: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
493
test/sql/containercopy.spec.ts
Normal file
493
test/sql/containercopy.spec.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect, Frame, Locator, Page, test } from "@playwright/test";
|
||||
import { set } from "lodash";
|
||||
import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils";
|
||||
import {
|
||||
ContainerCopy,
|
||||
getAccountName,
|
||||
getDropdownItemByNameOrPosition,
|
||||
interceptAndInspectApiRequest,
|
||||
TestAccount,
|
||||
waitForApiResponse,
|
||||
} from "../fx";
|
||||
import { createMultipleTestContainers } from "../testData";
|
||||
|
||||
let page: Page;
|
||||
let wrapper: Locator = null!;
|
||||
let panel: Locator = null!;
|
||||
let frame: Frame = null!;
|
||||
let expectedCopyJobNameInitial: string = null!;
|
||||
let expectedJobName: string = "";
|
||||
let targetAccountName: string = "";
|
||||
let expectedSourceAccountName: string = "";
|
||||
let expectedSubscriptionName: string = "";
|
||||
const VISIBLE_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Container Copy", () => {
|
||||
test.beforeAll("Container Copy - Before All", async ({ browser }) => {
|
||||
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 });
|
||||
|
||||
page = await browser.newPage();
|
||||
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
|
||||
expectedJobName = `test_job_${Date.now()}`;
|
||||
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
|
||||
});
|
||||
|
||||
test.afterEach("Container Copy - After Each", async () => {
|
||||
await page.unroute(/.*/, (route) => route.continue());
|
||||
});
|
||||
|
||||
test("Loading and verifying the content of the page", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
|
||||
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
|
||||
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS });
|
||||
});
|
||||
|
||||
test("Successfully create a copy job for offline migration", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
// Loading and verifying subscription & account dropdown
|
||||
|
||||
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
|
||||
await createCopyJobButton.click();
|
||||
panel = frame.getByTestId("Panel:Create copy job");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(10 * 1000);
|
||||
|
||||
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
|
||||
|
||||
const expectedAccountName = targetAccountName;
|
||||
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
|
||||
|
||||
await subscriptionDropdown.click();
|
||||
const subscriptionItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ name: expectedSubscriptionName },
|
||||
{ ariaLabel: "Subscription" },
|
||||
);
|
||||
await subscriptionItem.click();
|
||||
|
||||
// Load account dropdown based on selected subscription
|
||||
|
||||
const accountDropdown = panel.getByTestId("account-dropdown");
|
||||
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
|
||||
await accountDropdown.click();
|
||||
|
||||
const accountItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ name: expectedAccountName },
|
||||
{ ariaLabel: "Account" },
|
||||
);
|
||||
await accountItem.click();
|
||||
|
||||
// Verifying online or offline checkbox functionality
|
||||
/**
|
||||
* This test verifies the functionality of the migration type checkbox that toggles between
|
||||
* online and offline container copy modes. It ensures that:
|
||||
* 1. When online mode is selected, the user is directed to a permissions screen
|
||||
* 2. When offline mode is selected, the user bypasses the permissions screen
|
||||
* 3. The UI correctly reflects the selected migration type throughout the workflow
|
||||
*/
|
||||
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
|
||||
await fluentUiCheckboxContainer.click();
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
|
||||
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "Previous" }).click();
|
||||
await fluentUiCheckboxContainer.click();
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
|
||||
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
|
||||
|
||||
// Verifying source and target container selection
|
||||
|
||||
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
|
||||
expect(sourceContainerDropdown).toBeVisible();
|
||||
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
|
||||
await sourceDatabaseDropdown.click();
|
||||
|
||||
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await sourceDbDropdownItem.click();
|
||||
|
||||
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
await sourceContainerDropdown.click();
|
||||
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await sourceContainerDropdownItem.click();
|
||||
|
||||
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
|
||||
expect(targetContainerDropdown).toBeVisible();
|
||||
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
|
||||
await targetDatabaseDropdown.click();
|
||||
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await targetDbDropdownItem.click();
|
||||
|
||||
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
await targetContainerDropdown.click();
|
||||
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await targetContainerDropdownItem1.click();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
|
||||
await expect(errorContainer).toBeVisible();
|
||||
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
|
||||
|
||||
// Reselect target container to be different from source container
|
||||
await targetContainerDropdown.click();
|
||||
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 1 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await targetContainerDropdownItem2.click();
|
||||
|
||||
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
|
||||
const selectedSourceContainer = await sourceContainerDropdown.innerText();
|
||||
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
|
||||
const selectedTargetContainer = await targetContainerDropdown.innerText();
|
||||
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
|
||||
selectedSourceContainer,
|
||||
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
await expect(errorContainer).not.toBeVisible();
|
||||
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
|
||||
|
||||
// Verifying the preview of the copy job
|
||||
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
|
||||
await expect(previewContainer).toBeVisible();
|
||||
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
|
||||
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
|
||||
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
|
||||
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
|
||||
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
await jobNameInput.fill("test job name");
|
||||
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
// Testing API request interception with duplicate job name
|
||||
const duplicateJobName = "test-job-name-1";
|
||||
await jobNameInput.fill(duplicateJobName);
|
||||
|
||||
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
|
||||
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
|
||||
await interceptAndInspectApiRequest(
|
||||
page,
|
||||
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
|
||||
"PUT",
|
||||
new Error(expectedErrorMessage),
|
||||
(url?: string) => url?.includes(duplicateJobName) ?? false,
|
||||
);
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
await copyButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
} catch (error: any) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toContain("not allowed");
|
||||
}
|
||||
if (!errorThrown) {
|
||||
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
|
||||
await expect(errorContainer).toBeVisible();
|
||||
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
|
||||
}
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Testing API request success with valid job name and verifying copy job creation
|
||||
|
||||
const validJobName = expectedJobName;
|
||||
|
||||
const copyJobCreationPromise = waitForApiResponse(
|
||||
page,
|
||||
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
|
||||
"PUT",
|
||||
);
|
||||
|
||||
await jobNameInput.fill(validJobName);
|
||||
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
const response = await copyJobCreationPromise;
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
await expect(panel).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
|
||||
await jobsListContainer.waitFor({ state: "visible" });
|
||||
|
||||
const jobItem = jobsListContainer.getByText(validJobName);
|
||||
await jobItem.waitFor({ state: "visible" });
|
||||
await expect(jobItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("Verify Online or Offline Container Copy Permissions Panel", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
// Opening the Create Copy Job panel again to verify initial state
|
||||
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
|
||||
await createCopyJobButton.click();
|
||||
panel = frame.getByTestId("Panel:Create copy job");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
|
||||
|
||||
// select different account dropdown
|
||||
|
||||
const accountDropdown = panel.getByTestId("account-dropdown");
|
||||
await accountDropdown.click();
|
||||
|
||||
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
|
||||
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
|
||||
|
||||
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
|
||||
|
||||
const filteredItems = [];
|
||||
for (const item of allDropdownItems) {
|
||||
const testContent = (await item.textContent()) ?? "";
|
||||
if (testContent.trim() !== targetAccountName.trim()) {
|
||||
filteredItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
const firstDropdownItem = filteredItems[0];
|
||||
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
|
||||
await firstDropdownItem.click();
|
||||
} else {
|
||||
throw new Error("No dropdown items available after filtering");
|
||||
}
|
||||
|
||||
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
|
||||
await fluentUiCheckboxContainer.click();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verifying Assign Permissions panel for online copy
|
||||
|
||||
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
|
||||
await expect(permissionScreen).toBeVisible();
|
||||
|
||||
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
|
||||
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
|
||||
|
||||
// Verify Point-in-Time Restore timer and refresh button workflow
|
||||
|
||||
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
|
||||
const mockData = {
|
||||
identity: {
|
||||
type: "SystemAssigned",
|
||||
principalId: "00-11-22-33",
|
||||
},
|
||||
properties: {
|
||||
defaultIdentity: "SystemAssignedIdentity",
|
||||
backupPolicy: {
|
||||
type: "Continuous",
|
||||
},
|
||||
capabilities: [{ name: "EnableOnlineContainerCopy" }],
|
||||
},
|
||||
};
|
||||
if (route.request().method() === "GET") {
|
||||
const response = await route.fetch();
|
||||
const actualData = await response.json();
|
||||
const mergedData = { ...actualData };
|
||||
|
||||
set(mergedData, "identity", mockData.identity);
|
||||
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
|
||||
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
|
||||
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mergedData),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await expect(permissionScreen).toBeVisible();
|
||||
|
||||
const expandedOnlineAccordionHeader = permissionScreen
|
||||
.getByTestId("permission-group-container-onlineConfigs")
|
||||
.locator("button[aria-expanded='true']");
|
||||
await expect(expandedOnlineAccordionHeader).toBeVisible();
|
||||
|
||||
const accordionItem = expandedOnlineAccordionHeader
|
||||
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
|
||||
.first();
|
||||
|
||||
const accordionPanel = accordionItem
|
||||
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
|
||||
.first();
|
||||
|
||||
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
|
||||
|
||||
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
|
||||
await expect(pitrBtn).toBeVisible();
|
||||
await pitrBtn.click();
|
||||
|
||||
page.context().on("page", async (newPage) => {
|
||||
const expectedUrlEndPattern = new RegExp(
|
||||
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`,
|
||||
);
|
||||
expect(newPage.url()).toMatch(expectedUrlEndPattern);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
const loadingOverlay = frame.locator("[data-test='loading-overlay']");
|
||||
await expect(loadingOverlay).toBeVisible();
|
||||
|
||||
const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn");
|
||||
await expect(refreshBtn).not.toBeVisible();
|
||||
|
||||
// Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms)
|
||||
await page.clock.fastForward(11 * 60 * 1000);
|
||||
|
||||
await expect(refreshBtn).toBeVisible();
|
||||
await expect(pitrBtn).not.toBeVisible();
|
||||
|
||||
// Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions
|
||||
|
||||
await page.route(
|
||||
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
principalId: "00-11-22-33",
|
||||
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
value: [
|
||||
{
|
||||
name: "00000000-0000-0000-0000-000000000001",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
|
||||
const mockData = {
|
||||
identity: {
|
||||
type: "SystemAssigned",
|
||||
principalId: "00-11-22-33",
|
||||
},
|
||||
properties: {
|
||||
defaultIdentity: "SystemAssignedIdentity",
|
||||
backupPolicy: {
|
||||
type: "Continuous",
|
||||
},
|
||||
capabilities: [{ name: "EnableOnlineContainerCopy" }],
|
||||
},
|
||||
};
|
||||
|
||||
if (route.request().method() === "PATCH") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "Succeeded" }),
|
||||
});
|
||||
} else if (route.request().method() === "GET") {
|
||||
// Get the actual response and merge with mock data
|
||||
const response = await route.fetch();
|
||||
const actualData = await response.json();
|
||||
const mergedData = { ...actualData };
|
||||
set(mergedData, "identity", mockData.identity);
|
||||
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
|
||||
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
|
||||
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mergedData),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
await expect(permissionScreen).toBeVisible();
|
||||
|
||||
const expandedCrossAccordionHeader = permissionScreen
|
||||
.getByTestId("permission-group-container-crossAccountConfigs")
|
||||
.locator("button[aria-expanded='true']");
|
||||
await expect(expandedCrossAccordionHeader).toBeVisible();
|
||||
|
||||
const crossAccordionItem = expandedCrossAccordionHeader
|
||||
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
|
||||
.first();
|
||||
|
||||
const crossAccordionPanel = crossAccordionItem
|
||||
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
|
||||
.first();
|
||||
|
||||
const toggleButton = crossAccordionPanel.getByTestId("btn-toggle");
|
||||
await expect(toggleButton).toBeVisible();
|
||||
await toggleButton.click();
|
||||
|
||||
const popover = frame.locator("[data-test='popover-container']");
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const yesButton = popover.getByRole("button", { name: /Yes/i });
|
||||
const noButton = popover.getByRole("button", { name: /No/i });
|
||||
await expect(yesButton).toBeVisible();
|
||||
await expect(noButton).toBeVisible();
|
||||
|
||||
await yesButton.click();
|
||||
|
||||
await expect(loadingOverlay).toBeVisible();
|
||||
|
||||
await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 });
|
||||
await expect(popover).toBeHidden({ timeout: 10 * 1000 });
|
||||
|
||||
await panel.getByRole("button", { name: "Cancel" }).click();
|
||||
});
|
||||
|
||||
test.afterAll("Container Copy - After All", async () => {
|
||||
await page.unroute(/.*/, (route) => route.continue());
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect, Frame, Locator, Page, test } from "@playwright/test";
|
||||
import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils";
|
||||
import {
|
||||
ContainerCopy,
|
||||
getAccountName,
|
||||
getDropdownItemByNameOrPosition,
|
||||
interceptAndInspectApiRequest,
|
||||
TestAccount,
|
||||
waitForApiResponse,
|
||||
} from "../../fx";
|
||||
import { createMultipleTestContainers } from "../../testData";
|
||||
|
||||
test.describe("Container Copy - Offline Migration", () => {
|
||||
let page: Page;
|
||||
let wrapper: Locator;
|
||||
let panel: Locator;
|
||||
let frame: Frame;
|
||||
let expectedJobName: string;
|
||||
let targetAccountName: string;
|
||||
let expectedSubscriptionName: string;
|
||||
let expectedCopyJobNameInitial: string;
|
||||
|
||||
test.beforeEach("Setup for offline migration test", async ({ browser }) => {
|
||||
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
|
||||
|
||||
page = await browser.newPage();
|
||||
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
|
||||
expectedJobName = `offline_test_job_${Date.now()}`;
|
||||
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
|
||||
});
|
||||
|
||||
test.afterEach("Cleanup after offline migration test", async () => {
|
||||
await page.unroute(/.*/, (route) => route.continue());
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("Successfully create and manage offline migration copy job", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
|
||||
|
||||
// Open Create Copy Job panel
|
||||
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
|
||||
await expect(createCopyJobButton).toBeVisible();
|
||||
await createCopyJobButton.click();
|
||||
panel = frame.getByTestId("Panel:Create copy job");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Reduced wait time for better performance
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Setup subscription and account
|
||||
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
|
||||
const expectedAccountName = targetAccountName;
|
||||
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
|
||||
|
||||
await subscriptionDropdown.click();
|
||||
const subscriptionItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ name: expectedSubscriptionName },
|
||||
{ ariaLabel: "Subscription" },
|
||||
);
|
||||
await subscriptionItem.click();
|
||||
|
||||
// Select account
|
||||
const accountDropdown = panel.getByTestId("account-dropdown");
|
||||
await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName));
|
||||
await accountDropdown.click();
|
||||
|
||||
const accountItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ name: expectedAccountName },
|
||||
{ ariaLabel: "Account" },
|
||||
);
|
||||
await accountItem.click();
|
||||
|
||||
// Test offline migration mode toggle functionality
|
||||
const migrationTypeContainer = panel.getByTestId("migration-type");
|
||||
|
||||
// First test online mode (should show permissions screen)
|
||||
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
|
||||
await onlineCopyRadioButton.click({ force: true });
|
||||
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
|
||||
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
|
||||
|
||||
// Go back and switch to offline mode
|
||||
await panel.getByRole("button", { name: "Previous" }).click();
|
||||
|
||||
const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i });
|
||||
await offlineCopyRadioButton.click({ force: true });
|
||||
await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verify we skip permissions screen in offline mode
|
||||
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
|
||||
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
|
||||
|
||||
// Test source and target container selection with validation
|
||||
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
|
||||
expect(sourceContainerDropdown).toBeVisible();
|
||||
await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
// Select source database first (containers are disabled until database is selected)
|
||||
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
|
||||
await sourceDatabaseDropdown.click();
|
||||
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await sourceDbDropdownItem.click();
|
||||
|
||||
// Now container dropdown should be enabled
|
||||
await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
await sourceContainerDropdown.click();
|
||||
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await sourceContainerDropdownItem.click();
|
||||
|
||||
// Test target container selection
|
||||
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
|
||||
expect(targetContainerDropdown).toBeVisible();
|
||||
await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
|
||||
await targetDatabaseDropdown.click();
|
||||
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await targetDbDropdownItem.click();
|
||||
|
||||
await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
await targetContainerDropdown.click();
|
||||
|
||||
// First try selecting the same container (should show error)
|
||||
const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await targetContainerDropdownItem1.click();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verify validation error for same source and target containers
|
||||
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
|
||||
await expect(errorContainer).toBeVisible();
|
||||
await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i);
|
||||
|
||||
// Select different target container
|
||||
await targetContainerDropdown.click();
|
||||
const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 1 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await targetContainerDropdownItem2.click();
|
||||
|
||||
// Generate expected job name based on selections
|
||||
const selectedSourceDatabase = await sourceDatabaseDropdown.innerText();
|
||||
const selectedSourceContainer = await sourceContainerDropdown.innerText();
|
||||
const selectedTargetDatabase = await targetDatabaseDropdown.innerText();
|
||||
const selectedTargetContainer = await targetContainerDropdown.innerText();
|
||||
expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName(
|
||||
selectedSourceContainer,
|
||||
)}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`;
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Error should disappear and preview should be visible
|
||||
await expect(errorContainer).not.toBeVisible();
|
||||
await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible();
|
||||
|
||||
// Verify job preview details
|
||||
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
|
||||
await expect(previewContainer).toBeVisible();
|
||||
await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName);
|
||||
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName);
|
||||
|
||||
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
|
||||
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));
|
||||
|
||||
const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true });
|
||||
await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
// Test invalid job name validation (spaces not allowed)
|
||||
await jobNameInput.fill("test job name");
|
||||
await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
// Test duplicate job name error handling
|
||||
const duplicateJobName = "test-job-name-1";
|
||||
await jobNameInput.fill(duplicateJobName);
|
||||
|
||||
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
|
||||
const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`;
|
||||
|
||||
await interceptAndInspectApiRequest(
|
||||
page,
|
||||
`${expectedAccountName}/dataTransferJobs/${duplicateJobName}`,
|
||||
"PUT",
|
||||
new Error(expectedErrorMessage),
|
||||
(url?: string) => url?.includes(duplicateJobName) ?? false,
|
||||
);
|
||||
|
||||
let errorThrown = false;
|
||||
try {
|
||||
await copyButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
} catch (error: any) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toContain("not allowed");
|
||||
}
|
||||
|
||||
if (!errorThrown) {
|
||||
const errorContainer = panel.getByTestId("Panel:ErrorContainer");
|
||||
await expect(errorContainer).toBeVisible();
|
||||
await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i"));
|
||||
}
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Test successful job creation with valid job name
|
||||
const validJobName = expectedJobName;
|
||||
|
||||
const copyJobCreationPromise = waitForApiResponse(
|
||||
page,
|
||||
`${expectedAccountName}/dataTransferJobs/${validJobName}`,
|
||||
"PUT",
|
||||
);
|
||||
|
||||
await jobNameInput.fill(validJobName);
|
||||
await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/);
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
const response = await copyJobCreationPromise;
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
// Verify panel closes and job appears in the list
|
||||
await expect(panel).not.toBeVisible();
|
||||
|
||||
const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField");
|
||||
await filterTextField.waitFor({ state: "visible" });
|
||||
await filterTextField.fill(validJobName);
|
||||
|
||||
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
|
||||
await jobsListContainer.waitFor({ state: "visible" });
|
||||
|
||||
const jobItem = jobsListContainer.getByText(validJobName);
|
||||
await jobItem.waitFor({ state: "visible" });
|
||||
await expect(jobItem).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect, Frame, Locator, Page, test } from "@playwright/test";
|
||||
import {
|
||||
ContainerCopy,
|
||||
getAccountName,
|
||||
getDropdownItemByNameOrPosition,
|
||||
TestAccount,
|
||||
waitForApiResponse,
|
||||
} from "../../fx";
|
||||
import { createMultipleTestContainers } from "../../testData";
|
||||
|
||||
test.describe("Container Copy - Online Migration", () => {
|
||||
let page: Page;
|
||||
let wrapper: Locator;
|
||||
let panel: Locator;
|
||||
let frame: Frame;
|
||||
let targetAccountName: string;
|
||||
|
||||
test.beforeEach("Setup for online migration test", async ({ browser }) => {
|
||||
await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
|
||||
|
||||
page = await browser.newPage();
|
||||
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
|
||||
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
|
||||
});
|
||||
|
||||
test.afterEach("Cleanup after online migration test", async () => {
|
||||
await page.unroute(/.*/, (route) => route.continue());
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("Successfully create and manage online migration copy job", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" });
|
||||
|
||||
// Open Create Copy Job panel
|
||||
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
|
||||
await expect(createCopyJobButton).toBeVisible();
|
||||
await createCopyJobButton.click();
|
||||
panel = frame.getByTestId("Panel:Create copy job");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Reduced wait time for better performance
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Enable online migration mode
|
||||
const migrationTypeContainer = panel.getByTestId("migration-type");
|
||||
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
|
||||
await onlineCopyRadioButton.click({ force: true });
|
||||
|
||||
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verify permissions screen is shown for online migration
|
||||
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
|
||||
await expect(permissionScreen).toBeVisible();
|
||||
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
|
||||
|
||||
// Skip permissions setup and proceed to container selection
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Configure source and target containers for online migration
|
||||
const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown");
|
||||
await sourceDatabaseDropdown.click();
|
||||
const sourceDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await sourceDbDropdownItem.click();
|
||||
|
||||
const sourceContainerDropdown = panel.getByTestId("source-containerDropdown");
|
||||
await sourceContainerDropdown.click();
|
||||
const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await sourceContainerDropdownItem.click();
|
||||
|
||||
const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown");
|
||||
await targetDatabaseDropdown.click();
|
||||
const targetDbDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 0 },
|
||||
{ ariaLabel: "Database" },
|
||||
);
|
||||
await targetDbDropdownItem.click();
|
||||
|
||||
const targetContainerDropdown = panel.getByTestId("target-containerDropdown");
|
||||
await targetContainerDropdown.click();
|
||||
const targetContainerDropdownItem = await getDropdownItemByNameOrPosition(
|
||||
frame,
|
||||
{ position: 1 },
|
||||
{ ariaLabel: "Container" },
|
||||
);
|
||||
await targetContainerDropdownItem.click();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verify job preview and create the online migration job
|
||||
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
|
||||
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName);
|
||||
|
||||
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
|
||||
const onlineMigrationJobName = await jobNameInput.inputValue();
|
||||
|
||||
const copyButton = panel.getByRole("button", { name: "Copy", exact: true });
|
||||
|
||||
const copyJobCreationPromise = waitForApiResponse(
|
||||
page,
|
||||
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
|
||||
"PUT",
|
||||
);
|
||||
await copyButton.click();
|
||||
await page.waitForTimeout(1000); // Reduced wait time
|
||||
|
||||
const response = await copyJobCreationPromise;
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
// Verify panel closes and job appears in the list
|
||||
await expect(panel).not.toBeVisible();
|
||||
|
||||
const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField");
|
||||
await filterTextField.waitFor({ state: "visible" });
|
||||
await filterTextField.fill(onlineMigrationJobName);
|
||||
|
||||
const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page");
|
||||
await jobsListContainer.waitFor({ state: "visible" });
|
||||
|
||||
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" });
|
||||
|
||||
// Verify job status changes to queued state
|
||||
await expect(statusCell).toContainText(/running|queued|pending/i);
|
||||
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { expect, Frame, Locator, Page, test } from "@playwright/test";
|
||||
import { set } from "lodash";
|
||||
import { ContainerCopy, getAccountName, TestAccount } from "../../fx";
|
||||
|
||||
const VISIBLE_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
test.describe("Container Copy - Permission Screen Verification", () => {
|
||||
let page: Page;
|
||||
let wrapper: Locator;
|
||||
let panel: Locator;
|
||||
let frame: Frame;
|
||||
let targetAccountName: string;
|
||||
let expectedSourceAccountName: string;
|
||||
|
||||
test.beforeEach("Setup for each test", async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
|
||||
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
|
||||
});
|
||||
|
||||
test.afterEach("Cleanup after each test", async () => {
|
||||
await page.unroute(/.*/, (route) => route.continue());
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("Verify online container copy permissions panel functionality", async () => {
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
// Verify all command bar buttons are visible
|
||||
await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS });
|
||||
|
||||
const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job");
|
||||
await expect(createCopyJobButton).toBeVisible();
|
||||
await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible();
|
||||
await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible();
|
||||
|
||||
// Open the Create Copy Job panel
|
||||
await createCopyJobButton.click();
|
||||
panel = frame.getByTestId("Panel:Create copy job");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible();
|
||||
|
||||
// Select a different account for cross-account testing
|
||||
const accountDropdown = panel.getByTestId("account-dropdown");
|
||||
await accountDropdown.click();
|
||||
|
||||
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
|
||||
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account");
|
||||
|
||||
const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all();
|
||||
|
||||
const filteredItems = [];
|
||||
for (const item of allDropdownItems) {
|
||||
const testContent = (await item.textContent()) ?? "";
|
||||
if (testContent.trim() !== targetAccountName.trim()) {
|
||||
filteredItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
const firstDropdownItem = filteredItems[0];
|
||||
expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? "";
|
||||
await firstDropdownItem.click();
|
||||
} else {
|
||||
throw new Error("No dropdown items available after filtering");
|
||||
}
|
||||
|
||||
// Enable online migration mode
|
||||
const migrationTypeContainer = panel.getByTestId("migration-type");
|
||||
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
|
||||
await onlineCopyRadioButton.click({ force: true });
|
||||
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
|
||||
|
||||
await panel.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Verify Assign Permissions panel for online copy
|
||||
const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer");
|
||||
await expect(permissionScreen).toBeVisible();
|
||||
await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible();
|
||||
await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible();
|
||||
|
||||
// Setup API mocking for the source account
|
||||
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => {
|
||||
const mockData = {
|
||||
identity: {
|
||||
type: "SystemAssigned",
|
||||
principalId: "00-11-22-33",
|
||||
},
|
||||
properties: {
|
||||
defaultIdentity: "SystemAssignedIdentity",
|
||||
backupPolicy: {
|
||||
type: "Continuous",
|
||||
},
|
||||
capabilities: [{ name: "EnableOnlineContainerCopy" }],
|
||||
},
|
||||
};
|
||||
if (route.request().method() === "GET") {
|
||||
const response = await route.fetch();
|
||||
const actualData = await response.json();
|
||||
const mergedData = { ...actualData };
|
||||
|
||||
set(mergedData, "identity", mockData.identity);
|
||||
set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity);
|
||||
set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy);
|
||||
set(mergedData, "properties.capabilities", mockData.properties.capabilities);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(mergedData),
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Point-in-Time Restore functionality
|
||||
const expandedOnlineAccordionHeader = permissionScreen
|
||||
.getByTestId("permission-group-container-onlineConfigs")
|
||||
.locator("button[aria-expanded='true']");
|
||||
await expect(expandedOnlineAccordionHeader).toBeVisible();
|
||||
|
||||
const accordionItem = expandedOnlineAccordionHeader
|
||||
.locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]")
|
||||
.first();
|
||||
|
||||
const accordionPanel = accordionItem
|
||||
.locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']")
|
||||
.first();
|
||||
|
||||
// Install clock mock and test PITR functionality
|
||||
await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") });
|
||||
|
||||
const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn");
|
||||
await expect(pitrBtn).toBeVisible();
|
||||
await pitrBtn.click({ force: true });
|
||||
|
||||
// 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({ force: true });
|
||||
|
||||
// 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({ force: true });
|
||||
|
||||
// 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({ force: true });
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ let CONTAINER_ID: string;
|
||||
|
||||
// Set up test database and container with data before all tests
|
||||
test.beforeAll(async () => {
|
||||
testContainer = await createTestSQLContainer({ includeTestData: true });
|
||||
testContainer = await createTestSQLContainer(true);
|
||||
DATABASE_ID = testContainer.database.id;
|
||||
CONTAINER_ID = testContainer.container.id;
|
||||
});
|
||||
|
||||
@@ -14,9 +14,11 @@ test.describe("Stored Procedures", () => {
|
||||
explorer = await DataExplorer.open(page, TestAccount.SQL);
|
||||
});
|
||||
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
if (!process.env.CI) {
|
||||
test.afterAll("Delete Test Database", async () => {
|
||||
await context?.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => {
|
||||
void page;
|
||||
|
||||
@@ -91,12 +91,8 @@ async function deleteWithRetry(client, database, accountName) {
|
||||
while (attempt < maxRetries) {
|
||||
try {
|
||||
const timestamp = Number(database.resource._ts) * 1000;
|
||||
if (timestamp && timestamp < thirtyMinutesAgo) {
|
||||
await client.sqlResources.deleteSqlDatabase(resourceGroupName, accountName, database.name);
|
||||
console.log(`DELETED: ${accountName} | ${database.name} | Age: ${friendlyTime(Date.now() - timestamp)}`);
|
||||
} else {
|
||||
console.log(`SKIPPED: ${accountName} | ${database.name} | Age: ${friendlyTime(Date.now() - timestamp)}`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.statusCode === 429) {
|
||||
|
||||
Reference in New Issue
Block a user