Revert "MSAL browser migration changes"

This reverts commit b855094fb1.
This commit is contained in:
Sindhu Balasubramanian
2026-03-29 18:20:35 -07:00
parent b855094fb1
commit 60a65efb7b
58 changed files with 43 additions and 9987 deletions

View File

@@ -1,229 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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.getConsoleHeaderStatus()).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();
await expect(explorer.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,239 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
try {
await dbContext?.dispose();
} catch (error) {
// Ignore cleanup errors if browser/page was already closed due to timeout
console.warn("Cleanup error (possibly due to test timeout):", error);
}
});
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 () => {
test.setTimeout(5 * ONE_MINUTE_MS); // 5 minutes timeout
// 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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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 () => {
test.setTimeout(3 * ONE_MINUTE_MS); // 3 minutes timeout
// 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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
});
test("Scale shared database from manual to autoscale", async () => {
test.setTimeout(3 * ONE_MINUTE_MS); // 3 minutes timeout
// 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();
await expect(explorer.getConsoleHeaderStatus()).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 () => {
test.setTimeout(3 * ONE_MINUTE_MS); // 3 minutes timeout
// 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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
});
test("Scale shared database from autoscale to manual", async () => {
test.setTimeout(3 * ONE_MINUTE_MS); // 3 minutes timeout
// 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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,229 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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.getConsoleHeaderStatus()).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();
await expect(explorer.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,703 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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 {
// Use a longer timeout (30s) since expanding may require loading children from the server
await tree.waitFor({ state: "visible", timeout: 30 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,703 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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 {
// Use a longer timeout (30s) since expanding may require loading children from the server
await tree.waitFor({ state: "visible", timeout: 30 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,703 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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 {
// Use a longer timeout (30s) since expanding may require loading children from the server
await tree.waitFor({ state: "visible", timeout: 30 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,229 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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.getConsoleHeaderStatus()).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();
await expect(explorer.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,229 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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.getConsoleHeaderStatus()).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();
await expect(explorer.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,230 +0,0 @@
import { Locator, expect, test } from "@playwright/test";
import {
CommandBarButton,
DataExplorer,
ONE_MINUTE_MS,
TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K,
TEST_MANUAL_THROUGHPUT_RU,
TestAccount,
} from "../../fx";
import { TestDatabaseContext, createTestDB } from "../../testData";
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("Delete Test Database", async () => {
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
const newContainerButton = await explorer.globalCommandButton("New Container");
await newContainerButton.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.getConsoleHeaderStatus()).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();
await expect(explorer.getConsoleHeaderStatus()).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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{
timeout: 2 * ONE_MINUTE_MS,
},
);
});
test("Scale shared database from autoscale to manual", async () => {
test.setTimeout(3 * ONE_MINUTE_MS); // 3 minutes timeout
// 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.getConsoleHeaderStatus()).toContainText(
`Successfully updated offer for database ${dbContext.database.id}`,
{ timeout: 2 * ONE_MINUTE_MS },
);
});
});
});

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,703 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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 {
// Use a longer timeout (30s) since expanding may require loading children from the server
await tree.waitFor({ state: "visible", timeout: 30 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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", timeout: 3 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,702 +0,0 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import { TestContainerContext } from "./testData";
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
export function generateUniqueName(baseName: string, 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 function getAzureCLICredentials(): DefaultAzureCredential {
return new DefaultAzureCredential();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
const credentials = getAzureCLICredentials();
const token = (await credentials.getToken("https://management.core.windows.net//.default"))?.token || "";
return token;
}
export enum TestAccount {
Tables = "Tables",
Cassandra = "Cassandra",
Gremlin = "Gremlin",
Mongo = "Mongo",
MongoReadonly = "MongoReadOnly",
Mongo32 = "Mongo32",
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
}
export const defaultAccounts: Record<TestAccount, string> = {
[TestAccount.Tables]: "github-e2etests-tables",
[TestAccount.Cassandra]: "github-e2etests-cassandra",
[TestAccount.Gremlin]: "github-e2etests-gremlin",
[TestAccount.Mongo]: "github-e2etests-mongo",
[TestAccount.MongoReadonly]: "github-e2etests-mongo-readonly",
[TestAccount.Mongo32]: "github-e2etests-mongo32",
[TestAccount.SQL]: "github-e2etests-sql",
[TestAccount.SQLReadOnly]: "github-e2etests-sql-readonly",
[TestAccount.SQLContainerCopyOnly]: "github-e2etests-sql-containercopyonly",
};
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) {
const actualPrefix = process.env.DE_TEST_ACCOUNT_PREFIX.endsWith("-")
? process.env.DE_TEST_ACCOUNT_PREFIX
: `${process.env.DE_TEST_ACCOUNT_PREFIX}-`;
return `${actualPrefix}${accountType.toLocaleLowerCase()}`;
}
}
export function getAccountName(accountType: TestAccount) {
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
defaultAccounts[accountType]
);
}
type TestExplorerUrlOptions = {
iframeSrc?: string;
enablecontainercopy?: boolean;
};
export async function getTestExplorerUrl(accountType: TestAccount, options?: TestExplorerUrlOptions): Promise<string> {
const { iframeSrc, enablecontainercopy } = options ?? {};
// We can't retrieve AZ CLI credentials from the browser so we get them here.
const token = await getAzureCLICredentialsToken();
const accountName = getAccountName(accountType);
const params = new URLSearchParams();
params.set("accountName", accountName);
params.set("resourceGroup", resourceGroupName);
params.set("subscriptionId", subscriptionId);
params.set("token", token);
// There seem to be occasional CORS issues with calling the copilot APIs (/api/tokens/sampledataconnection/v2, for example)
// For now, since we don't test copilot, we can disable the copilot APIs by setting the feature flag to false.
params.set("feature.enableCopilot", "false");
const nosqlRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN;
const nosqlReadOnlyRbacToken = process.env.NOSQL_READONLY_TESTACCOUNT_TOKEN;
const nosqlContainerCopyRbacToken = process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN;
const tableRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN;
const gremlinRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN;
const cassandraRbacToken = process.env.CASSANDRA_TESTACCOUNT_TOKEN;
const mongoRbacToken = process.env.MONGO_TESTACCOUNT_TOKEN;
const mongo32RbacToken = process.env.MONGO32_TESTACCOUNT_TOKEN;
const mongoReadOnlyRbacToken = process.env.MONGO_READONLY_TESTACCOUNT_TOKEN;
switch (accountType) {
case TestAccount.SQL:
if (nosqlRbacToken) {
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:
if (nosqlContainerCopyRbacToken) {
params.set("nosqlRbacToken", nosqlContainerCopyRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLReadOnly:
if (nosqlReadOnlyRbacToken) {
params.set("nosqlReadOnlyRbacToken", nosqlReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Tables:
if (tableRbacToken) {
params.set("tableRbacToken", tableRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Gremlin:
if (gremlinRbacToken) {
params.set("gremlinRbacToken", gremlinRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Cassandra:
if (cassandraRbacToken) {
params.set("cassandraRbacToken", cassandraRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo:
if (mongoRbacToken) {
params.set("mongoRbacToken", mongoRbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.Mongo32:
if (mongo32RbacToken) {
params.set("mongo32RbacToken", mongo32RbacToken);
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.MongoReadonly:
if (mongoReadOnlyRbacToken) {
params.set("mongoReadOnlyRbacToken", mongoReadOnlyRbacToken);
params.set("enableaaddataplane", "true");
}
break;
}
if (iframeSrc) {
params.set("iframeSrc", iframeSrc);
}
return `https://localhost:1234/testExplorer.html?${params.toString()}`;
}
type DropdownItemExpectations = {
ariaLabel?: string;
itemCount?: number;
};
type DropdownItemMatcher = {
name?: string;
position?: number;
};
export async function getDropdownItemByNameOrPosition(
frame: Frame,
matcher?: DropdownItemMatcher,
expectedOptions?: DropdownItemExpectations,
): Promise<Locator> {
const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items");
if (expectedOptions?.ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(expectedOptions.ariaLabel);
}
if (expectedOptions?.itemCount) {
const items = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
await expect(items).toHaveCount(expectedOptions.itemCount);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
if (matcher?.name) {
return containerDropdownItems.filter({ hasText: matcher.name });
} else if (matcher?.position !== undefined) {
return containerDropdownItems.nth(matcher.position);
}
// Return first item if no matcher is provided
return containerDropdownItems.first();
}
/** Helper class that provides locator methods for TreeNode elements, on top of a Locator */
class TreeNode {
constructor(
public element: Locator,
public frame: Frame,
public id: string,
) {}
async openContextMenu(): Promise<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
const tree = this.frame.getByTestId(`Tree:${this.id}`);
// 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", timeout: 3 * 1000 });
// 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;
}
// 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);
}
}
export class DocumentsTab {
documentsFilter: Locator;
documentsListPane: Locator;
documentResultsPane: Locator;
resultsEditor: Editor;
loadMoreButton: Locator;
filterInput: Locator;
filterButton: Locator;
constructor(
public frame: Frame,
public tabId: string,
public tab: Locator,
public locator: Locator,
) {
this.documentsFilter = this.locator.getByTestId("DocumentsTab/Filter");
this.documentsListPane = this.locator.getByTestId("DocumentsTab/DocumentsPane");
this.documentResultsPane = this.locator.getByTestId("DocumentsTab/ResultsPane");
this.resultsEditor = new Editor(this.frame, this.documentResultsPane.getByTestId("EditorReact/Host/Loaded"));
this.loadMoreButton = this.documentsListPane.getByTestId("DocumentsTab/LoadMore");
this.filterInput = this.documentsFilter.getByTestId("DocumentsTab/FilterInput");
this.filterButton = this.documentsFilter.getByTestId("DocumentsTab/ApplyFilter");
}
async setFilter(text: string) {
await this.filterInput.fill(text);
}
}
type PanelOpenOptions = {
closeTimeout?: number;
};
export enum CommandBarButton {
Save = "Save",
Delete = "Delete",
Execute = "Execute",
ExecuteQuery = "Execute Query",
UploadItem = "Upload Item",
NewDocument = "New Document",
}
/** 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);
}
documentsTab(tabId: string): DocumentsTab {
const tab = this.tab(tabId);
const documentsTab = tab.getByTestId("DocumentsTab");
return new DocumentsTab(this.frame, tabId, tab, documentsTab);
}
/** 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.
*/
async globalCommandButton(label: string): Promise<Locator> {
await this.frame.getByTestId("GlobalCommands").click();
return this.frame.getByRole("menuitem", { name: label });
}
/** Select the command bar button with the specified label */
commandBarButton(commandBarButton: CommandBarButton): Locator {
return this.frame.getByTestId(`CommandBar/Button:${commandBarButton}`).and(this.frame.locator("css=button"));
}
dialogButton(label: string): Locator {
return this.frame.getByTestId(`DialogButton:${label}`).and(this.frame.locator("css=button"));
}
/** Select the side panel with the specified title */
panel(title: string): Locator {
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}`);
}
async waitForContainerItemsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Items`);
}
async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise<TreeNode> {
return await this.waitForNode(`${databaseId}/${containerId}/Documents`);
}
async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise<Locator> {
const commandBar = this.commandBarButton(label);
await commandBar.waitFor({ state: "visible", timeout });
return commandBar;
}
async waitForDialogButton(label: string, timeout?: number): Promise<Locator> {
const dialogButton = this.dialogButton(label);
await dialogButton.waitFor({ timeout });
return dialogButton;
}
/** 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>,
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", 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 */
getConsoleHeaderStatus(): Locator {
return this.frame.getByTestId("notification-console/header-status");
}
async expandNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(true);
}
async collapseNotificationConsole(): Promise<void> {
await this.setNotificationConsoleExpanded(false);
}
async setNotificationConsoleExpanded(expanded: boolean): Promise<void> {
const notificationConsoleToggleButton = this.frame.getByTestId("NotificationConsole/ExpandCollapseButton");
const alt = await notificationConsoleToggleButton.locator("img").getAttribute("alt");
// When expanded, the icon says "Collapse icon"
if (expanded && alt === "Expand icon") {
await notificationConsoleToggleButton.click();
} else if (!expanded && alt === "Collapse icon") {
await notificationConsoleToggleButton.click();
}
}
async getNotificationConsoleMessages(): Promise<Locator> {
await this.setNotificationConsoleExpanded(true);
return this.frame.getByTestId("NotificationConsole/Contents");
}
async getDropdownItemByName(name: string, ariaLabel?: string): Promise<Locator> {
const dropdownItemsWrapper = this.frame.locator("div.ms-Dropdown-items");
if (ariaLabel) {
expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual(ariaLabel);
}
const containerDropdownItems = dropdownItemsWrapper.locator("button.ms-Dropdown-item[role='option']");
return containerDropdownItems.filter({ hasText: name });
}
/** Waits for the Data Explorer app to load */
static async waitForExplorer(page: Page, options?: TestExplorerUrlOptions): Promise<DataExplorer> {
const iframeElement = await page.getByTestId("DataExplorerFrame").elementHandle();
if (iframeElement === null) {
throw new Error("Explorer iframe not found");
}
const explorerFrame = await iframeElement.contentFrame();
if (explorerFrame === null) {
throw new Error("Explorer frame not found");
}
if (!options?.enablecontainercopy) {
await explorerFrame?.getByTestId("DataExplorerRoot").waitFor();
}
return new DataExplorer(explorerFrame);
}
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc });
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}
export async function waitForApiResponse(
page: Page,
urlPattern: string,
method?: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payloadValidator?: (payload: any) => boolean,
) {
try {
// Check if page is still valid before waiting
if (page.isClosed()) {
throw new Error(`Page is closed, cannot wait for API response: ${urlPattern}`);
}
return page.waitForResponse(
async (response) => {
const request = response.request();
if (!request.url().includes(urlPattern)) {
return false;
}
if (method && request.method() !== method) {
return false;
}
if (payloadValidator && (request.method() === "POST" || request.method() === "PUT")) {
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
return payloadValidator(payload);
} catch {
return false;
}
}
}
return true;
},
{ timeout: 60 * 1000 },
);
} catch (error) {
if (error instanceof Error && error.message.includes("Target page, context or browser has been closed")) {
console.warn("Page was closed while waiting for API response:", urlPattern);
throw new Error(`Page closed while waiting for API response: ${urlPattern}`);
}
throw error;
}
}
export async function interceptAndInspectApiRequest(
page: Page,
urlPattern: string,
method: string = "POST",
error: Error,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
errorValidator: (url?: string, payload?: any) => boolean,
): Promise<void> {
await page.route(
(url) => url.pathname.includes(urlPattern),
async (route, request) => {
if (request.method() !== method) {
await route.continue();
return;
}
const postData = request.postData();
if (postData) {
try {
const payload = JSON.parse(postData);
if (errorValidator && errorValidator(request.url(), payload)) {
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
code: "Conflict",
message: error.message,
}),
});
return;
}
} catch (err) {
if (err instanceof Error && err.message.includes("not allowed")) {
throw err;
}
}
}
await route.continue();
},
);
}
export class ContainerCopy {
constructor(
public frame: Frame,
public wrapper: Locator,
) {}
static async waitForContainerCopy(page: Page): Promise<ContainerCopy> {
const explorerFrame = await DataExplorer.waitForExplorer(page, { enablecontainercopy: true });
const containerCopyWrapper = explorerFrame.frame.locator("div#containerCopyWrapper");
return new ContainerCopy(explorerFrame.frame, containerCopyWrapper);
}
static async open(page: Page, testAccount: TestAccount, iframeSrc?: string): Promise<ContainerCopy> {
const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true });
await page.goto(url);
return ContainerCopy.waitForContainerCopy(page);
}
}

View File

@@ -1,3 +0,0 @@
{
"CurrentProjectSetting": null
}

View File

@@ -1,7 +0,0 @@
{
"ExpandedNodes": [
""
],
"SelectedNode": "\\utils",
"PreviewInSolutionExplorer": false
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,217 +0,0 @@
{
"Version": 1,
"WorkspaceRootPath": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\",
"Documents": [
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\testData.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\mongo\\pagination.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\container.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\query.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:src\\Common\\MongoProxyClient.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\scale.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\settings.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\computedProperties.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\containercopy.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\resources\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
}
],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": [
{
"DockedWidth": 200,
"SelectedChildIndex": 1,
"Children": [
{
"$type": "Bookmark",
"Name": "ST:0:0:{56df62a4-05a3-4e5b-aa1a-99371ccfb997}"
},
{
"$type": "Document",
"DocumentIndex": 0,
"Title": "testData.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts",
"RelativeDocumentMoniker": "test\\testData.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts",
"RelativeToolTip": "test\\testData.ts",
"ViewState": "AgIAAGsAAACol5mZmZknwLAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-15T01:02:33.958Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 4,
"Title": "query.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\query.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts",
"RelativeToolTip": "test\\sql\\query.spec.ts",
"ViewState": "AgIAAAcAAAAAMzMzMzMrwB0AAABNAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T00:33:23.32Z"
},
{
"$type": "Document",
"DocumentIndex": 8,
"Title": "computedProperties.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"ViewState": "AgIAABkAAAAAAAAAAAAuwC4AAAAHAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:14.962Z"
},
{
"$type": "Document",
"DocumentIndex": 1,
"Title": "sharedThroughput.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAkAAAA+AAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T20:50:42.998Z"
},
{
"$type": "Document",
"DocumentIndex": 2,
"Title": "pagination.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts",
"RelativeDocumentMoniker": "test\\mongo\\pagination.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts",
"RelativeToolTip": "test\\mongo\\pagination.spec.ts",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T04:48:29.613Z"
},
{
"$type": "Document",
"DocumentIndex": 3,
"Title": "container.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\container.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts",
"RelativeToolTip": "test\\sql\\container.spec.ts",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAQAAAAGAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T04:05:21.4Z"
},
{
"$type": "Document",
"DocumentIndex": 5,
"Title": "MongoProxyClient.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts",
"RelativeDocumentMoniker": "src\\Common\\MongoProxyClient.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts",
"RelativeToolTip": "src\\Common\\MongoProxyClient.ts",
"ViewState": "AgIAABMAAABAZmZmZqYvwCgAAABRAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-09T07:08:06.873Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "settings.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\settings.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\settings.spec.ts",
"ViewState": "AgIAAAsAAAAAAAAAAAAuwCQAAAAGAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:50.263Z"
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "scale.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\scale.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\scale.spec.ts",
"ViewState": "AgIAAAgAAAAAAAAAAAAuwB0AAAAHAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:27.321Z"
},
{
"$type": "Document",
"DocumentIndex": 9,
"Title": "containercopy.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\containercopy.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts",
"RelativeToolTip": "test\\sql\\containercopy.spec.ts",
"ViewState": "AgIAABsAAAAAAAAAAAAuwDAAAAAIAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:09.469Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"Title": "README.md",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md",
"RelativeDocumentMoniker": "test\\README.md",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md",
"RelativeToolTip": "test\\README.md",
"ViewState": "AgIAADcAAADAlpmZmdkhwF4AAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-01-13T20:44:59.892Z"
},
{
"$type": "Document",
"DocumentIndex": 11,
"Title": "README.md",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md",
"RelativeDocumentMoniker": "test\\resources\\README.md",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md",
"RelativeToolTip": "test\\resources\\README.md",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-01-13T20:44:58.329Z"
}
]
}
]
}
]
}

View File

@@ -1,235 +0,0 @@
{
"Version": 1,
"WorkspaceRootPath": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\",
"Documents": [
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\fx.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\fx.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\testData.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\mongo\\pagination.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\container.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\query.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:src\\Common\\MongoProxyClient.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\scale.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\settings.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\scaleAndSettings\\computedProperties.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\sql\\containercopy.spec.ts||{0F2454B1-A556-402D-A7D0-1FDE7F99DEE0}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:test\\resources\\README.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
}
],
"DocumentGroupContainers": [
{
"Orientation": 0,
"VerticalTabListWidth": 256,
"DocumentGroups": [
{
"DockedWidth": 200,
"SelectedChildIndex": 1,
"Children": [
{
"$type": "Bookmark",
"Name": "ST:0:0:{56df62a4-05a3-4e5b-aa1a-99371ccfb997}"
},
{
"$type": "Document",
"DocumentIndex": 0,
"Title": "fx.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\fx.ts",
"RelativeDocumentMoniker": "test\\fx.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\fx.ts",
"RelativeToolTip": "test\\fx.ts",
"ViewState": "AgIAAGsCAAAANzMzMzMMwHMCAAANAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-02-05T00:31:11.555Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 2,
"Title": "testData.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts",
"RelativeDocumentMoniker": "test\\testData.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\testData.ts",
"RelativeToolTip": "test\\testData.ts",
"ViewState": "AgIAAGoAAACol5mZmZknwK8AAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-15T01:02:33.958Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 5,
"Title": "query.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\query.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\query.spec.ts",
"RelativeToolTip": "test\\sql\\query.spec.ts",
"ViewState": "AgIAAAcAAAAAMzMzMzMrwB0AAABNAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T00:33:23.32Z"
},
{
"$type": "Document",
"DocumentIndex": 9,
"Title": "computedProperties.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\computedProperties.spec.ts",
"ViewState": "AgIAABkAAAAAAAAAAAAuwC4AAAAHAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:14.962Z"
},
{
"$type": "Document",
"DocumentIndex": 1,
"Title": "sharedThroughput.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\sharedThroughput.spec.ts",
"ViewState": "AgIAAJMAAAAASAAAAIAZwJwAAAApAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T20:50:42.998Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 3,
"Title": "pagination.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts",
"RelativeDocumentMoniker": "test\\mongo\\pagination.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\mongo\\pagination.spec.ts",
"RelativeToolTip": "test\\mongo\\pagination.spec.ts",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T04:48:29.613Z"
},
{
"$type": "Document",
"DocumentIndex": 4,
"Title": "container.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\container.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\container.spec.ts",
"RelativeToolTip": "test\\sql\\container.spec.ts",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAQAAAAGAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-14T04:05:21.4Z"
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "MongoProxyClient.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts",
"RelativeDocumentMoniker": "src\\Common\\MongoProxyClient.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\src\\Common\\MongoProxyClient.ts",
"RelativeToolTip": "src\\Common\\MongoProxyClient.ts",
"ViewState": "AgIAABMAAABAZmZmZqYvwCgAAABRAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-09T07:08:06.873Z"
},
{
"$type": "Document",
"DocumentIndex": 8,
"Title": "settings.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\settings.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\settings.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\settings.spec.ts",
"ViewState": "AgIAAAsAAAAAAAAAAAAuwCQAAAAGAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:50.263Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "scale.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\scaleAndSettings\\scale.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\scaleAndSettings\\scale.spec.ts",
"RelativeToolTip": "test\\sql\\scaleAndSettings\\scale.spec.ts",
"ViewState": "AgIAAAgAAAAAAAAAAAAuwB0AAAAHAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:27.321Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"Title": "containercopy.spec.ts",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts",
"RelativeDocumentMoniker": "test\\sql\\containercopy.spec.ts",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\sql\\containercopy.spec.ts",
"RelativeToolTip": "test\\sql\\containercopy.spec.ts",
"ViewState": "AgIAABsAAAAAAAAAAAAuwDAAAAAIAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.003213|",
"WhenOpened": "2026-01-13T21:25:09.469Z"
},
{
"$type": "Document",
"DocumentIndex": 11,
"Title": "README.md",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md",
"RelativeDocumentMoniker": "test\\README.md",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\README.md",
"RelativeToolTip": "test\\README.md",
"ViewState": "AgIAADcAAADAlpmZmdkhwF4AAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-01-13T20:44:59.892Z"
},
{
"$type": "Document",
"DocumentIndex": 12,
"Title": "README.md",
"DocumentMoniker": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md",
"RelativeDocumentMoniker": "test\\resources\\README.md",
"ToolTip": "C:\\Users\\sindhuba\\workspace\\DataExplorer\\cosmos-explorer\\test\\resources\\README.md",
"RelativeToolTip": "test\\resources\\README.md",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-01-13T20:44:58.329Z"
}
]
}
]
}
]
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

15
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "^5.2.0",
"@azure/msal-browser": "2.14.2",
"@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12",
"@fluentui/react": "8.119.0",
@@ -583,22 +583,21 @@
"license": "0BSD"
},
"node_modules/@azure/msal-browser": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.0.tgz",
"integrity": "sha512-LLqyAtpQNfnATQKnplg/dKJaigxGaaMPrp003ZWGnWwsAmmtzk7xcHEVykCu/4FMyyIfn66NPPzxS9DHrg/UOA==",
"version": "2.14.2",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "16.4.0"
"@azure/msal-common": "^4.3.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-common": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.0.tgz",
"integrity": "sha512-twXt09PYtj1PffNNIAzQlrBd0DS91cdA6i1gAfzJ6BnPM4xNk5k9q/5xna7jLIjU3Jnp0slKYtucshGM8OGNAw==",
"version": "4.5.1",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1"
},
"engines": {
"node": ">=0.8.0"
}

View File

@@ -8,7 +8,7 @@
"@azure/cosmos": "4.7.0",
"@azure/cosmos-language-service": "0.0.5",
"@azure/identity": "4.5.0",
"@azure/msal-browser": "^5.2.0",
"@azure/msal-browser": "2.14.2",
"@babel/plugin-proposal-class-properties": "7.12.1",
"@babel/plugin-proposal-decorators": "7.12.12",
"@fluentui/react": "8.119.0",

View File

@@ -278,7 +278,7 @@ export default class Explorer {
updateUserContext({ aadToken: aadToken });
useDataPlaneRbac.setState({ aadTokenUpdated: true });
} catch (error) {
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) {
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) {
logConsoleError(
"We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and try again",
);

View File

@@ -1,4 +1,7 @@
import { AuthError as msalAuthError, BrowserAuthErrorCodes as msalBrowserAuthErrorCodes } from "@azure/msal-browser";
import {
AuthError as msalAuthError,
BrowserAuthErrorMessage as msalBrowserAuthErrorMessage,
} from "@azure/msal-browser";
import {
Checkbox,
ChoiceGroup,
@@ -312,7 +315,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
} catch (authError) {
if (
authError instanceof msalAuthError &&
authError.errorCode === msalBrowserAuthErrorCodes.popupWindowError
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
) {
logConsoleError(
`We were unable to establish authorization for this account, due to pop-ups being disabled in the browser.\nPlease enable pop-ups for this site and click on "Login for Entra ID" button`,

View File

@@ -49,9 +49,6 @@ export function decryptJWTToken(token: string) {
}
export async function getMsalInstance() {
// Compute the redirect bridge URL for MSAL v5 COOP handling
const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`;
const msalConfig: msal.Configuration = {
cache: {
cacheLocation: "localStorage",
@@ -59,18 +56,14 @@ export async function getMsalInstance() {
auth: {
authority: `${configContext.AAD_ENDPOINT}organizations`,
clientId: "203f1145-856a-4232-83d4-a43568fba23d",
// MSAL v5 requires redirect bridge for popup/silent flows (CG alert MVS-2026-vmmw-f85q)
redirectUri: redirectBridgeUrl,
},
};
if (process.env.NODE_ENV === "development" && !window.location.hostname.includes("localhost")) {
msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html";
if (process.env.NODE_ENV === "development") {
msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net";
}
const msalInstance = new msal.PublicClientApplication(msalConfig);
// v3+ requires explicit initialization before using MSAL APIs
await msalInstance.initialize();
return msalInstance;
}

View File

@@ -59,12 +59,9 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
return;
}
// Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q)
const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`;
try {
const response = await msalInstance.loginPopup({
redirectUri: redirectBridgeUrl,
redirectUri: config.msalRedirectURI,
scopes: [],
});
setLoggedIn();
@@ -92,11 +89,9 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
if (!msalInstance || !config) {
return;
}
// Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q)
const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`;
try {
const response = await msalInstance.loginPopup({
redirectUri: redirectBridgeUrl,
redirectUri: config.msalRedirectURI,
authority: `${config.AAD_ENDPOINT}${id}`,
scopes: [],
});
@@ -125,7 +120,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
setArmToken(armToken);
setAuthFailure(null);
} catch (error) {
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) {
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) {
// This error can occur when acquireTokenWithMsal() has attempted to acquire token interactively
// and user has popups disabled in browser. This fails as the popup is not the result of a explicit user
// action. In this case, we display the failure and a link to repeat the operation. Clicking on the

View File

@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Authentication Redirect</title>
</head>
<body>
<!-- MSAL COOP Redirect Bridge - handles auth response from Identity Provider -->
<div id="redirect-container">
<p>Processing authentication...</p>
</div>
</body>
</html>

View File

@@ -1,16 +0,0 @@
/**
* MSAL COOP Redirect Bridge
*
* This page handles the authentication response from the Identity Provider (IdP)
* and broadcasts it to the main application frame. Required for msal-browser v5+
* to securely handle auth responses when the IdP sets Cross-Origin-Opener-Policy headers.
*
* Security Note: This file must be bundled with your application, NOT loaded from a CDN.
*
* CG Alert: MVS-2026-vmmw-f85q
*/
import { broadcastResponseToMainFrame } from "@azure/msal-browser/redirect-bridge";
broadcastResponseToMainFrame().catch((error: unknown) => {
console.error("MSAL redirect bridge error:", error);
});

View File

@@ -1,11 +0,0 @@
// Type declarations for @azure/msal-browser subpath exports
// Required because tsconfig uses moduleResolution: "node" which doesn't support exports field
declare module "@azure/msal-browser/redirect-bridge" {
/**
* Processes the authentication response from the redirect URL.
* For SSO and popup scenarios broadcasts it to the main frame.
* For redirect scenario navigates to the home page.
*/
export function broadcastResponseToMainFrame(navigationClient?: unknown): Promise<void>;
}

View File

@@ -116,7 +116,6 @@ module.exports = function (_env = {}, argv = {}) {
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
selfServe: "./src/SelfServe/SelfServe.tsx",
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
redirectBridge: "./src/redirectBridge.ts",
...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }),
};
@@ -166,11 +165,6 @@ module.exports = function (_env = {}, argv = {}) {
template: "src/SelfServe/selfServe.html",
chunks: ["selfServe"],
}),
new HtmlWebpackPlugin({
filename: "redirectBridge.html",
template: "src/redirectBridge.html",
chunks: ["redirectBridge"],
}),
...(mode !== "production"
? [
new HtmlWebpackPlugin({