Compare commits

...

13 Commits

Author SHA1 Message Date
Sindhu Balasubramanian
79dbdbbe7f Merge branch 'users/sindhuba/playwrit-tests' of https://github.com/Azure/cosmos-explorer into users/sindhuba/sharedThroughput 2026-01-13 11:34:54 -08:00
Sindhu Balasubramanian
3f977df00d Merge branch 'master' of https://github.com/Azure/cosmos-explorer 2026-01-13 10:25:55 -08:00
sunghyunkang1111
896b3e974e Monitor telemetry (#2326)
* Adding more telemetries for monitoring

* Adding more telemetries for monitoring
2026-01-12 11:15:26 -06:00
BChoudhury-ms
e6461cf079 Refactor container copy migration type selection from checkbox to radio buttons (#2307)
* replace migration type checkbox with radio button selection

* use force: true to bypass label interception
2026-01-12 08:46:47 +05:30
Sindhu Balasubramanian
865e9c906b Run npm format 2026-01-08 13:16:22 -08:00
Sindhu Balasubramanian
1c34425dd8 Merge branch 'master' of https://github.com/Azure/cosmos-explorer 2026-01-08 13:00:47 -08:00
Sindhu Balasubramanian
50a244e6f9 Add Mongo Pagination tests 2026-01-08 12:55:24 -08:00
Sindhu Balasubramanian
9dad75c2f9 Remove localhost 2025-12-30 23:21:56 -08:00
Sindhu Balasubramanian
876b531248 Add changes for Load more option to work 2025-12-30 22:54:42 -08:00
Sindhu Balasubramanian
28fe5846b3 Fix error with adding container to shared db test 2025-12-22 11:22:36 -08:00
Sindhu Balasubramanian
f8533abb64 Run npm format 2025-12-22 07:08:30 -08:00
Sindhu Balasubramanian
2921294a3d Run npm commands 2025-12-22 06:58:56 -08:00
Sindhu Balasubramanian
a03c289da0 Add sharedThroughput db tests 2025-12-21 22:03:12 -08:00
20 changed files with 1040 additions and 320 deletions

View File

@@ -38,7 +38,7 @@ export function queryIterator(databaseId: string, collection: Collection, query:
let continuationToken: string;
return {
fetchNext: () => {
return queryDocuments(databaseId, collection, false, query).then((response) => {
return queryDocuments(databaseId, collection, false, query, continuationToken).then((response) => {
continuationToken = response.continuationToken;
const headers: { [key: string]: string | number } = {};
response.headers.forEach((value, key) => {

View File

@@ -25,7 +25,18 @@ export default {
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
migrationTypeCheckboxLabel: "Copy container in offline mode",
migrationTypeOptions: {
offline: {
title: "Offline mode",
description:
"Offline container copy jobs let you copy data from a source container to a destination Cosmos DB container for supported APIs. To ensure data integrity between the source and destination, we recommend stopping updates on the source container before creating the copy job. Learn more about [offline copy jobs](https://learn.microsoft.com/azure/cosmos-db/how-to-container-copy?tabs=offline-copy&pivots=api-nosql).",
},
online: {
title: "Online mode",
description:
"Online container copy jobs let you copy data from a source container to a destination Cosmos DB NoSQL API container using the [All Versions and Delete](https://learn.microsoft.com/azure/cosmos-db/change-feed-modes?tabs=all-versions-and-deletes#all-versions-and-deletes-change-feed-mode-preview) change feed. This allows updates to continue on the source while data is copied. A brief downtime is required at the end to safely switch over client applications to the destination container. Learn more about [online copy jobs](https://learn.microsoft.com/azure/cosmos-db/container-copy?tabs=online-copy&pivots=api-nosql#getting-started).",
},
},
// Select Source and Target Containers Screen
selectSourceAndTargetContainersDescription:

View File

@@ -0,0 +1,241 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
import { MigrationType } from "./MigrationType";
jest.mock("../../../../Context/CopyJobContext", () => ({
useCopyJobContext: jest.fn(),
}));
describe("MigrationType", () => {
const mockSetCopyJobState = jest.fn();
const defaultContextValue = {
copyJobState: {
jobName: "",
migrationType: CopyJobMigrationType.Online,
source: {
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: { currentScreen: "selectAccount" },
setFlow: jest.fn(),
contextError: "",
setContextError: jest.fn(),
explorer: {} as any,
resetCopyJobState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(useCopyJobContext as jest.Mock).mockReturnValue(defaultContextValue);
});
describe("Component Rendering", () => {
it("should render migration type component with radio buttons", () => {
const { container } = render(<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();
});
});
});

View File

@@ -0,0 +1,77 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { ChoiceGroup, IChoiceGroupOption, Stack, Text } from "@fluentui/react";
import MarkdownRender from "@nteract/markdown";
import { useCopyJobContext } from "Explorer/ContainerCopy/Context/CopyJobContext";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
import { CopyJobMigrationType } from "../../../../Enums/CopyJobEnums";
interface MigrationTypeProps {}
const options: IChoiceGroupOption[] = [
{
key: CopyJobMigrationType.Offline,
text: ContainerCopyMessages.migrationTypeOptions.offline.title,
styles: { root: { width: "33%" } },
},
{
key: CopyJobMigrationType.Online,
text: ContainerCopyMessages.migrationTypeOptions.online.title,
styles: { root: { width: "33%" } },
},
];
const choiceGroupStyles = {
flexContainer: { display: "flex" as const },
root: {
selectors: {
".ms-ChoiceField": {
color: "var(--colorNeutralForeground1)",
},
".ms-ChoiceField-field:hover .ms-ChoiceFieldLabel": {
color: "var(--colorNeutralForeground1)",
},
},
},
};
export const MigrationType: React.FC<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>
);
});

View File

@@ -1,72 +0,0 @@
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import { MigrationTypeCheckbox } from "./MigrationTypeCheckbox";
describe("MigrationTypeCheckbox", () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe("Component Rendering", () => {
it("should render with default props (unchecked state)", () => {
const { container } = render(<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();
});
});
});

View File

@@ -1,33 +0,0 @@
/* eslint-disable react/prop-types */
/* eslint-disable react/display-name */
import { Checkbox, ICheckboxStyles, Stack } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../../ContainerCopyMessages";
interface MigrationTypeCheckboxProps {
checked: boolean;
onChange: (_ev?: React.FormEvent, checked?: boolean) => void;
}
const checkboxStyles: ICheckboxStyles = {
text: { color: "var(--colorNeutralForeground1)" },
checkbox: { borderColor: "var(--colorNeutralStroke1)" },
root: {
selectors: {
":hover .ms-Checkbox-text": { color: "var(--colorNeutralForeground1)" },
},
},
};
export const MigrationTypeCheckbox: React.FC<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>
);
});

View File

@@ -0,0 +1,109 @@
// 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>
`;

View File

@@ -1,82 +0,0 @@
// 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>
`;

View File

@@ -1,5 +1,5 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import React from "react";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
@@ -18,19 +18,8 @@ jest.mock("./Components/AccountDropdown", () => ({
AccountDropdown: jest.fn(() => <div data-testid="account-dropdown">Account Dropdown</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>
)),
jest.mock("./Components/MigrationType", () => ({
MigrationType: jest.fn(() => <div data-testid="migration-type">Migration Type</div>),
}));
describe("SelectAccount", () => {
@@ -83,7 +72,7 @@ describe("SelectAccount", () => {
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("migration-type-checkbox")).toBeInTheDocument();
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
});
it("should render correctly with snapshot", () => {
@@ -93,78 +82,20 @@ describe("SelectAccount", () => {
});
describe("Migration Type Functionality", () => {
it("should display migration type checkbox as unchecked when migrationType is Online", () => {
(useCopyJobContext as jest.Mock).mockReturnValue({
...defaultContextValue,
copyJobState: {
...defaultContextValue.copyJobState,
migrationType: CopyJobMigrationType.Online,
},
});
it("should render migration type component", () => {
render(<SelectAccount />);
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,
});
const migrationTypeComponent = screen.getByTestId("migration-type");
expect(migrationTypeComponent).toBeInTheDocument();
});
});
describe("Performance and Optimization", () => {
it("should maintain referential equality of handler functions between renders", async () => {
it("should render without performance issues", () => {
const { rerender } = render(<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 />);
const secondRenderHandler = migrationCheckbox.mock.calls[migrationCheckbox.mock.calls.length - 1][0].onChange;
expect(firstRenderHandler).toBe(secondRenderHandler);
expect(screen.getByTestId("migration-type")).toBeInTheDocument();
});
});
});

View File

@@ -1,24 +1,11 @@
import { Stack, Text } from "@fluentui/react";
import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobMigrationType } from "../../../Enums/CopyJobEnums";
import { AccountDropdown } from "./Components/AccountDropdown";
import { MigrationTypeCheckbox } from "./Components/MigrationTypeCheckbox";
import { MigrationType } from "./Components/MigrationType";
import { SubscriptionDropdown } from "./Components/SubscriptionDropdown";
const SelectAccount = React.memo(() => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const handleMigrationTypeChange = (_ev?: React.FormEvent<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>
@@ -27,7 +14,7 @@ const SelectAccount = React.memo(() => {
<AccountDropdown />
<MigrationTypeCheckbox checked={migrationTypeChecked} onChange={handleMigrationTypeChange} />
<MigrationType />
</Stack>
);
});

View File

@@ -21,14 +21,9 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
Account Dropdown
</div>
<div
data-testid="migration-type-checkbox"
data-testid="migration-type"
>
<input
aria-label="Migration Type Checkbox"
data-testid="migration-checkbox-input"
type="checkbox"
/>
Copy container in offline mode
Migration Type
</div>
</div>
`;

View File

@@ -138,6 +138,14 @@
color: var(--colorNeutralForeground1);
}
}
.migrationTypeDescription {
p {
color: var(--colorNeutralForeground1);
}
a {
color: var(--colorBrandForeground1);
}
}
}
.create-container-link-btn {
padding: 0;
@@ -181,6 +189,9 @@
background-color: var(--colorNeutralBackground3);
}
}
.ms-DetailsHeader-cellTitle {
padding-left: 20px;
}
}
.ms-DetailsRow {

View File

@@ -410,6 +410,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
defaultSelectedKey={this.props.databaseId}
responsiveMode={999}
onRenderOption={this.onRenderDatabaseOption}
/>
)}
<Separator className="panelSeparator" style={{ marginTop: -4, marginBottom: -4 }} />
@@ -1473,4 +1474,19 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
TelemetryProcessor.traceFailure(Action.CreateCollection, failureTelemetryData, startKey);
}
}
private onRenderDatabaseOption = (
option?: IDropdownOption,
defaultRender?: (props?: IDropdownOption) => JSX.Element,
): JSX.Element | null => {
if (!option) {
return null;
}
return (
<div data-testid={`database-option-${option.key}`}>
{defaultRender ? defaultRender(option) : <span>{option.text}</span>}
</div>
);
};
}

View File

@@ -1,6 +1,7 @@
import { Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
import { configContext } from "../ConfigContext";
import { trackEvent } from "../Shared/appInsights";
import { Action } from "../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceMark, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor";
import { userContext } from "../UserContext";
import MetricScenario, { reportHealthy, reportUnhealthy } from "./MetricEvents";
import { scenarioConfigs } from "./MetricScenarioConfigs";
@@ -83,6 +84,13 @@ class ScenarioMonitor {
ctx.phases.set(phase, { startMarkName: phaseStartMarkName });
});
traceMark(Action.MetricsScenario, {
event: "scenario_start",
scenario,
requiredPhases: config.requiredPhases.join(","),
timeoutMs: config.timeoutMs,
});
ctx.timeoutId = window.setTimeout(() => this.emit(ctx, false, true), config.timeoutMs);
this.contexts.set(scenario, ctx);
}
@@ -96,6 +104,12 @@ class ScenarioMonitor {
const startMarkName = `scenario_${scenario}_${phase}_start`;
performance.mark(startMarkName);
ctx.phases.set(phase, { startMarkName });
traceStart(Action.MetricsScenario, {
event: "phase_start",
scenario,
phase,
});
}
completePhase(scenario: MetricScenario, phase: MetricPhase) {
@@ -110,6 +124,22 @@ class ScenarioMonitor {
phaseCtx.endMarkName = endMarkName;
ctx.completed.add(phase);
const navigationStart = performance.timeOrigin;
const startEntry = performance.getEntriesByName(phaseCtx.startMarkName)[0];
const endEntry = performance.getEntriesByName(endMarkName)[0];
const endTimeISO = endEntry ? new Date(navigationStart + endEntry.startTime).toISOString() : undefined;
const durationMs = startEntry && endEntry ? endEntry.startTime - startEntry.startTime : undefined;
traceSuccess(Action.MetricsScenario, {
event: "phase_complete",
scenario,
phase,
endTimeISO,
durationMs,
completedCount: ctx.completed.size,
requiredCount: ctx.config.requiredPhases.length,
});
this.tryEmitIfReady(ctx);
}
@@ -133,6 +163,14 @@ class ScenarioMonitor {
// Build a snapshot with failure info
const failureSnapshot = this.buildSnapshot(ctx, { final: false, timedOut: false });
traceFailure(Action.MetricsScenario, {
event: "phase_fail",
scenario,
phase,
failedPhases: Array.from(ctx.failed).join(","),
completedPhases: Array.from(ctx.completed).join(","),
});
// Emit unhealthy immediately
this.emit(ctx, false, false, failureSnapshot);
}
@@ -191,27 +229,22 @@ class ScenarioMonitor {
// Build snapshot if not provided
const finalSnapshot = snapshot || this.buildSnapshot(ctx, { final: false, timedOut });
// Emit enriched telemetry with performance data
// TODO: Call portal backend metrics endpoint
trackEvent(
{ name: "MetricScenarioComplete" },
{
scenario: ctx.scenario,
healthy: healthy.toString(),
timedOut: timedOut.toString(),
platform,
api,
durationMs: finalSnapshot.durationMs.toString(),
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp?.toString(),
inp: finalSnapshot.vitals?.inp?.toString(),
cls: finalSnapshot.vitals?.cls?.toString(),
fcp: finalSnapshot.vitals?.fcp?.toString(),
ttfb: finalSnapshot.vitals?.ttfb?.toString(),
phaseTimings: JSON.stringify(finalSnapshot.phaseTimings),
},
);
traceMark(Action.MetricsScenario, {
event: "scenario_end",
scenario: ctx.scenario,
healthy,
timedOut,
platform,
api,
durationMs: finalSnapshot.durationMs,
completedPhases: finalSnapshot.completed.join(","),
failedPhases: finalSnapshot.failedPhases?.join(","),
lcp: finalSnapshot.vitals?.lcp,
inp: finalSnapshot.vitals?.inp,
cls: finalSnapshot.vitals?.cls,
fcp: finalSnapshot.vitals?.fcp,
ttfb: finalSnapshot.vitals?.ttfb,
});
// Call portal backend health metrics endpoint
if (healthy && !timedOut) {
@@ -227,9 +260,16 @@ class ScenarioMonitor {
private cleanupPerformanceEntries(ctx: InternalScenarioContext) {
performance.clearMarks(ctx.startMarkName);
ctx.config.requiredPhases.forEach((phase) => {
performance.clearMarks(`scenario_${ctx.scenario}_${phase}`);
const phaseCtx = ctx.phases.get(phase);
if (phaseCtx?.startMarkName) {
performance.clearMarks(phaseCtx.startMarkName);
}
if (phaseCtx?.endMarkName) {
performance.clearMarks(phaseCtx.endMarkName);
}
performance.clearMarks(`scenario_${ctx.scenario}_${phase}_failed`);
performance.clearMeasures(`scenario_${ctx.scenario}_${phase}_duration`);
});
performance.clearMeasures(`scenario_${ctx.scenario}_total`);
}
private buildSnapshot(

View File

@@ -2,6 +2,7 @@
// Some of the enums names are used in Fabric. Please do not rename them.
export enum Action {
CollapseTreeNode,
MetricsScenario,
CreateCollection, // Used in Fabric. Please do not rename.
CreateGlobalSecondaryIndex,
CreateDocument, // Used in Fabric. Please do not rename.

View File

@@ -58,7 +58,9 @@ export const defaultAccounts: Record<TestAccount, string> = {
export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests";
export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c";
export const TEST_AUTOSCALE_THROUGHPUT_RU = 1000;
export const TEST_MANUAL_THROUGHPUT_RU = 800;
export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K = 2000;
export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K = 4000;
export const TEST_MANUAL_THROUGHPUT_RU_2K = 2000;
export const ONE_MINUTE_MS: number = 60 * 1000;

View File

@@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import { setupCORSBypass } from "../CORSBypass";
import { DataExplorer, QueryTab, TestAccount, CommandBarButton, Editor } from "../fx";
import { serializeMongoToJson } from "../testData";
const databaseId = "test-e2etests-mongo-pagination";
const collectionId = "test-coll-mongo-pagination";
let explorer: DataExplorer = null!;
test.setTimeout(5 * 60 * 1000);
test.describe("Test Mongo Pagination", () => {
let queryTab: QueryTab;
let queryEditor: Editor;
test.beforeEach("Open query tab", async ({ page }) => {
await setupCORSBypass(page);
explorer = await DataExplorer.open(page, TestAccount.MongoReadonly);
const containerNode = await explorer.waitForContainerNode(databaseId, collectionId);
await containerNode.expand();
const containerMenuNode = await explorer.waitForContainerDocumentsNode(databaseId, collectionId);
await containerMenuNode.openContextMenu();
await containerMenuNode.contextMenuItem("New Query").click();
queryTab = explorer.queryTab("tab0");
queryEditor = queryTab.editor();
await queryEditor.locator.waitFor({ timeout: 30 * 1000 });
await queryTab.executeCTA.waitFor();
await explorer.frame.getByTestId("NotificationConsole/ExpandCollapseButton").click();
await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor();
});
test("should execute a query and load more results", async ({ page }) => {
const query = "{}";
await queryEditor.locator.click();
await queryEditor.setText(query);
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
// Wait for query execution to complete
await expect(queryTab.resultsView).toBeVisible({ timeout: 60000 });
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 30000 });
// Get initial results
const resultText = await queryTab.resultsEditor.text();
if (!resultText || resultText.trim() === "" || resultText.trim() === "[]") {
throw new Error("Query returned no results - the collection appears to be empty");
}
const resultData = serializeMongoToJson(resultText);
if (resultData.length === 0) {
throw new Error("Parsed results contain 0 documents - collection is empty");
}
if (resultData.length < 100) {
expect(resultData.length).toBeGreaterThan(0);
return;
}
expect(resultData.length).toBe(100);
// Pagination test
let totalPagesLoaded = 1;
const maxLoadMoreAttempts = 10;
for (let loadMoreAttempts = 0; loadMoreAttempts < maxLoadMoreAttempts; loadMoreAttempts++) {
const loadMoreButton = queryTab.resultsView.getByText("Load more");
try {
await expect(loadMoreButton).toBeVisible({ timeout: 5000 });
} catch {
// Load more button not visible - pagination complete
break;
}
const beforeClickText = await queryTab.resultsEditor.text();
const beforeClickHash = Buffer.from(beforeClickText || "")
.toString("base64")
.substring(0, 50);
await loadMoreButton.click();
// Wait for content to update
let editorContentChanged = false;
for (let waitAttempt = 1; waitAttempt <= 3; waitAttempt++) {
await page.waitForTimeout(2000);
const currentEditorText = await queryTab.resultsEditor.text();
const currentHash = Buffer.from(currentEditorText || "")
.toString("base64")
.substring(0, 50);
if (currentHash !== beforeClickHash) {
editorContentChanged = true;
break;
}
}
if (editorContentChanged) {
totalPagesLoaded++;
} else {
// No content change detected, stop pagination
break;
}
await page.waitForTimeout(1000);
}
// Final verification
const finalIndicator = queryTab.resultsView.locator("text=/\\d+ - \\d+/");
const finalIndicatorText = await finalIndicator.textContent();
if (finalIndicatorText) {
const match = finalIndicatorText.match(/(\d+) - (\d+)/);
if (match) {
const totalDocuments = parseInt(match[2]);
expect(totalDocuments).toBe(405);
expect(totalPagesLoaded).toBe(5);
} else {
throw new Error(`Invalid results indicator format: ${finalIndicatorText}`);
}
} else {
expect(totalPagesLoaded).toBe(5);
}
});
});

View File

@@ -83,22 +83,33 @@ test.describe("Container Copy", () => {
);
await accountItem.click();
// Verifying online or offline checkbox functionality
// Verifying online or offline migration functionality
/**
* This test verifies the functionality of the migration type checkbox that toggles between
* This test verifies the functionality of the migration type radio that toggles between
* online and offline container copy modes. It ensures that:
* 1. When online mode is selected, the user is directed to a permissions screen
* 2. When offline mode is selected, the user bypasses the permissions screen
* 3. The UI correctly reflects the selected migration type throughout the workflow
*/
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible();
await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "Previous" }).click();
await fluentUiCheckboxContainer.click();
const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i });
await offlineCopyRadioButton.click({ force: true });
await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible();
await panel.getByRole("button", { name: "Next" }).click();
await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible();
await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible();
@@ -284,8 +295,9 @@ test.describe("Container Copy", () => {
throw new Error("No dropdown items available after filtering");
}
const fluentUiCheckboxContainer = panel.getByTestId("migration-type-checkbox").locator("div.ms-Checkbox");
await fluentUiCheckboxContainer.click();
const migrationTypeContainer = panel.getByTestId("migration-type");
const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i });
await onlineCopyRadioButton.click({ force: true });
await panel.getByRole("button", { name: "Next" }).click();

View File

@@ -0,0 +1,288 @@
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { CosmosClient, CosmosClientOptions, Database } from "@azure/cosmos";
import { AzureIdentityCredentialAdapter } from "@azure/ms-rest-js";
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../../fx";
// Helper class for database context
class TestDatabaseContext {
constructor(
public armClient: CosmosDBManagementClient,
public client: CosmosClient,
public database: Database,
) {}
async dispose() {
await this.database.delete();
}
}
// Options for creating test database
interface CreateTestDBOptions {
throughput?: number;
maxThroughput?: number; // For autoscale
}
// Helper function to create a test database with shared throughput
async function createTestDB(options?: CreateTestDBOptions): Promise<TestDatabaseContext> {
const databaseId = generateUniqueName("db");
const credentials = getAzureCLICredentials();
const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials);
const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const clientOptions: CosmosClientOptions = {
endpoint: account.documentEndpoint!,
};
const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
if (nosqlAccountRbacToken) {
clientOptions.tokenProvider = async (): Promise<string> => {
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
const authorizationToken = `${AUTH_PREFIX}${nosqlAccountRbacToken}`;
return authorizationToken;
};
} else {
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
clientOptions.key = keys.primaryMasterKey;
}
const client = new CosmosClient(clientOptions);
// Create database with provisioned throughput (shared throughput)
// This checks the "Provision database throughput" option
const { database } = await client.databases.create({
id: databaseId,
throughput: options?.throughput, // Manual throughput (e.g., 400)
maxThroughput: options?.maxThroughput, // Autoscale max throughput (e.g., 1000)
});
return new TestDatabaseContext(armClient, client, database);
}
test.describe("Database with Shared Throughput", () => {
let dbContext: TestDatabaseContext = null!;
let explorer: DataExplorer = null!;
const containerId = "sharedcontainer";
// Helper methods
const getThroughputInput = (type: "manual" | "autopilot"): Locator => {
return explorer.frame.getByTestId(`${type}-throughput-input`);
};
test.afterEach(async () => {
// Clean up: delete the created database
await dbContext?.dispose();
});
test.describe("Manual Throughput Tests", () => {
test.beforeEach(async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
test("Create database with shared manual throughput and verify Scale node in UI", async () => {
test.setTimeout(120000); // 2 minutes timeout
// Create database with shared manual throughput (400 RU/s)
dbContext = await createTestDB({ throughput: 400 });
// Verify database node appears in the tree
const databaseNode = await explorer.waitForNode(dbContext.database.id);
expect(databaseNode).toBeDefined();
// Expand the database node to see child nodes
await databaseNode.expand();
// Verify that "Scale" node appears under the database
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
expect(scaleNode).toBeDefined();
await expect(scaleNode.element).toBeVisible();
});
test("Add container to shared database without dedicated throughput", async () => {
// Create database with shared manual throughput
dbContext = await createTestDB({ throughput: 400 });
// Wait for the database to appear in the tree
await explorer.waitForNode(dbContext.database.id);
// Add a container to the shared database via UI
await explorer.frame.getByRole("button", { name: "New Container" }).click();
await explorer.whilePanelOpen(
"New Container",
async (panel, okButton) => {
// Select "Use existing" database
const useExistingRadio = panel.getByRole("radio", { name: /Use existing/i });
await useExistingRadio.click();
// Select the database from dropdown using the new data-testid
const databaseDropdown = panel.getByRole("combobox", { name: "Choose an existing database" });
await databaseDropdown.click();
await explorer.frame.getByRole("option", { name: dbContext.database.id }).click();
// Now you can target the specific database option by its data-testid
//await panel.getByTestId(`database-option-${dbContext.database.id}`).click();
// Fill container id
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
// Fill partition key
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
// Ensure "Provision dedicated throughput" is NOT checked
const dedicatedThroughputCheckbox = panel.getByRole("checkbox", {
name: /Provision dedicated throughput for this container/i,
});
if (await dedicatedThroughputCheckbox.isVisible()) {
const isChecked = await dedicatedThroughputCheckbox.isChecked();
if (isChecked) {
await dedicatedThroughputCheckbox.uncheck();
}
}
await okButton.click();
},
{ closeTimeout: 5 * ONE_MINUTE_MS },
);
// Verify container was created under the database
const containerNode = await explorer.waitForContainerNode(dbContext.database.id, containerId);
expect(containerNode).toBeDefined();
});
test("Scale shared database manual throughput", async () => {
// Create database with shared manual throughput (400 RU/s)
dbContext = await createTestDB({ throughput: 400 });
// Navigate to the scale settings by clicking the "Scale" node in the tree
const databaseNode = await explorer.waitForNode(dbContext.database.id);
await databaseNode.expand();
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
await scaleNode.element.click();
// Update manual throughput from 400 to 800
await getThroughputInput("manual").fill(TEST_MANUAL_THROUGHPUT_RU.toString());
// Save changes
await explorer.commandBarButton(CommandBarButton.Save).click();
// Verify success message
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
test("Scale shared database from manual to autoscale", async () => {
// Create database with shared manual throughput (400 RU/s)
dbContext = await createTestDB({ throughput: 400 });
// Open database settings by clicking the "Scale" node
const databaseNode = await explorer.waitForNode(dbContext.database.id);
await databaseNode.expand();
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
await scaleNode.element.click();
// Switch to Autoscale
const autoscaleRadio = explorer.frame.getByText("Autoscale", { exact: true });
await autoscaleRadio.click();
// Set autoscale max throughput to 1000
//await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_THROUGHPUT_RU.toString());
// Save changes
await explorer.commandBarButton(CommandBarButton.Save).click();
// Verify success message
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
test.describe("Autoscale Throughput Tests", () => {
test.beforeEach(async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
});
test("Create database with shared autoscale throughput and verify Scale node in UI", async () => {
test.setTimeout(120000); // 2 minutes timeout
// Create database with shared autoscale throughput (max 1000 RU/s)
dbContext = await createTestDB({ maxThroughput: 1000 });
// Verify database node appears
const databaseNode = await explorer.waitForNode(dbContext.database.id);
expect(databaseNode).toBeDefined();
// Expand the database node to see child nodes
await databaseNode.expand();
// Verify that "Scale" node appears under the database
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
expect(scaleNode).toBeDefined();
await expect(scaleNode.element).toBeVisible();
});
test("Scale shared database autoscale throughput", async () => {
// Create database with shared autoscale throughput (max 1000 RU/s)
dbContext = await createTestDB({ maxThroughput: 1000 });
// Open database settings
const databaseNode = await explorer.waitForNode(dbContext.database.id);
await databaseNode.expand();
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
await scaleNode.element.click();
// Update autoscale max throughput from 1000 to 4000
await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K.toString());
// Save changes
await explorer.commandBarButton(CommandBarButton.Save).click();
// Verify success message
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
test("Scale shared database from autoscale to manual", async () => {
// Create database with shared autoscale throughput (max 1000 RU/s)
dbContext = await createTestDB({ maxThroughput: 1000 });
// Open database settings
const databaseNode = await explorer.waitForNode(dbContext.database.id);
await databaseNode.expand();
const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`);
await scaleNode.element.click();
// Switch to Manual
const manualRadio = explorer.frame.getByText("Manual", { exact: true });
await manualRadio.click();
// Save changes
await explorer.commandBarButton(CommandBarButton.Save).click();
// Verify success message
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -82,6 +82,60 @@ export class TestContainerContext {
}
}
export class TestDatabaseContext {
constructor(
public armClient: CosmosDBManagementClient,
public client: CosmosClient,
public database: Database,
) {}
async dispose() {
await this.database.delete();
}
}
export interface CreateTestDBOptions {
throughput?: number;
maxThroughput?: number; // For autoscale
}
export async function createTestDB(options?: CreateTestDBOptions): Promise<TestDatabaseContext> {
const databaseId = generateUniqueName("db");
const credentials = getAzureCLICredentials();
const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials);
const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const clientOptions: CosmosClientOptions = {
endpoint: account.documentEndpoint!,
};
const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
if (nosqlAccountRbacToken) {
clientOptions.tokenProvider = async (): Promise<string> => {
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
const authorizationToken = `${AUTH_PREFIX}${nosqlAccountRbacToken}`;
return authorizationToken;
};
} else {
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
clientOptions.key = keys.primaryMasterKey;
}
const client = new CosmosClient(clientOptions);
// Create database with provisioned throughput (shared throughput)
// This checks the "Provision database throughput" option
const { database } = await client.databases.create({
id: databaseId,
throughput: options?.throughput, // Manual throughput (e.g., 400)
maxThroughput: options?.maxThroughput, // Autoscale max throughput (e.g., 1000)
});
return new TestDatabaseContext(armClient, client, database);
}
type createTestSqlContainerConfig = {
includeTestData?: boolean;
partitionKey?: string;