mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-04-17 03:49:23 +01:00
MSAL browser migration changes
This commit is contained in:
@@ -0,0 +1,229 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,703 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
3
.vs/ProjectSettings.json
Normal file
3
.vs/ProjectSettings.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"CurrentProjectSetting": null
|
||||||
|
}
|
||||||
7
.vs/VSWorkspaceState.json
Normal file
7
.vs/VSWorkspaceState.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"ExpandedNodes": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"SelectedNode": "\\utils",
|
||||||
|
"PreviewInSolutionExplorer": false
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1016
.vs/cosmos-explorer.slnx/config/applicationhost.config
Normal file
1016
.vs/cosmos-explorer.slnx/config/applicationhost.config
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.vs/cosmos-explorer.slnx/v18/.wsuo
Normal file
BIN
.vs/cosmos-explorer.slnx/v18/.wsuo
Normal file
Binary file not shown.
217
.vs/cosmos-explorer.slnx/v18/DocumentLayout.backup.json
Normal file
217
.vs/cosmos-explorer.slnx/v18/DocumentLayout.backup.json
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
235
.vs/cosmos-explorer.slnx/v18/DocumentLayout.json
Normal file
235
.vs/cosmos-explorer.slnx/v18/DocumentLayout.json
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/CodeChunks.db
Normal file
BIN
.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/CodeChunks.db
Normal file
Binary file not shown.
Binary file not shown.
BIN
.vs/slnx.sqlite
BIN
.vs/slnx.sqlite
Binary file not shown.
BIN
debug-editor-no-update-1767841423350.png
Normal file
BIN
debug-editor-no-update-1767841423350.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
debug-no-querytab-1767825118042.png
Normal file
BIN
debug-no-querytab-1767825118042.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
debug-no-querytab-1767826112580.png
Normal file
BIN
debug-no-querytab-1767826112580.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
debug-no-querytab-1767826639181.png
Normal file
BIN
debug-no-querytab-1767826639181.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
debug-pagination-broken-1767841424562.png
Normal file
BIN
debug-pagination-broken-1767841424562.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
debug-pagination-stuck-1767840650565.png
Normal file
BIN
debug-pagination-stuck-1767840650565.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
15
package-lock.json
generated
15
package-lock.json
generated
@@ -13,7 +13,7 @@
|
|||||||
"@azure/cosmos": "4.7.0",
|
"@azure/cosmos": "4.7.0",
|
||||||
"@azure/cosmos-language-service": "0.0.5",
|
"@azure/cosmos-language-service": "0.0.5",
|
||||||
"@azure/identity": "4.5.0",
|
"@azure/identity": "4.5.0",
|
||||||
"@azure/msal-browser": "2.14.2",
|
"@azure/msal-browser": "^5.2.0",
|
||||||
"@babel/plugin-proposal-class-properties": "7.12.1",
|
"@babel/plugin-proposal-class-properties": "7.12.1",
|
||||||
"@babel/plugin-proposal-decorators": "7.12.12",
|
"@babel/plugin-proposal-decorators": "7.12.12",
|
||||||
"@fluentui/react": "8.119.0",
|
"@fluentui/react": "8.119.0",
|
||||||
@@ -583,21 +583,22 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/@azure/msal-browser": {
|
"node_modules/@azure/msal-browser": {
|
||||||
"version": "2.14.2",
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-LLqyAtpQNfnATQKnplg/dKJaigxGaaMPrp003ZWGnWwsAmmtzk7xcHEVykCu/4FMyyIfn66NPPzxS9DHrg/UOA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-common": "^4.3.0"
|
"@azure/msal-common": "16.4.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.0"
|
"node": ">=0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@azure/msal-common": {
|
"node_modules/@azure/msal-common": {
|
||||||
"version": "4.5.1",
|
"version": "16.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.0.tgz",
|
||||||
|
"integrity": "sha512-twXt09PYtj1PffNNIAzQlrBd0DS91cdA6i1gAfzJ6BnPM4xNk5k9q/5xna7jLIjU3Jnp0slKYtucshGM8OGNAw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.0"
|
"node": ">=0.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"@azure/cosmos": "4.7.0",
|
"@azure/cosmos": "4.7.0",
|
||||||
"@azure/cosmos-language-service": "0.0.5",
|
"@azure/cosmos-language-service": "0.0.5",
|
||||||
"@azure/identity": "4.5.0",
|
"@azure/identity": "4.5.0",
|
||||||
"@azure/msal-browser": "2.14.2",
|
"@azure/msal-browser": "^5.2.0",
|
||||||
"@babel/plugin-proposal-class-properties": "7.12.1",
|
"@babel/plugin-proposal-class-properties": "7.12.1",
|
||||||
"@babel/plugin-proposal-decorators": "7.12.12",
|
"@babel/plugin-proposal-decorators": "7.12.12",
|
||||||
"@fluentui/react": "8.119.0",
|
"@fluentui/react": "8.119.0",
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export default class Explorer {
|
|||||||
updateUserContext({ aadToken: aadToken });
|
updateUserContext({ aadToken: aadToken });
|
||||||
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
useDataPlaneRbac.setState({ aadTokenUpdated: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) {
|
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) {
|
||||||
logConsoleError(
|
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",
|
"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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
|
import { AuthError as msalAuthError, BrowserAuthErrorCodes as msalBrowserAuthErrorCodes } from "@azure/msal-browser";
|
||||||
import {
|
import {
|
||||||
AuthError as msalAuthError,
|
Checkbox,
|
||||||
BrowserAuthErrorMessage as msalBrowserAuthErrorMessage,
|
ChoiceGroup,
|
||||||
} from "@azure/msal-browser";
|
DefaultButton,
|
||||||
import {
|
Dropdown,
|
||||||
Checkbox,
|
IChoiceGroupOption,
|
||||||
ChoiceGroup,
|
IDropdownOption,
|
||||||
DefaultButton,
|
ISpinButtonStyles,
|
||||||
Dropdown,
|
IToggleStyles,
|
||||||
IChoiceGroupOption,
|
Position,
|
||||||
IDropdownOption,
|
SpinButton,
|
||||||
ISpinButtonStyles,
|
Stack,
|
||||||
IToggleStyles,
|
Toggle,
|
||||||
Position,
|
|
||||||
SpinButton,
|
|
||||||
Stack,
|
|
||||||
Toggle,
|
|
||||||
} from "@fluentui/react";
|
} from "@fluentui/react";
|
||||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components";
|
||||||
import { AuthType } from "AuthType";
|
import { AuthType } from "AuthType";
|
||||||
@@ -26,20 +23,20 @@ import { useDialog } from "Explorer/Controls/Dialog";
|
|||||||
import { useDatabases } from "Explorer/useDatabases";
|
import { useDatabases } from "Explorer/useDatabases";
|
||||||
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
|
||||||
import {
|
import {
|
||||||
AppStateComponentNames,
|
AppStateComponentNames,
|
||||||
deleteAllStates,
|
deleteAllStates,
|
||||||
deleteState,
|
deleteState,
|
||||||
hasState,
|
hasState,
|
||||||
loadState,
|
loadState,
|
||||||
saveState,
|
saveState,
|
||||||
} from "Shared/AppStatePersistenceUtility";
|
} from "Shared/AppStatePersistenceUtility";
|
||||||
import {
|
import {
|
||||||
DefaultRUThreshold,
|
DefaultRUThreshold,
|
||||||
LocalStorageUtility,
|
LocalStorageUtility,
|
||||||
StorageKey,
|
StorageKey,
|
||||||
getDefaultQueryResultsView,
|
getDefaultQueryResultsView,
|
||||||
getRUThreshold,
|
getRUThreshold,
|
||||||
ruThresholdEnabled as isRUThresholdEnabled,
|
ruThresholdEnabled as isRUThresholdEnabled,
|
||||||
} from "Shared/StorageUtility";
|
} from "Shared/StorageUtility";
|
||||||
import * as StringUtility from "Shared/StringUtility";
|
import * as StringUtility from "Shared/StringUtility";
|
||||||
import { updateUserContext, userContext } from "UserContext";
|
import { updateUserContext, userContext } from "UserContext";
|
||||||
@@ -315,7 +312,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({
|
|||||||
} catch (authError) {
|
} catch (authError) {
|
||||||
if (
|
if (
|
||||||
authError instanceof msalAuthError &&
|
authError instanceof msalAuthError &&
|
||||||
authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code
|
authError.errorCode === msalBrowserAuthErrorCodes.popupWindowError
|
||||||
) {
|
) {
|
||||||
logConsoleError(
|
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`,
|
`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`,
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export function decryptJWTToken(token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getMsalInstance() {
|
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 = {
|
const msalConfig: msal.Configuration = {
|
||||||
cache: {
|
cache: {
|
||||||
cacheLocation: "localStorage",
|
cacheLocation: "localStorage",
|
||||||
@@ -56,14 +59,18 @@ export async function getMsalInstance() {
|
|||||||
auth: {
|
auth: {
|
||||||
authority: `${configContext.AAD_ENDPOINT}organizations`,
|
authority: `${configContext.AAD_ENDPOINT}organizations`,
|
||||||
clientId: "203f1145-856a-4232-83d4-a43568fba23d",
|
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") {
|
if (process.env.NODE_ENV === "development" && !window.location.hostname.includes("localhost")) {
|
||||||
msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net";
|
msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html";
|
||||||
}
|
}
|
||||||
|
|
||||||
const msalInstance = new msal.PublicClientApplication(msalConfig);
|
const msalInstance = new msal.PublicClientApplication(msalConfig);
|
||||||
|
// v3+ requires explicit initialization before using MSAL APIs
|
||||||
|
await msalInstance.initialize();
|
||||||
return msalInstance;
|
return msalInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,12 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q)
|
||||||
|
const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await msalInstance.loginPopup({
|
const response = await msalInstance.loginPopup({
|
||||||
redirectUri: config.msalRedirectURI,
|
redirectUri: redirectBridgeUrl,
|
||||||
scopes: [],
|
scopes: [],
|
||||||
});
|
});
|
||||||
setLoggedIn();
|
setLoggedIn();
|
||||||
@@ -89,9 +92,11 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
if (!msalInstance || !config) {
|
if (!msalInstance || !config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q)
|
||||||
|
const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`;
|
||||||
try {
|
try {
|
||||||
const response = await msalInstance.loginPopup({
|
const response = await msalInstance.loginPopup({
|
||||||
redirectUri: config.msalRedirectURI,
|
redirectUri: redirectBridgeUrl,
|
||||||
authority: `${config.AAD_ENDPOINT}${id}`,
|
authority: `${config.AAD_ENDPOINT}${id}`,
|
||||||
scopes: [],
|
scopes: [],
|
||||||
});
|
});
|
||||||
@@ -120,7 +125,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType {
|
|||||||
setArmToken(armToken);
|
setArmToken(armToken);
|
||||||
setAuthFailure(null);
|
setAuthFailure(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) {
|
if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) {
|
||||||
// This error can occur when acquireTokenWithMsal() has attempted to acquire token interactively
|
// 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
|
// 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
|
// action. In this case, we display the failure and a link to repeat the operation. Clicking on the
|
||||||
|
|||||||
14
src/redirectBridge.html
Normal file
14
src/redirectBridge.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!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>
|
||||||
16
src/redirectBridge.ts
Normal file
16
src/redirectBridge.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
11
src/types/msal-browser-redirect-bridge.d.ts
vendored
Normal file
11
src/types/msal-browser-redirect-bridge.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// 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>;
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
|
galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx",
|
||||||
selfServe: "./src/SelfServe/SelfServe.tsx",
|
selfServe: "./src/SelfServe/SelfServe.tsx",
|
||||||
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
|
connectToGitHub: "./src/GitHub/GitHubConnector.ts",
|
||||||
|
redirectBridge: "./src/redirectBridge.ts",
|
||||||
...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }),
|
...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,6 +166,11 @@ module.exports = function (_env = {}, argv = {}) {
|
|||||||
template: "src/SelfServe/selfServe.html",
|
template: "src/SelfServe/selfServe.html",
|
||||||
chunks: ["selfServe"],
|
chunks: ["selfServe"],
|
||||||
}),
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: "redirectBridge.html",
|
||||||
|
template: "src/redirectBridge.html",
|
||||||
|
chunks: ["redirectBridge"],
|
||||||
|
}),
|
||||||
...(mode !== "production"
|
...(mode !== "production"
|
||||||
? [
|
? [
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
|
|||||||
Reference in New Issue
Block a user