diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/39D77E4DA1804736E39178139E5048A6 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/39D77E4DA1804736E39178139E5048A6 new file mode 100644 index 000000000..906282863 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/39D77E4DA1804736E39178139E5048A6 @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/76CE5452071DA53B49DA7250DF70857E b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/76CE5452071DA53B49DA7250DF70857E new file mode 100644 index 000000000..7110f2c25 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/76CE5452071DA53B49DA7250DF70857E @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/A536882AB0DBA450A09BC67D815A0A27 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/A536882AB0DBA450A09BC67D815A0A27 new file mode 100644 index 000000000..906282863 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/A536882AB0DBA450A09BC67D815A0A27 @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/BDC0B1042C11B58A5259E50EBB63DC01 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/BDC0B1042C11B58A5259E50EBB63DC01 new file mode 100644 index 000000000..e9d5e2325 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/049D74CB6EC3174D9ACE5C016AC60AE5/BDC0B1042C11B58A5259E50EBB63DC01 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/37107C32C08AA184BD281864D44763B6 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/37107C32C08AA184BD281864D44763B6 new file mode 100644 index 000000000..e9d5e2325 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/37107C32C08AA184BD281864D44763B6 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/B708CF3CBCBAD0788B92EF15E6396B0D b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/B708CF3CBCBAD0788B92EF15E6396B0D new file mode 100644 index 000000000..9c0dd0d7a --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/B708CF3CBCBAD0788B92EF15E6396B0D @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/BDC0B1042C11B58A5259E50EBB63DC01 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/BDC0B1042C11B58A5259E50EBB63DC01 new file mode 100644 index 000000000..e9d5e2325 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/96BE2FBCD6372F42971253EC90544052/BDC0B1042C11B58A5259E50EBB63DC01 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/BDC0B1042C11B58A5259E50EBB63DC01 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/BDC0B1042C11B58A5259E50EBB63DC01 new file mode 100644 index 000000000..9c0dd0d7a --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/BDC0B1042C11B58A5259E50EBB63DC01 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/FB327B239331C5DD2358B4E83D63183E b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/FB327B239331C5DD2358B4E83D63183E new file mode 100644 index 000000000..9c0dd0d7a --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/BC13077299BF7643B6C9034B8CDDB754/FB327B239331C5DD2358B4E83D63183E @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/06FFEB74F9428A8CCD2BF1741B12A42C b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/06FFEB74F9428A8CCD2BF1741B12A42C new file mode 100644 index 000000000..906282863 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/06FFEB74F9428A8CCD2BF1741B12A42C @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/39D77E4DA1804736E39178139E5048A6 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/39D77E4DA1804736E39178139E5048A6 new file mode 100644 index 000000000..906282863 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/39D77E4DA1804736E39178139E5048A6 @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/4A297F95ADD1E83DB8F022AF3B1B1E74 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/4A297F95ADD1E83DB8F022AF3B1B1E74 new file mode 100644 index 000000000..9222cb557 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/4A297F95ADD1E83DB8F022AF3B1B1E74 @@ -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 }, + ); + }); + }); +}); diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/BDC0B1042C11B58A5259E50EBB63DC01 b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/BDC0B1042C11B58A5259E50EBB63DC01 new file mode 100644 index 000000000..e9d5e2325 --- /dev/null +++ b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/F622CD2C03D62348869FA84BB1647168/BDC0B1042C11B58A5259E50EBB63DC01 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/state.mpack b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/state.mpack new file mode 100644 index 000000000..7ce6172b0 Binary files /dev/null and b/.vs/CopilotSnapshots/548E56D5117B4249B654F2DE5EA27C25/state.mpack differ diff --git a/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/76EC84F90637547DE632CC6E34850A12 b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/76EC84F90637547DE632CC6E34850A12 new file mode 100644 index 000000000..9c0dd0d7a --- /dev/null +++ b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/76EC84F90637547DE632CC6E34850A12 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/88CD68F6C261251F69442B2733965940 b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/88CD68F6C261251F69442B2733965940 new file mode 100644 index 000000000..262545aa1 --- /dev/null +++ b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/88CD68F6C261251F69442B2733965940 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/BDC0B1042C11B58A5259E50EBB63DC01 b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/BDC0B1042C11B58A5259E50EBB63DC01 new file mode 100644 index 000000000..262545aa1 --- /dev/null +++ b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/8C84C14214C34148A24F6EDEC13E1656/BDC0B1042C11B58A5259E50EBB63DC01 @@ -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 { + 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.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 { + 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 { + 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 { + await this.element.click({ button: "right" }); + } + + contextMenuItem(name: string): Locator { + return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`); + } + + async expand(): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + return await this.waitForNode(`${databaseId}/${containerId}/Items`); + } + + async waitForContainerDocumentsNode(databaseId: string, containerId: string): Promise { + return await this.waitForNode(`${databaseId}/${containerId}/Documents`); + } + + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { + const commandBar = this.commandBarButton(label); + await commandBar.waitFor({ state: "visible", timeout }); + return commandBar; + } + + async waitForDialogButton(label: string, timeout?: number): Promise { + 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, + options?: PanelOpenOptions, + ): Promise { + 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 { + 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 { + await this.setNotificationConsoleExpanded(true); + } + + async collapseNotificationConsole(): Promise { + await this.setNotificationConsoleExpanded(false); + } + + async setNotificationConsoleExpanded(expanded: boolean): Promise { + 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 { + await this.setNotificationConsoleExpanded(true); + return this.frame.getByTestId("NotificationConsole/Contents"); + } + + async getDropdownItemByName(name: string, ariaLabel?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const url = await getTestExplorerUrl(testAccount, { iframeSrc, enablecontainercopy: true }); + await page.goto(url); + return ContainerCopy.waitForContainerCopy(page); + } +} diff --git a/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/state.mpack b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/state.mpack new file mode 100644 index 000000000..24eee73cd Binary files /dev/null and b/.vs/CopilotSnapshots/D94A63B0B7BF1348926679F1C2D0F6F9/state.mpack differ diff --git a/.vs/ProjectSettings.json b/.vs/ProjectSettings.json new file mode 100644 index 000000000..f8b488856 --- /dev/null +++ b/.vs/ProjectSettings.json @@ -0,0 +1,3 @@ +{ + "CurrentProjectSetting": null +} \ No newline at end of file diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json new file mode 100644 index 000000000..e9f6a167d --- /dev/null +++ b/.vs/VSWorkspaceState.json @@ -0,0 +1,7 @@ +{ + "ExpandedNodes": [ + "" + ], + "SelectedNode": "\\utils", + "PreviewInSolutionExplorer": false +} \ No newline at end of file diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/094519c6-1a5b-4adf-bffa-370670d65672.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/094519c6-1a5b-4adf-bffa-370670d65672.vsidx new file mode 100644 index 000000000..bc1921957 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/094519c6-1a5b-4adf-bffa-370670d65672.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/393b64f7-3776-4c53-a0ae-59b642adf304.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/393b64f7-3776-4c53-a0ae-59b642adf304.vsidx new file mode 100644 index 000000000..e18d66823 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/393b64f7-3776-4c53-a0ae-59b642adf304.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/53c17dc0-d230-402a-87bf-e04fc2b680f0.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/53c17dc0-d230-402a-87bf-e04fc2b680f0.vsidx new file mode 100644 index 000000000..90f4d5f06 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/53c17dc0-d230-402a-87bf-e04fc2b680f0.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/6d8db478-e113-47cc-b2ac-a5735e54484b.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/6d8db478-e113-47cc-b2ac-a5735e54484b.vsidx new file mode 100644 index 000000000..9bd6648c5 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/6d8db478-e113-47cc-b2ac-a5735e54484b.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/8de9df71-a4ad-4e18-9ff2-76551d7a1b62.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/8de9df71-a4ad-4e18-9ff2-76551d7a1b62.vsidx new file mode 100644 index 000000000..eddd41fdf Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/8de9df71-a4ad-4e18-9ff2-76551d7a1b62.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/FileContentIndex/bf9da8bf-cf23-41b4-8edd-31fcc6973de6.vsidx b/.vs/cosmos-explorer.slnx/FileContentIndex/bf9da8bf-cf23-41b4-8edd-31fcc6973de6.vsidx new file mode 100644 index 000000000..5e8bdcfdc Binary files /dev/null and b/.vs/cosmos-explorer.slnx/FileContentIndex/bf9da8bf-cf23-41b4-8edd-31fcc6973de6.vsidx differ diff --git a/.vs/cosmos-explorer.slnx/config/applicationhost.config b/.vs/cosmos-explorer.slnx/config/applicationhost.config new file mode 100644 index 000000000..0d88f0db3 --- /dev/null +++ b/.vs/cosmos-explorer.slnx/config/applicationhost.config @@ -0,0 +1,1016 @@ + + + + + + + +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/2e5ec468/sessions/b0634ad9-bfb7-4813-9266-79f1c2d0f6f9 b/.vs/cosmos-explorer.slnx/copilot-chat/2e5ec468/sessions/b0634ad9-bfb7-4813-9266-79f1c2d0f6f9 new file mode 100644 index 000000000..66cd9c05f Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/2e5ec468/sessions/b0634ad9-bfb7-4813-9266-79f1c2d0f6f9 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/3c45e421-9f5e-4a6b-ac2d-8336fac4a365 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/3c45e421-9f5e-4a6b-ac2d-8336fac4a365 new file mode 100644 index 000000000..8fccc7176 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/3c45e421-9f5e-4a6b-ac2d-8336fac4a365 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/5a583057-3675-443a-a280-bde9b502525c b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/5a583057-3675-443a-a280-bde9b502525c new file mode 100644 index 000000000..cf321d447 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/5a583057-3675-443a-a280-bde9b502525c differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/789645ca-6927-4190-8fce-0453e6d9e810 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/789645ca-6927-4190-8fce-0453e6d9e810 new file mode 100644 index 000000000..ecb1c8d31 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/789645ca-6927-4190-8fce-0453e6d9e810 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/a60f5d5b-a040-440a-a221-4fb20f0e91c9 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/a60f5d5b-a040-440a-a221-4fb20f0e91c9 new file mode 100644 index 000000000..7f32c93f8 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/a60f5d5b-a040-440a-a221-4fb20f0e91c9 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/aaee2f9c-0370-4b22-a917-5196d9bfc46e b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/aaee2f9c-0370-4b22-a917-5196d9bfc46e new file mode 100644 index 000000000..c77f49581 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/aaee2f9c-0370-4b22-a917-5196d9bfc46e differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d5568e54-7b11-4942-b654-f2de5ea27c25 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d5568e54-7b11-4942-b654-f2de5ea27c25 new file mode 100644 index 000000000..b96c3bf3a Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d5568e54-7b11-4942-b654-f2de5ea27c25 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d822ae7a-02b6-4cc3-b6cb-7e44e67fb576 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d822ae7a-02b6-4cc3-b6cb-7e44e67fb576 new file mode 100644 index 000000000..0f9e3f4b8 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/d822ae7a-02b6-4cc3-b6cb-7e44e67fb576 differ diff --git a/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/ecd00b42-06ad-4140-bf41-d50da8f9f5b6 b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/ecd00b42-06ad-4140-bf41-d50da8f9f5b6 new file mode 100644 index 000000000..c56d95f9a Binary files /dev/null and b/.vs/cosmos-explorer.slnx/copilot-chat/3bf6af7d/sessions/ecd00b42-06ad-4140-bf41-d50da8f9f5b6 differ diff --git a/.vs/cosmos-explorer.slnx/v18/.wsuo b/.vs/cosmos-explorer.slnx/v18/.wsuo new file mode 100644 index 000000000..3ad90af73 Binary files /dev/null and b/.vs/cosmos-explorer.slnx/v18/.wsuo differ diff --git a/.vs/cosmos-explorer.slnx/v18/DocumentLayout.backup.json b/.vs/cosmos-explorer.slnx/v18/DocumentLayout.backup.json new file mode 100644 index 000000000..10f49ae06 --- /dev/null +++ b/.vs/cosmos-explorer.slnx/v18/DocumentLayout.backup.json @@ -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" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.vs/cosmos-explorer.slnx/v18/DocumentLayout.json b/.vs/cosmos-explorer.slnx/v18/DocumentLayout.json new file mode 100644 index 000000000..66d9549f0 --- /dev/null +++ b/.vs/cosmos-explorer.slnx/v18/DocumentLayout.json @@ -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" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/CodeChunks.db b/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/CodeChunks.db new file mode 100644 index 000000000..0734908fe Binary files /dev/null and b/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/CodeChunks.db differ diff --git a/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/SemanticSymbols.db b/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/SemanticSymbols.db new file mode 100644 index 000000000..5b1ec5710 Binary files /dev/null and b/.vs/cosmos-explorer/CopilotIndices/18.0.934.24903/SemanticSymbols.db differ diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite index 2891048dc..e369da08c 100644 Binary files a/.vs/slnx.sqlite and b/.vs/slnx.sqlite differ diff --git a/debug-editor-no-update-1767841423350.png b/debug-editor-no-update-1767841423350.png new file mode 100644 index 000000000..3345fc1c3 Binary files /dev/null and b/debug-editor-no-update-1767841423350.png differ diff --git a/debug-no-querytab-1767825118042.png b/debug-no-querytab-1767825118042.png new file mode 100644 index 000000000..dd66e6865 Binary files /dev/null and b/debug-no-querytab-1767825118042.png differ diff --git a/debug-no-querytab-1767826112580.png b/debug-no-querytab-1767826112580.png new file mode 100644 index 000000000..33ad41577 Binary files /dev/null and b/debug-no-querytab-1767826112580.png differ diff --git a/debug-no-querytab-1767826639181.png b/debug-no-querytab-1767826639181.png new file mode 100644 index 000000000..f3f6eeb33 Binary files /dev/null and b/debug-no-querytab-1767826639181.png differ diff --git a/debug-pagination-broken-1767841424562.png b/debug-pagination-broken-1767841424562.png new file mode 100644 index 000000000..3345fc1c3 Binary files /dev/null and b/debug-pagination-broken-1767841424562.png differ diff --git a/debug-pagination-stuck-1767840650565.png b/debug-pagination-stuck-1767840650565.png new file mode 100644 index 000000000..8609a2f56 Binary files /dev/null and b/debug-pagination-stuck-1767840650565.png differ diff --git a/package-lock.json b/package-lock.json index e4d926a9f..c53ef7c56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@azure/cosmos": "4.7.0", "@azure/cosmos-language-service": "0.0.5", "@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-decorators": "7.12.12", "@fluentui/react": "8.119.0", @@ -583,21 +583,22 @@ "license": "0BSD" }, "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", "dependencies": { - "@azure/msal-common": "^4.3.0" + "@azure/msal-common": "16.4.0" }, "engines": { "node": ">=0.8.0" } }, "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", - "dependencies": { - "debug": "^4.1.1" - }, "engines": { "node": ">=0.8.0" } diff --git a/package.json b/package.json index d6c8734ff..8663912d1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@azure/cosmos": "4.7.0", "@azure/cosmos-language-service": "0.0.5", "@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-decorators": "7.12.12", "@fluentui/react": "8.119.0", diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index 3b4295bf7..7ce652ef3 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -278,7 +278,7 @@ export default class Explorer { updateUserContext({ aadToken: aadToken }); useDataPlaneRbac.setState({ aadTokenUpdated: true }); } catch (error) { - if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorMessage.popUpWindowError.code) { + if (error instanceof msal.AuthError && error.errorCode === msal.BrowserAuthErrorCodes.popupWindowError) { 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", ); diff --git a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx index 69cca5fc5..2af58a004 100644 --- a/src/Explorer/Panes/SettingsPane/SettingsPane.tsx +++ b/src/Explorer/Panes/SettingsPane/SettingsPane.tsx @@ -1,20 +1,17 @@ +import { AuthError as msalAuthError, BrowserAuthErrorCodes as msalBrowserAuthErrorCodes } from "@azure/msal-browser"; import { - AuthError as msalAuthError, - BrowserAuthErrorMessage as msalBrowserAuthErrorMessage, -} from "@azure/msal-browser"; -import { - Checkbox, - ChoiceGroup, - DefaultButton, - Dropdown, - IChoiceGroupOption, - IDropdownOption, - ISpinButtonStyles, - IToggleStyles, - Position, - SpinButton, - Stack, - Toggle, + Checkbox, + ChoiceGroup, + DefaultButton, + Dropdown, + IChoiceGroupOption, + IDropdownOption, + ISpinButtonStyles, + IToggleStyles, + Position, + SpinButton, + Stack, + Toggle, } from "@fluentui/react"; import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, makeStyles } from "@fluentui/react-components"; import { AuthType } from "AuthType"; @@ -26,20 +23,20 @@ import { useDialog } from "Explorer/Controls/Dialog"; import { useDatabases } from "Explorer/useDatabases"; import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; import { - AppStateComponentNames, - deleteAllStates, - deleteState, - hasState, - loadState, - saveState, + AppStateComponentNames, + deleteAllStates, + deleteState, + hasState, + loadState, + saveState, } from "Shared/AppStatePersistenceUtility"; import { - DefaultRUThreshold, - LocalStorageUtility, - StorageKey, - getDefaultQueryResultsView, - getRUThreshold, - ruThresholdEnabled as isRUThresholdEnabled, + DefaultRUThreshold, + LocalStorageUtility, + StorageKey, + getDefaultQueryResultsView, + getRUThreshold, + ruThresholdEnabled as isRUThresholdEnabled, } from "Shared/StorageUtility"; import * as StringUtility from "Shared/StringUtility"; import { updateUserContext, userContext } from "UserContext"; @@ -315,7 +312,7 @@ export const SettingsPane: FunctionComponent<{ explorer: Explorer }> = ({ } catch (authError) { if ( authError instanceof msalAuthError && - authError.errorCode === msalBrowserAuthErrorMessage.popUpWindowError.code + authError.errorCode === msalBrowserAuthErrorCodes.popupWindowError ) { 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`, diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 84d4cfcc4..d4c42a216 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -49,6 +49,9 @@ export function decryptJWTToken(token: string) { } export async function getMsalInstance() { + // Compute the redirect bridge URL for MSAL v5 COOP handling + const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`; + const msalConfig: msal.Configuration = { cache: { cacheLocation: "localStorage", @@ -56,14 +59,18 @@ export async function getMsalInstance() { auth: { authority: `${configContext.AAD_ENDPOINT}organizations`, clientId: "203f1145-856a-4232-83d4-a43568fba23d", + // MSAL v5 requires redirect bridge for popup/silent flows (CG alert MVS-2026-vmmw-f85q) + redirectUri: redirectBridgeUrl, }, }; - if (process.env.NODE_ENV === "development") { - msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net"; + if (process.env.NODE_ENV === "development" && !window.location.hostname.includes("localhost")) { + msalConfig.auth.redirectUri = "https://dataexplorer-dev.azurewebsites.net/redirectBridge.html"; } const msalInstance = new msal.PublicClientApplication(msalConfig); + // v3+ requires explicit initialization before using MSAL APIs + await msalInstance.initialize(); return msalInstance; } diff --git a/src/hooks/useAADAuth.ts b/src/hooks/useAADAuth.ts index 6a927dca0..146aff8a6 100644 --- a/src/hooks/useAADAuth.ts +++ b/src/hooks/useAADAuth.ts @@ -59,9 +59,12 @@ export function useAADAuth(config?: ConfigContext): ReturnType { return; } + // Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q) + const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`; + try { const response = await msalInstance.loginPopup({ - redirectUri: config.msalRedirectURI, + redirectUri: redirectBridgeUrl, scopes: [], }); setLoggedIn(); @@ -89,9 +92,11 @@ export function useAADAuth(config?: ConfigContext): ReturnType { if (!msalInstance || !config) { return; } + // Use redirect bridge for MSAL v5 COOP handling (CG alert MVS-2026-vmmw-f85q) + const redirectBridgeUrl = `${window.location.origin}/redirectBridge.html`; try { const response = await msalInstance.loginPopup({ - redirectUri: config.msalRedirectURI, + redirectUri: redirectBridgeUrl, authority: `${config.AAD_ENDPOINT}${id}`, scopes: [], }); @@ -120,7 +125,7 @@ export function useAADAuth(config?: ConfigContext): ReturnType { setArmToken(armToken); setAuthFailure(null); } 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 // 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 diff --git a/src/redirectBridge.html b/src/redirectBridge.html new file mode 100644 index 000000000..7f4ada149 --- /dev/null +++ b/src/redirectBridge.html @@ -0,0 +1,14 @@ + + + + + + Authentication Redirect + + + +
+

Processing authentication...

+
+ + diff --git a/src/redirectBridge.ts b/src/redirectBridge.ts new file mode 100644 index 000000000..ffeab752a --- /dev/null +++ b/src/redirectBridge.ts @@ -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); +}); diff --git a/src/types/msal-browser-redirect-bridge.d.ts b/src/types/msal-browser-redirect-bridge.d.ts new file mode 100644 index 000000000..33d10d4a5 --- /dev/null +++ b/src/types/msal-browser-redirect-bridge.d.ts @@ -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; +} diff --git a/webpack.config.js b/webpack.config.js index 7dcc89828..006c96da4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -116,6 +116,7 @@ module.exports = function (_env = {}, argv = {}) { galleryViewer: "./src/GalleryViewer/GalleryViewer.tsx", selfServe: "./src/SelfServe/SelfServe.tsx", connectToGitHub: "./src/GitHub/GitHubConnector.ts", + redirectBridge: "./src/redirectBridge.ts", ...(mode !== "production" && { testExplorer: "./test/testExplorer/TestExplorer.ts" }), }; @@ -165,6 +166,11 @@ module.exports = function (_env = {}, argv = {}) { template: "src/SelfServe/selfServe.html", chunks: ["selfServe"], }), + new HtmlWebpackPlugin({ + filename: "redirectBridge.html", + template: "src/redirectBridge.html", + chunks: ["redirectBridge"], + }), ...(mode !== "production" ? [ new HtmlWebpackPlugin({