Error rendering improvements (#1887)

This commit is contained in:
Ashley Stanton-Nurse
2024-08-15 13:29:57 -07:00
committed by GitHub
parent cc89691da3
commit 805a4ae168
40 changed files with 2393 additions and 1261 deletions

View File

@@ -98,7 +98,7 @@ If you used all the standard deployment scripts and naming scheme, you can set t
If Azure Powershell's current subscription is not the one you want to use for testing, you can set the subscription using the following command:
```powershell
.\test\scripts\set-test-subscription.ps1 -Subscription "My Subscription"
.\test\scripts\set-test-accounts.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then set the necessary environment variables:
@@ -151,3 +151,42 @@ npx playwright test --ui
The UI allows you to select a specific test to run and to see the results of the test in the browser.
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
## Clean-up
Tests should clean-up after themselves if they succeed (and sometimes even when they fail).
However, this is not guaranteed, and you may find that you have resources left over from failed tests.
Any resource (database, container, etc.) prefixed with `t_` is a test resource and can be safely deleted if you aren't currently running tests.
The `test/scripts/clean-test-accounts.ps1` script will attempt to clean all the test resources.
```powershell
.\test\scripts\clean-test-accounts.ps1 -Subscription "My Subscription"
```
That script will confirm the resource group exists and then prompt you to confirm the deletion of the resources:
```
Found a resource with the default resource prefix (ashleyst-e2e-). Configuring that prefix for E2E testing.
Cleaning E2E Testing Resources
Subscription: cosmosdb-portalteam-generaltest-msft (b9c77f10-b438-4c32-9819-eef8a654e478)
Resource Group: ashleyst-e2e-testing
Resource Prefix: ashleyst-e2e-
All databases with the prefix 't_' will be deleted.
Are you sure you want to delete these resources? (y/n): y
Cleaning Mongo Account: ashleyst-e2e-mongo
Cleaning Gremlin Account: ashleyst-e2e-gremlin
Cleaning Table Account: ashleyst-e2e-tables
Cleaning Cassandra Account: ashleyst-e2e-cassandra
Cleaning Keyspace: t_db90_1722888413729
Cleaning Keyspace: t_db76_1722882571248
Cleaning Keyspace: t_db3a_1722882413947
Cleaning Keyspace: t_db4d_1722882342943
Cleaning Keyspace: t_db64_1722888944788
Cleaning Keyspace: t_db90_1722882507916
Cleaning Keyspace: t_dbf5_1722888997915
Cleaning Keyspace: t_db7e_1722882689913
Cleaning SQL Account: ashleyst-e2e-sql
Cleaning Database: t_db32_1722890547089
Cleaning Mongo Account: ashleyst-e2e-mongo32
```

View File

@@ -1,39 +1,50 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Cassandra keyspace and table CRUD", async ({ page }) => {
const keyspaceId = generateDatabaseNameWithTimestamp();
const tableId = generateUniqueName("table");
const keyspaceId = generateUniqueName("db");
const tableId = "testtable"; // A unique table name isn't needed because the keyspace is unique
const explorer = await DataExplorer.open(page, TestAccount.Cassandra);
await explorer.globalCommandButton("New Table").click();
await explorer.whilePanelOpen("Add Table", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"Add Table",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new keyspace id").fill(keyspaceId);
await panel.getByPlaceholder("Enter table Id").fill(tableId);
await panel.getByLabel("Table max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const keyspaceNode = explorer.treeNode(keyspaceId);
await keyspaceNode.expand();
const tableNode = explorer.treeNode(`${keyspaceId}/${tableId}`);
const keyspaceNode = await explorer.waitForNode(keyspaceId);
const tableNode = await explorer.waitForContainerNode(keyspaceId, tableId);
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(tableNode.element).not.toBeAttached();
await keyspaceNode.openContextMenu();
await keyspaceNode.contextMenuItem("Delete Keyspace").click();
await explorer.whilePanelOpen("Delete Keyspace", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Keyspace",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Keyspace id" }).fill(keyspaceId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(keyspaceNode.element).not.toBeAttached();
});

View File

@@ -2,13 +2,22 @@ import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import { expect, Frame, Locator, Page } from "@playwright/test";
import crypto from "crypto";
export function generateUniqueName(baseName = "", length = 4): string {
return `${baseName}${crypto.randomBytes(length).toString("hex")}`;
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateDatabaseNameWithTimestamp(baseName = "db", length = 1): string {
// We use '_' as the separator because it's supported across all the API types.
return `${baseName}${crypto.randomBytes(length).toString("hex")}_${Date.now()}`;
export function generateUniqueName(baseName, options?: TestNameOptions): string {
const length = options?.length ?? 1;
const timestamp = options?.timestampped === undefined ? true : options.timestampped;
const prefixed = options?.prefixed === undefined ? true : options.prefixed;
const prefix = prefixed ? "t_" : "";
const suffix = timestamp ? `_${Date.now()}` : "";
return `${prefix}${baseName}${crypto.randomBytes(length).toString("hex")}${suffix}`;
}
export async function getAzureCLICredentials(): Promise<AzureCliCredentials> {
@@ -97,25 +106,132 @@ class TreeNode {
}
async expand(): Promise<void> {
// Sometimes, the expand button doesn't load at all, because the node didn't have children when it was initially loaded.
// Still, clicking the node will trigger loading and expansion. So if the node isn't expanded, we click it.
// The "aria-expanded" attribute is applied to the TreeItem. But we have the TreeItemLayout selected because the TreeItem contains the child tree as well.
// So, we need to find the TreeItem that contains this TreeItemLayout.
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
const expandNode = async () => {
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// Click the node, to trigger loading and expansion
await this.element.click();
}
// Try three times to wait for the node to expand.
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await tree.waitFor({ state: "visible" });
// The tree has expanded, let's get out of here
return true;
} catch {
// Just try again
if ((await treeNodeContainer.getAttribute("aria-expanded")) !== "true") {
// We might have collapsed the node, try expanding it again, then retry.
await this.element.click();
}
}
}
return false;
};
if (await expandNode()) {
return;
}
await expect(treeNodeContainer).toHaveAttribute("aria-expanded", "true");
// The tree never expanded. OR, it may have expanded in between when we found the "ExpandIcon" and when we clicked it (it's happened before)
// So, let's try one more time to expand it.
if (!(await expandNode())) {
// The tree never expanded. This is a problem.
throw new Error(`Node ${this.id} did not expand after clicking it.`);
}
// We did it. It took a lot of weird messing around, but we expanded a tree node... I hope.
}
}
export class Editor {
constructor(
public frame: Frame,
public locator: Locator,
) {}
text(): Promise<string | null> {
return this.locator.evaluate((e) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_getEditorContentForElement) {
return win._monaco_getEditorContentForElement(e);
}
return null;
});
}
async setText(text: string): Promise<void> {
// We trust that Monaco can handle the keyboard, and it's _extremely_ flaky to try and enter text using browser commands.
// So we use a hook we installed in 'window' to set the content of the editor.
// NOTE: This function is serialized and sent to the browser for execution
// So you can't use any variables from the outer scope, but we can send a string (via the second argument to evaluate)
await this.locator.evaluate((e, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_setEditorContentForElement) {
win._monaco_setEditorContentForElement(e, content);
}
}, text);
expect(await this.text()).toEqual(text);
}
}
export class QueryTab {
resultsPane: Locator;
resultsView: Locator;
executeCTA: Locator;
errorList: Locator;
queryStatsList: Locator;
resultsEditor: Editor;
resultsTab: Locator;
queryStatsTab: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.resultsPane = locator.getByTestId("QueryTab/ResultsPane");
this.resultsView = locator.getByTestId("QueryTab/ResultsPane/ResultsView");
this.executeCTA = locator.getByTestId("QueryTab/ResultsPane/ExecuteCTA");
this.errorList = locator.getByTestId("QueryTab/ResultsPane/ErrorList");
this.resultsEditor = new Editor(this.frame, this.resultsView.getByTestId("EditorReact/Host/Loaded"));
this.queryStatsList = locator.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsList");
this.resultsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/ResultsTab");
this.queryStatsTab = this.resultsView.getByTestId("QueryTab/ResultsPane/ResultsView/QueryStatsTab");
}
editor(): Editor {
const locator = this.locator.getByTestId("EditorReact/Host/Loaded");
return new Editor(this.frame, locator);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
/** Helper class that provides locator methods for DataExplorer components, on top of a Frame */
export class DataExplorer {
constructor(public frame: Frame) {}
tab(tabId: string): Locator {
return this.frame.getByTestId(`Tab:${tabId}`);
}
queryTab(tabId: string): QueryTab {
const tab = this.tab(tabId);
const queryTab = tab.getByTestId("QueryTab");
return new QueryTab(this.frame, tabId, tab, queryTab);
}
/** Select the primary global command button.
*
* There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button.
@@ -134,18 +250,68 @@ export class DataExplorer {
return this.frame.getByTestId(`Panel:${title}`);
}
async waitForNode(treeNodeId: string): Promise<TreeNode> {
const node = this.treeNode(treeNodeId);
// Is the node already visible?
if (await node.element.isVisible()) {
return node;
}
// No, try refreshing the tree
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
await refreshButton.click();
// Try a few times to find the node
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await node.element.waitFor();
return node;
} catch {
// Just try again
}
}
// We tried 3 times, but the node never appeared
throw new Error(`Node ${treeNodeId} not found and did not appear after refreshing.`);
}
async waitForContainerNode(databaseId: string, containerId: string): Promise<TreeNode> {
const databaseNode = await this.waitForNode(databaseId);
// The container node may be auto-expanded. Wait 5s for that to happen
try {
const containerNode = this.treeNode(`${databaseId}/${containerId}`);
await containerNode.element.waitFor({ state: "visible", timeout: 5 * 1000 });
return containerNode;
} catch {
// It didn't auto-expand, that's fine, we'll expand it ourselves
}
// Ok, expand the database node.
await databaseNode.expand();
return await this.waitForNode(`${databaseId}/${containerId}`);
}
/** Select the tree node with the specified id */
treeNode(id: string): TreeNode {
return new TreeNode(this.frame.getByTestId(`TreeNode:${id}`), this.frame, id);
}
/** Waits for the panel with the specified title to be open, then runs the provided callback. After the callback completes, waits for the panel to close. */
async whilePanelOpen(title: string, action: (panel: Locator, okButton: Locator) => Promise<void>): Promise<void> {
async whilePanelOpen(
title: string,
action: (panel: Locator, okButton: Locator) => Promise<void>,
options?: PanelOpenOptions,
): Promise<void> {
options ||= {};
const panel = this.panel(title);
await panel.waitFor();
const okButton = panel.getByTestId("Panel/OkButton");
await action(panel, okButton);
await panel.waitFor({ state: "detached" });
await panel.waitFor({ state: "detached", timeout: options.closeTimeout });
}
/** Waits for the Data Explorer app to load */

View File

@@ -1,41 +1,52 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Gremlin graph CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const graphId = generateUniqueName("graph");
const databaseId = generateUniqueName("db");
const graphId = "testgraph"; // A unique graph name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, TestAccount.Gremlin);
// Create new database and graph
await explorer.globalCommandButton("New Graph").click();
await explorer.whilePanelOpen("New Graph", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Graph",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Graph id, Example Graph1" }).fill(graphId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const graphNode = explorer.treeNode(`${databaseId}/${graphId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const graphNode = await explorer.waitForContainerNode(databaseId, graphId);
await graphNode.openContextMenu();
await graphNode.contextMenuItem("Delete Graph").click();
await explorer.whilePanelOpen("Delete Graph", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Graph",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the graph id" }).fill(graphId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(graphNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
(
[
@@ -9,38 +9,49 @@ import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateU
] as [string, TestAccount][]
).forEach(([apiVersionDescription, accountType]) => {
test(`Mongo CRUD using ${apiVersionDescription}`, async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const collectionId = generateUniqueName("collection");
const databaseId = generateUniqueName("db");
const collectionId = "testcollection"; // A unique collection name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, accountType);
await explorer.globalCommandButton("New Collection").click();
await explorer.whilePanelOpen("New Collection", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Collection",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Collection id, Example Collection1" }).fill(collectionId);
await panel.getByRole("textbox", { name: "Shard key" }).fill("pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const collectionNode = explorer.treeNode(`${databaseId}/${collectionId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const collectionNode = await explorer.waitForContainerNode(databaseId, collectionId);
await collectionNode.openContextMenu();
await collectionNode.contextMenuItem("Delete Collection").click();
await explorer.whilePanelOpen("Delete Collection", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Collection",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the collection id" }).fill(collectionId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(collectionNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the Database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

View File

@@ -0,0 +1,107 @@
param(
[Parameter(Mandatory=$false)][string]$ResourceGroup,
[Parameter(Mandatory=$false)][string]$Subscription,
[Parameter(Mandatory=$false)][string]$ResourcePrefix,
[Parameter(Mandatory=$false)][string]$DatabasePrefix = "t_"
)
Import-Module "Az.Accounts" -Scope Local
Import-Module "Az.Resources" -Scope Local
if (-not $Subscription) {
# Show the user the currently-selected subscription and ask if that's what they want to use
$currentSubscription = Get-AzContext | Select-Object -ExpandProperty Subscription
Write-Host "The currently-selected subscription is $($currentSubscription.Name) ($($currentSubscription.Id))."
$useCurrentSubscription = Read-Host "Do you want to use this subscription? (y/n)"
if ($useCurrentSubscription -eq "n") {
throw "Either specify a subscription using '-Subscription' or select a subscription using 'Select-AzSubscription' before running this script."
}
$Subscription = $currentSubscription.Id
}
$AzSubscription = (Get-AzSubscription -SubscriptionId $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1) ?? (Get-AzSubscription -SubscriptionName $Subscription -ErrorAction SilentlyContinue | Select-Object -First 1)
if (-not $AzSubscription) {
throw "The subscription '$Subscription' could not be found."
}
Set-AzContext $AzSubscription.Id | Out-Null
if (-not $ResourceGroup) {
# Check for the default resource group name
$DefaultResourceGroupName = $env:USERNAME + "-e2e-testing"
if (Get-AzResourceGroup -Name $DefaultResourceGroupName -ErrorAction SilentlyContinue) {
$ResourceGroup = $DefaultResourceGroupName
} else {
$ResourceGroup = Read-Host "Specify the name of the resource group to find the resources in."
}
}
$AzResourceGroup = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction SilentlyContinue
if (-not $AzResourceGroup) {
throw "The resource group '$ResourceGroup' could not be found. You have to create the resource group manually before running this script."
}
if (-not $ResourcePrefix) {
$defaultResourcePrefix = $env:USERNAME + "-e2e-"
# Check for one of the default resources
$defaultResource = Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceName "$($defaultResourcePrefix)cassandra" -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue
if ($defaultResource) {
Write-Host "Found a resource with the default resource prefix ($defaultResourcePrefix). Configuring that prefix for E2E testing."
$ResourcePrefix = $defaultResourcePrefix
} else {
$ResourcePrefix = Read-Host "Specify the resource prefix used in the resource names."
}
}
Write-Host "Cleaning E2E Testing Resources"
Write-Host " Subscription: $($AzSubscription.Name) ($($AzSubscription.Id))"
Write-Host " Resource Group: $($AzResourceGroup.ResourceGroupName)"
Write-Host " Resource Prefix: $ResourcePrefix"
Write-Host
Write-Host "All databases with the prefix '$DatabasePrefix' will be deleted."
# Confirm the deletion
$confirm = Read-Host "Are you sure you want to delete these resources? (y/n)"
if ($confirm -ne "y") {
Write-Host "Aborting."
exit
}
Get-AzResource -ResourceGroupName $AzResourceGroup.ResourceGroupName -ResourceType "Microsoft.DocumentDB/databaseAccounts" -ErrorAction SilentlyContinue | ForEach-Object {
$account = Get-AzCosmosDBAccount -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name -ErrorAction SilentlyContinue
if (-not $account) {
return
}
if ($account.Kind -eq "MongoDB") {
Write-Host " Cleaning Mongo Account: $($_.Name)"
Get-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBMongoDBDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableCassandra" }) {
Write-Host " Cleaning Cassandra Account: $($_.Name)"
Get-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Keyspace: $($_.Name)"
Remove-AzCosmosDBCassandraKeyspace -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableGremlin" }) {
Write-Host " Cleaning Gremlin Account: $($_.Name)"
Get-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBGremlinDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} elseif ($account.Capabilities | Where-Object { $_.Name -eq "EnableTable" }) {
Write-Host " Cleaning Table Account: $($_.Name)"
Get-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Table: $($_.Name)"
Remove-AzCosmosDBTable -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
} else {
Write-Host " Cleaning SQL Account: $($_.Name)"
Get-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName | Where-Object { $_.Name -like "$DatabasePrefix*" } | ForEach-Object {
Write-Host " Cleaning Database: $($_.Name)"
Remove-AzCosmosDBSqlDatabase -AccountName $account.Name -ResourceGroupName $AzResourceGroup.ResourceGroupName -Name $_.Name
}
}
}

View File

@@ -1,40 +1,51 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateDatabaseNameWithTimestamp, generateUniqueName } from "../fx";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("SQL database and container CRUD", async ({ page }) => {
const databaseId = generateDatabaseNameWithTimestamp();
const containerId = generateUniqueName("container");
const databaseId = generateUniqueName("db");
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
const explorer = await DataExplorer.open(page, TestAccount.SQL);
await explorer.globalCommandButton("New Container").click();
await explorer.whilePanelOpen("New Container", async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Container",
async (panel, okButton) => {
await panel.getByPlaceholder("Type a new database id").fill(databaseId);
await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId);
await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk");
await panel.getByLabel("Database max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode(databaseId);
await databaseNode.expand();
const containerNode = explorer.treeNode(`${databaseId}/${containerId}`);
const databaseNode = await explorer.waitForNode(databaseId);
const containerNode = await explorer.waitForContainerNode(databaseId, containerId);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("Delete Container").click();
await explorer.whilePanelOpen("Delete Container", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Container",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(containerNode.element).not.toBeAttached();
await databaseNode.openContextMenu();
await databaseNode.contextMenuItem("Delete Database").click();
await explorer.whilePanelOpen("Delete Database", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Database",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(databaseNode.element).not.toBeAttached();
});

91
test/sql/query.spec.ts Normal file
View File

@@ -0,0 +1,91 @@
import { expect, test } from "@playwright/test";
import { DataExplorer, Editor, QueryTab, TestAccount } from "../fx";
import { TestContainerContext, TestItem, createTestSQLContainer } from "../testData";
let context: TestContainerContext = null!;
let explorer: DataExplorer = null!;
let queryTab: QueryTab = null!;
let queryEditor: Editor = null!;
test.beforeAll("Create Test Database", async () => {
context = await createTestSQLContainer(true);
});
test.beforeEach("Open new query tab", async ({ page }) => {
// Open a query tab
explorer = await DataExplorer.open(page, TestAccount.SQL);
// Container nodes should be visible. The explorer auto-expands database nodes when they are first loaded.
const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id);
await containerNode.openContextMenu();
await containerNode.contextMenuItem("New SQL Query").click();
// Wait for the editor to load
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.afterAll("Delete Test Database", async () => {
await context?.dispose();
});
test("Query results", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
// Read the results
const resultText = await queryTab.resultsEditor.text();
expect(resultText).not.toBeNull();
const resultData: TestItem[] = JSON.parse(resultText!);
// Pick 3 random documents and assert them
const randomDocs = [0, 1, 2].map(() => resultData[Math.floor(Math.random() * resultData.length)]);
randomDocs.forEach((doc) => {
const matchingDoc = context?.testData.get(doc.id);
expect(matchingDoc).not.toBeNull();
expect(doc.randomData).toEqual(matchingDoc?.randomData);
expect(doc.partitionKey).toEqual(matchingDoc?.partitionKey);
});
});
test("Query stats", async () => {
// Run the query and verify the results
await queryEditor.locator.click();
const executeQueryButton = explorer.commandBarButton("Execute Query");
await executeQueryButton.click();
await expect(queryTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 });
// Open the query stats tab and validate some data there
queryTab.queryStatsTab.click();
await expect(queryTab.queryStatsList).toBeAttached();
const showingResultsCell = queryTab.queryStatsList.getByTestId("Row:Showing Results/Column:value");
await expect(showingResultsCell).toContainText(/\d+ - \d+/);
});
test("Query errors", async () => {
await queryEditor.locator.click();
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");
await executeQueryButton.click();
await expect(queryTab.errorList).toBeAttached({ timeout: 60 * 1000 });
// Validating the squiggles requires a lot of digging through the Monaco model, OR a screenshot comparison.
// The screenshot ended up being fairly flaky, and a pain to maintain, so I decided not to include validation for the squiggles.
// Validate the errors are in the list
await expect(queryTab.errorList.getByTestId("Row:0/Column:code")).toHaveText("SC2005");
await expect(queryTab.errorList.getByTestId("Row:0/Column:location")).toHaveText("Line 2");
await expect(queryTab.errorList.getByTestId("Row:1/Column:code")).toHaveText("SC2005");
await expect(queryTab.errorList.getByTestId("Row:1/Column:location")).toHaveText("Line 3");
});

View File

@@ -19,7 +19,7 @@ test("SQL account using Resource token", async ({ page }) => {
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const dbId = generateUniqueName("db");
const collectionId = generateUniqueName("col");
const collectionId = "testcollection";
const client = new CosmosClient({
endpoint: account.documentEndpoint!,
key: keys.primaryMasterKey,

View File

@@ -3,29 +3,33 @@ import { expect, test } from "@playwright/test";
import { DataExplorer, TestAccount, generateUniqueName } from "../fx";
test("Tables CRUD", async ({ page }) => {
const tableId = generateUniqueName("table");
const tableId = generateUniqueName("table"); // A unique table name IS needed because the database is shared when using Table Storage.
const explorer = await DataExplorer.open(page, TestAccount.Tables);
await explorer.globalCommandButton("New Table").click();
await explorer.whilePanelOpen("New Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
});
await explorer.whilePanelOpen(
"New Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Table id, Example Table1" }).fill(tableId);
await panel.getByLabel("Table Max RU/s").fill("1000");
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
const databaseNode = explorer.treeNode("TablesDB");
await databaseNode.expand();
const tableNode = explorer.treeNode(`TablesDB/${tableId}`);
await expect(tableNode.element).toBeAttached();
const tableNode = await explorer.waitForContainerNode("TablesDB", tableId);
await tableNode.openContextMenu();
await tableNode.contextMenuItem("Delete Table").click();
await explorer.whilePanelOpen("Delete Table", async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
});
await explorer.whilePanelOpen(
"Delete Table",
async (panel, okButton) => {
await panel.getByRole("textbox", { name: "Confirm by typing the table id" }).fill(tableId);
await okButton.click();
},
{ closeTimeout: 5 * 60 * 1000 },
);
await expect(tableNode.element).not.toBeAttached();
});

95
test/testData.ts Normal file
View File

@@ -0,0 +1,95 @@
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { BulkOperationType, Container, CosmosClient, Database, JSONObject } from "@azure/cosmos";
import crypto from "crypto";
import {
TestAccount,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "./fx";
export interface TestItem {
id: string;
partitionKey: string;
randomData: string;
}
const partitionCount = 4;
// If we increase this number, we need to split bulk creates into multiple batches.
// Bulk operations are limited to 100 items per partition.
const itemsPerPartition = 100;
function createTestItems(): TestItem[] {
const items: TestItem[] = [];
for (let i = 0; i < partitionCount; i++) {
for (let j = 0; j < itemsPerPartition; j++) {
const id = crypto.randomBytes(32).toString("base64");
items.push({
id,
partitionKey: `partition_${i}`,
randomData: crypto.randomBytes(32).toString("base64"),
});
}
}
return items;
}
export const TestData: TestItem[] = createTestItems();
export class TestContainerContext {
constructor(
public armClient: CosmosDBManagementClient,
public client: CosmosClient,
public database: Database,
public container: Container,
public testData: Map<string, TestItem>,
) {}
async dispose() {
await this.database.delete();
}
}
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
const credentials = await getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
const client = new CosmosClient({
endpoint: account.documentEndpoint!,
key: keys.primaryMasterKey,
});
const { database } = await client.databases.createIfNotExists({ id: databaseId });
try {
const { container } = await database.containers.createIfNotExists({
id: containerId,
partitionKey: "/partitionKey",
});
if (includeTestData) {
const batchCount = TestData.length / 100;
for (let i = 0; i < batchCount; i++) {
const batchItems = TestData.slice(i * 100, i * 100 + 100);
await container.items.bulk(
batchItems.map((item) => ({
operationType: BulkOperationType.Create,
resourceBody: item as unknown as JSONObject,
})),
);
}
}
const testDataMap = new Map<string, TestItem>();
TestData.forEach((item) => testDataMap.set(item.id, item));
return new TestContainerContext(armClient, client, database, container, testDataMap);
} catch (e) {
await database.delete();
throw e;
}
}