Compare commits

...

10 Commits

Author SHA1 Message Date
Sindhu Balasubramanian
e904e305e7 Add sharedThroughput db tests 2025-12-21 21:55:19 -08:00
Sindhu Balasubramanian
96822af37f Merge branch 'users/aisayas/playwright' of https://github.com/Azure/cosmos-explorer into users/sindhuba/playwright-tests 2025-12-09 09:52:37 -08:00
Asier Isayas
4801aae754 changed throughput above limit 2025-12-08 11:21:20 -08:00
Asier Isayas
2a02112d87 fix autoscale selector 2025-12-08 09:55:21 -08:00
Asier Isayas
bbfff77495 Merge branch 'master' of https://github.com/Azure/cosmos-explorer into users/aisayas/playwright 2025-12-08 07:39:33 -08:00
Asier Isayas
f695b42071 fix tests 2025-12-04 12:24:47 -08:00
Asier Isayas
a8a96e22b4 fix unit tests 2025-12-04 12:05:47 -08:00
Asier Isayas
a1b026544d fix unit tests and lint 2025-12-04 11:52:31 -08:00
Asier Isayas
a912233b33 Merge branch 'master' of https://github.com/Azure/cosmos-explorer into users/aisayas/playwright 2025-12-04 11:43:17 -08:00
Asier Isayas
487130f6e3 Add playwright tests for Autoscale/Manual Throughpout and TTL 2025-12-04 11:40:48 -08:00
16 changed files with 691 additions and 32 deletions

View File

@@ -1482,6 +1482,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
itemKey: SettingsV2TabTypes[tab.tab],
style: { marginTop: 20 },
headerText: getTabTitle(tab.tab),
headerButtonProps: {
"data-test": `settings-tab-header/${SettingsV2TabTypes[tab.tab]}`,
},
};
return (

View File

@@ -127,9 +127,9 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
};
private ttlChoiceGroupOptions: IChoiceGroupOption[] = [
{ key: TtlType.Off, text: "Off" },
{ key: TtlType.OnNoDefault, text: "On (no default)" },
{ key: TtlType.On, text: "On" },
{ key: TtlType.Off, text: "Off", ariaLabel: "ttl-off-option" },
{ key: TtlType.OnNoDefault, text: "On (no default)", ariaLabel: "ttl-on-no-default-option" },
{ key: TtlType.On, text: "On", ariaLabel: "ttl-on-option" },
];
public getTtlValue = (value: string): TtlType => {
@@ -223,6 +223,7 @@ export class SubSettingsComponent extends React.Component<SubSettingsComponentPr
onChange={this.onTimeToLiveSecondsChange}
suffix="second(s)"
ariaLabel={`Time to live in seconds`}
data-test="ttl-input"
/>
)}
</Stack>

View File

@@ -503,7 +503,9 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
<span style={{ float: "left", transform: "translateX(-50%)" }}>
{this.props.instantMaximumThroughput.toLocaleString()}
</span>
<span style={{ float: "right" }}>{this.props.softAllowedMaximumThroughput.toLocaleString()}</span>
<span style={{ float: "right" }} data-test="soft-allowed-maximum-throughput">
{this.props.softAllowedMaximumThroughput.toLocaleString()}
</span>
</Stack.Item>
</Stack>
<ProgressIndicator
@@ -626,11 +628,12 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
min={autoPilotThroughput1K}
onGetErrorMessage={(value: string) => {
const sanitizedValue = getSanitizedInputValue(value);
return sanitizedValue % 1000
? "Throughput value must be in increments of 1000"
: this.props.throughputError;
const errorMessage: string =
sanitizedValue % 1000 ? "Throughput value must be in increments of 1000" : this.props.throughputError;
return <span data-test="autopilot-throughput-input-error">{errorMessage}</span>;
}}
validateOnLoad={false}
data-test="autopilot-throughput-input"
/>
</Stack>
</Stack>
@@ -650,7 +653,10 @@ export class ThroughputInputAutoPilotV3Component extends React.Component<
}
onChange={this.onThroughputChange}
min={this.props.minimum}
errorMessage={this.props.throughputError}
onGetErrorMessage={() => {
return <span data-test="manual-throughput-input-error">{this.props.throughputError}</span>;
}}
data-test="manual-throughput-input"
/>
)}
</>

View File

@@ -273,6 +273,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
/>
</Stack>
<StyledTextFieldBase
data-test="autopilot-throughput-input"
disabled={true}
id="autopilotInput"
key="auto pilot throughput input"
@@ -333,6 +334,7 @@ exports[`ThroughputInputAutoPilotV3Component autopilot input visible 1`] = `
5,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",
@@ -752,11 +754,13 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
}
>
<StyledTextFieldBase
data-test="manual-throughput-input"
disabled={false}
id="throughputInput"
key="provisioned throughput input"
min={10000}
onChange={[Function]}
onGetErrorMessage={[Function]}
required={true}
step={100}
styles={
@@ -811,6 +815,7 @@ exports[`ThroughputInputAutoPilotV3Component spendAck checkbox visible 1`] = `
5,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",
@@ -1206,11 +1211,13 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
}
>
<StyledTextFieldBase
data-test="manual-throughput-input"
disabled={false}
id="throughputInput"
key="provisioned throughput input"
min={10000}
onChange={[Function]}
onGetErrorMessage={[Function]}
required={true}
step={100}
styles={
@@ -1265,6 +1272,7 @@ exports[`ThroughputInputAutoPilotV3Component throughput input visible 1`] = `
5,000
</span>
<span
data-test="soft-allowed-maximum-throughput"
style={
{
"float": "right",

View File

@@ -22,14 +22,17 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
options={
[
{
"ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
"ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
"ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -63,6 +66,7 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = `
/>
<StyledTextFieldBase
ariaLabel="Time to live in seconds"
data-test="ttl-input"
id="timeToLiveSeconds"
max={2147483647}
min={1}
@@ -284,14 +288,17 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
"ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
"ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -325,6 +332,7 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = `
/>
<StyledTextFieldBase
ariaLabel="Time to live in seconds"
data-test="ttl-input"
id="timeToLiveSeconds"
max={2147483647}
min={1}
@@ -601,14 +609,17 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
options={
[
{
"ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
"ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
"ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -642,6 +653,7 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = `
/>
<StyledTextFieldBase
ariaLabel="Time to live in seconds"
data-test="ttl-input"
id="timeToLiveSeconds"
max={2147483647}
min={1}
@@ -878,14 +890,17 @@ exports[`SubSettingsComponent renders 1`] = `
options={
[
{
"ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
"ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
"ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},
@@ -919,6 +934,7 @@ exports[`SubSettingsComponent renders 1`] = `
/>
<StyledTextFieldBase
ariaLabel="Time to live in seconds"
data-test="ttl-input"
id="timeToLiveSeconds"
max={2147483647}
min={1}
@@ -1220,14 +1236,17 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = `
options={
[
{
"ariaLabel": "ttl-off-option",
"key": "off",
"text": "Off",
},
{
"ariaLabel": "ttl-on-no-default-option",
"key": "on-nodefault",
"text": "On (no default)",
},
{
"ariaLabel": "ttl-on-option",
"key": "on",
"text": "On",
},

View File

@@ -12,6 +12,11 @@ exports[`SettingsComponent renders 1`] = `
selectedKey="ScaleTab"
>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/ScaleTab",
}
}
headerText="Scale"
itemKey="ScaleTab"
key="ScaleTab"
@@ -102,6 +107,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/SubSettingsTab",
}
}
headerText="Settings"
itemKey="SubSettingsTab"
key="SubSettingsTab"
@@ -201,6 +211,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/ContainerVectorPolicyTab",
}
}
headerText="Container Policies"
itemKey="ContainerVectorPolicyTab"
key="ContainerVectorPolicyTab"
@@ -227,6 +242,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/IndexingPolicyTab",
}
}
headerText="Indexing Policy"
itemKey="IndexingPolicyTab"
key="IndexingPolicyTab"
@@ -263,6 +283,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/PartitionKeyTab",
}
}
headerText="Partition Keys (preview)"
itemKey="PartitionKeyTab"
key="PartitionKeyTab"
@@ -370,6 +395,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/ComputedPropertiesTab",
}
}
headerText="Computed Properties"
itemKey="ComputedPropertiesTab"
key="ComputedPropertiesTab"
@@ -404,6 +434,11 @@ exports[`SettingsComponent renders 1`] = `
/>
</PivotItem>
<PivotItem
headerButtonProps={
{
"data-test": "settings-tab-header/GlobalSecondaryIndexTab",
}
}
headerText="Global Secondary Index (Preview)"
itemKey="GlobalSecondaryIndexTab"
key="GlobalSecondaryIndexTab"

View File

@@ -127,7 +127,7 @@ export class NotificationConsoleComponent extends React.Component<
</span>
</span>
<span className="consoleSplitter" />
<span className="headerStatus">
<span className="headerStatus" data-test="notification-console/header-status">
<span className="headerStatusEllipsis" aria-live="assertive" aria-atomic="true">
{this.state.headerStatus}
</span>

View File

@@ -78,6 +78,7 @@ exports[`NotificationConsoleComponent renders the console 1`] = `
/>
<span
className="headerStatus"
data-test="notification-console/header-status"
>
<span
aria-atomic="true"
@@ -261,6 +262,7 @@ exports[`NotificationConsoleComponent renders the console 2`] = `
/>
<span
className="headerStatus"
data-test="notification-console/header-status"
>
<span
aria-atomic="true"

View File

@@ -7,6 +7,7 @@ import {
Icon,
IconButton,
IDropdownOption,
IRenderFunction,
Link,
ProgressIndicator,
Separator,
@@ -405,6 +406,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 }} />
@@ -1433,4 +1435,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

@@ -40,13 +40,13 @@ To use this script, there are a few prerequisites that must be done at least onc
5. Ensure you have a Resource Group _ready_ to deploy into, the deploy script requires an existing resource group. This resource group should be named `[username]-e2e-testing`, where `[username]` is your Windows username, (**Microsoft employees:** This should be your alias). The easiest way to do this is by running the `create-resource-group.ps1` script, specifying the Subscription (Name or ID) and Location in which you want to create the Resource Group. For example:
```powershell
.\test\resources\create-resource-group.ps1 -SubscriptionName "My Subscription" -Location "West US 3"
.\test\resources\create-resource-group.ps1 -SubscriptionId "My Subscription Id" -Location "West US 3"
```
Then, whenever you want to create/update the resources, you can run the `deploy.ps1` script in the `resources` directory. As long as you're using the default naming convention (`[username]-e2e-testing`), you just need to specify the Subscription. For example:
```powershell
.\test\resources\deploy.ps1 -SubscriptionName "My Subscription"
.\test\resources\deploy.ps1 -Subscription "My Subscription"
```
You'll get a confirmation prompt before anything is deployed:

View File

@@ -1,6 +1,7 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext, TestDatabaseContext } from "./testData";
const RETRY_COUNT = 3;
@@ -55,6 +56,11 @@ 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;
function tryGetStandardName(accountType: TestAccount) {
if (process.env.DE_TEST_ACCOUNT_PREFIX) {
@@ -319,6 +325,11 @@ type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
ExecuteQuery = "Execute Query",
}
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
export class DataExplorer {
constructor(public frame: Frame) {}
@@ -348,8 +359,8 @@ export class DataExplorer {
}
/** Select the command bar button with the specified label */
commandBarButton(label: string): Locator {
return this.frame.getByTestId(`CommandBar/Button:${label}`).and(this.frame.locator("css=button"));
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
@@ -445,6 +456,22 @@ export class DataExplorer {
await panel.waitFor({ state: "detached", timeout: options.closeTimeout });
}
/** Opens the Scale & Settings panel for the specified container */
async openScaleAndSettings(context: TestContainerContext): Promise<void> {
const containerNode = await this.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
const scaleAndSettingsButton = this.frame.getByTestId(
`TreeNode:${context.database.id}/${context.container.id}/Scale & Settings`,
);
await scaleAndSettingsButton.click();
}
/** Gets the console message element */
getConsoleMessage(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page) {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, Editor, QueryTab, TestAccount } from "../fx";
import { CommandBarButton, DataExplorer, Editor, QueryTab, TestAccount } from "../fx";
import { TestContainerContext, TestItem, createTestSQLContainer } from "../testData";
let context: TestContainerContext = null!;
@@ -37,7 +37,7 @@ test.afterAll("Delete Test Database", async () => {
test("Query results", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
@@ -59,7 +59,7 @@ test("Query results", async () => {
test("Query stats", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
@@ -77,7 +77,7 @@ test("Query errors", async () => {
await queryEditor.setText("SELECT\n glarb(c.id),\n blarg(c.id)\nFROM c");
// Run the query and verify the results
const executeQueryButton = explorer.commandBarButton("Execute Query");
const executeQueryButton = explorer.commandBarButton(CommandBarButton.ExecuteQuery);
await executeQueryButton.click();
await expect(queryTab.errorList).toBeAttached({ timeout: 60 * 1000 });

View File

@@ -0,0 +1,129 @@
import { expect, Locator, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K,
TEST_MANUAL_THROUGHPUT_RU_2K,
TestAccount,
} from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Autoscale and Manual throughput", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer(true);
});
test.beforeEach("Open container settings", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
// Click Scale & Settings and open Scale tab
await explorer.openScaleAndSettings(context);
const scaleTab = explorer.frame.getByTestId("settings-tab-header/ScaleTab");
await scaleTab.click();
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Update autoscale max throughput", async () => {
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
await switchManualToAutoscaleThroughput();
// Update autoscale max throughput
await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K.toString());
// Save
await explorer.commandBarButton(CommandBarButton.Save).click();
// Read console message
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for collection ${context.container.id}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
});
test("Update autoscale max throughput passed allowed limit", async () => {
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
await switchManualToAutoscaleThroughput();
// Get soft allowed max throughput and remove commas
const softAllowedMaxThroughputString = await explorer.frame
.getByTestId("soft-allowed-maximum-throughput")
.innerText();
const softAllowedMaxThroughput = Number(softAllowedMaxThroughputString.replace(/,/g, ""));
// Try to set autoscale max throughput above allowed limit
await getThroughputInput("autopilot").fill((softAllowedMaxThroughput * 10).toString());
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
await expect(getThroughputInputErrorMessage("autopilot")).toContainText(
"This update isn't possible because it would increase the total throughput",
);
});
test("Update autoscale max throughput with invalid increment", async () => {
// By default the created container has manual throughput (Containers created via JS SDK v4.7.0 cannot be created with autoscale throughput)
await switchManualToAutoscaleThroughput();
// Try to set autoscale max throughput with invalid increment
await getThroughputInput("autopilot").fill("1100");
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
await expect(getThroughputInputErrorMessage("autopilot")).toContainText(
"Throughput value must be in increments of 1000",
);
});
test("Update manual throughput", async () => {
await getThroughputInput("manual").fill(TEST_MANUAL_THROUGHPUT_RU_2K.toString());
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for collection ${context.container.id}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
});
test("Update manual throughput passed allowed limit", async () => {
// Get soft allowed max throughput and remove commas
const softAllowedMaxThroughputString = await explorer.frame
.getByTestId("soft-allowed-maximum-throughput")
.innerText();
const softAllowedMaxThroughput = Number(softAllowedMaxThroughputString.replace(/,/g, ""));
// Try to set manual throughput above allowed limit
await getThroughputInput("manual").fill((softAllowedMaxThroughput * 10).toString());
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
await expect(getThroughputInputErrorMessage("manual")).toContainText(
"This update isn't possible because it would increase the total throughput",
);
});
// Helper methods
const getThroughputInput = (type: "manual" | "autopilot"): Locator => {
return explorer.frame.getByTestId(`${type}-throughput-input`);
};
const getThroughputInputErrorMessage = (type: "manual" | "autopilot"): Locator => {
return explorer.frame.getByTestId(`${type}-throughput-input-error`);
};
const switchManualToAutoscaleThroughput = async (): Promise<void> => {
const autoscaleRadioButton = explorer.frame.getByText("Autoscale", { exact: true });
await autoscaleRadioButton.click();
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeEnabled();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(
`Successfully updated offer for collection ${context.container.id}`,
{
timeout: ONE_MINUTE_MS,
},
);
};
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx";
import { createTestSQLContainer, TestContainerContext } from "../../testData";
test.describe("Settings under Scale & Settings", () => {
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer(true);
});
test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => {
explorer = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.expand();
// Click Scale & Settings and open Scale tab
await explorer.openScaleAndSettings(context);
const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab");
await settingsTab.click();
});
test.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Update TTL to On (no default)", async () => {
const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" });
await ttlOnNoDefaultRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
timeout: ONE_MINUTE_MS,
});
});
test("Update TTL to On (with user entry)", async () => {
const ttlOnRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-option" });
await ttlOnRadioButton.click();
// Enter TTL seconds
const ttlInput = explorer.frame.getByTestId("ttl-input");
await ttlInput.fill("30000");
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
timeout: ONE_MINUTE_MS,
});
});
test("Update TTL to Off", async () => {
// By default TTL is set to off so we need to first set it to On
const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" });
await ttlOnNoDefaultRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
timeout: ONE_MINUTE_MS,
});
// Set it to Off
const ttlOffRadioButton = explorer.frame.getByRole("radio", { name: "ttl-off-option" });
await ttlOffRadioButton.click();
await explorer.commandBarButton(CommandBarButton.Save).click();
await expect(explorer.getConsoleMessage()).toContainText(`Successfully updated container ${context.container.id}`, {
timeout: ONE_MINUTE_MS,
});
});
});

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.globalCommandButton("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 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

@@ -74,6 +74,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);
}
export async function createTestSQLContainer(includeTestData?: boolean) {
const databaseId = generateUniqueName("db");
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique