From 92c8afd166bd23505ed751027ed8971ebca241bf Mon Sep 17 00:00:00 2001 From: asier-isayas Date: Fri, 9 Jan 2026 08:23:35 -0800 Subject: [PATCH] More playwright tests (#2310) * Add playwright tests for Autoscale/Manual Throughpout and TTL * fix unit tests and lint * fix unit tests * fix tests * fix autoscale selector * changed throughput above limit * Add more playwright tests * fix tests * nit * cleanup * format * stored procedure playwright test * add user defined function playwright test * Add user defined functions and trigger test * fix upload items * fix tests * fix lint errors * fix lint * run cleanup every 3 hours * keep cleanup at 2 hours --------- Co-authored-by: Asier Isayas --- .../dataAccess/createStoredProcedure.ts | 19 +-- src/Common/dataAccess/createTrigger.ts | 19 +-- .../dataAccess/createUserDefinedFunction.ts | 20 +-- .../dataAccess/deleteStoredProcedure.ts | 1 + src/Common/dataAccess/deleteTrigger.ts | 1 + .../dataAccess/deleteUserDefinedFunction.ts | 1 + .../ComputedPropertiesComponent.tsx | 7 +- .../SubSettingsComponent.tsx | 4 +- .../ComputedPropertiesComponent.test.tsx.snap | 1 + .../SubSettingsComponent.test.tsx.snap | 10 ++ .../Panes/UploadItemsPane/UploadItemsPane.tsx | 2 +- .../StoredProcedureTabComponent.tsx | 1 - test/fx.ts | 2 + test/sql/document.spec.ts | 117 +++++++++++++++++- .../computedProperties.spec.ts | 108 ++++++++++++++++ test/sql/scaleAndSettings/settings.spec.ts | 26 +++- test/sql/scripts/storedProcedure.spec.ts | 78 ++++++++++++ test/sql/scripts/trigger.spec.ts | 80 ++++++++++++ test/sql/scripts/userDefinedFunction.spec.ts | 82 ++++++++++++ test/testData.ts | 16 ++- 20 files changed, 559 insertions(+), 36 deletions(-) create mode 100644 test/sql/scaleAndSettings/computedProperties.spec.ts create mode 100644 test/sql/scripts/storedProcedure.spec.ts create mode 100644 test/sql/scripts/trigger.spec.ts create mode 100644 test/sql/scripts/userDefinedFunction.spec.ts diff --git a/src/Common/dataAccess/createStoredProcedure.ts b/src/Common/dataAccess/createStoredProcedure.ts index ab7839f3e..28f67846b 100644 --- a/src/Common/dataAccess/createStoredProcedure.ts +++ b/src/Common/dataAccess/createStoredProcedure.ts @@ -9,7 +9,7 @@ import { SqlStoredProcedureCreateUpdateParameters, SqlStoredProcedureResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createStoredProcedure( ): Promise { const clearMessage = logConsoleProgress(`Creating stored procedure ${storedProcedure.id}`); try { + let resource: StoredProcedureDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,16 @@ export async function createStoredProcedure( storedProcedure.id, createSprocParams, ); - return rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + resource = rpResponse && (rpResponse.properties?.resource as StoredProcedureDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.storedProcedures.create(storedProcedure); + resource = response.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.storedProcedures.create(storedProcedure); - return response?.resource; + logConsoleInfo(`Successfully created stored procedure ${storedProcedure.id}`); + return resource; } catch (error) { handleError(error, "CreateStoredProcedure", `Error while creating stored procedure ${storedProcedure.id}`); throw error; diff --git a/src/Common/dataAccess/createTrigger.ts b/src/Common/dataAccess/createTrigger.ts index dd4ec1af5..c1cda22be 100644 --- a/src/Common/dataAccess/createTrigger.ts +++ b/src/Common/dataAccess/createTrigger.ts @@ -3,7 +3,7 @@ import { AuthType } from "../../AuthType"; import { userContext } from "../../UserContext"; import { createUpdateSqlTrigger, getSqlTrigger } from "../../Utils/arm/generatedClients/cosmos/sqlResources"; import { SqlTriggerCreateUpdateParameters, SqlTriggerResource } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -14,6 +14,7 @@ export async function createTrigger( ): Promise { const clearMessage = logConsoleProgress(`Creating trigger ${trigger.id}`); try { + let resource: SqlTriggerResource | TriggerDefinition; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -52,14 +53,16 @@ export async function createTrigger( trigger.id, createTriggerParams, ); - return rpResponse && rpResponse.properties?.resource; + resource = rpResponse && rpResponse.properties?.resource; + } else { + const sdkResponse = await client() + .database(databaseId) + .container(collectionId) + .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type + resource = sdkResponse.resource; } - - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.triggers.create(trigger as unknown as TriggerDefinition); // TODO: TypeScript does not like the SQL SDK trigger type - return response.resource; + logConsoleInfo(`Successfully created trigger ${trigger.id}`); + return resource; } catch (error) { handleError(error, "CreateTrigger", `Error while creating trigger ${trigger.id}`); throw error; diff --git a/src/Common/dataAccess/createUserDefinedFunction.ts b/src/Common/dataAccess/createUserDefinedFunction.ts index 3d1bca86e..c2ac4c489 100644 --- a/src/Common/dataAccess/createUserDefinedFunction.ts +++ b/src/Common/dataAccess/createUserDefinedFunction.ts @@ -9,7 +9,7 @@ import { SqlUserDefinedFunctionCreateUpdateParameters, SqlUserDefinedFunctionResource, } from "../../Utils/arm/generatedClients/cosmos/types"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; +import { logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { client } from "../CosmosClient"; import { handleError } from "../ErrorHandlingUtils"; @@ -20,6 +20,7 @@ export async function createUserDefinedFunction( ): Promise { const clearMessage = logConsoleProgress(`Creating user defined function ${userDefinedFunction.id}`); try { + let resource: UserDefinedFunctionDefinition & Resource; if ( userContext.authType === AuthType.AAD && !userContext.features.enableSDKoperations && @@ -60,14 +61,17 @@ export async function createUserDefinedFunction( userDefinedFunction.id, createUDFParams, ); - return rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); - } - const response = await client() - .database(databaseId) - .container(collectionId) - .scripts.userDefinedFunctions.create(userDefinedFunction); - return response?.resource; + resource = rpResponse && (rpResponse.properties?.resource as UserDefinedFunctionDefinition & Resource); + } else { + const response = await client() + .database(databaseId) + .container(collectionId) + .scripts.userDefinedFunctions.create(userDefinedFunction); + resource = response.resource; + } + logConsoleInfo(`Successfully created user defined function ${userDefinedFunction.id}`); + return resource; } catch (error) { handleError( error, diff --git a/src/Common/dataAccess/deleteStoredProcedure.ts b/src/Common/dataAccess/deleteStoredProcedure.ts index 403b707ff..61ad16127 100644 --- a/src/Common/dataAccess/deleteStoredProcedure.ts +++ b/src/Common/dataAccess/deleteStoredProcedure.ts @@ -28,6 +28,7 @@ export async function deleteStoredProcedure( } else { await client().database(databaseId).container(collectionId).scripts.storedProcedure(storedProcedureId).delete(); } + logConsoleProgress(`Successfully deleted stored procedure ${storedProcedureId}`); } catch (error) { handleError(error, "DeleteStoredProcedure", `Error while deleting stored procedure ${storedProcedureId}`); throw error; diff --git a/src/Common/dataAccess/deleteTrigger.ts b/src/Common/dataAccess/deleteTrigger.ts index 22b77f009..568f4cefe 100644 --- a/src/Common/dataAccess/deleteTrigger.ts +++ b/src/Common/dataAccess/deleteTrigger.ts @@ -24,6 +24,7 @@ export async function deleteTrigger(databaseId: string, collectionId: string, tr } else { await client().database(databaseId).container(collectionId).scripts.trigger(triggerId).delete(); } + logConsoleProgress(`Successfully deleted trigger ${triggerId}`); } catch (error) { handleError(error, "DeleteTrigger", `Error while deleting trigger ${triggerId}`); throw error; diff --git a/src/Common/dataAccess/deleteUserDefinedFunction.ts b/src/Common/dataAccess/deleteUserDefinedFunction.ts index ee70b803c..d551cfbf0 100644 --- a/src/Common/dataAccess/deleteUserDefinedFunction.ts +++ b/src/Common/dataAccess/deleteUserDefinedFunction.ts @@ -24,6 +24,7 @@ export async function deleteUserDefinedFunction(databaseId: string, collectionId } else { await client().database(databaseId).container(collectionId).scripts.userDefinedFunction(id).delete(); } + logConsoleProgress(`Successfully deleted user defined function ${id}`); } catch (error) { handleError(error, "DeleteUserDefinedFunction", `Error while deleting user defined function ${id}`); throw error; diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx index a0bfc2116..039a66304 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/ComputedPropertiesComponent.tsx @@ -155,7 +155,12 @@ export class ComputedPropertiesComponent extends React.Component<   about how to define computed properties and how to use them. -
+
); } diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx index 648395722..e2da4db0b 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/SubSettingsComponent.tsx @@ -302,8 +302,8 @@ export class SubSettingsComponent extends React.Component ( diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap index d5684b825..9646cf995 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/ComputedPropertiesComponent.test.tsx.snap @@ -31,6 +31,7 @@ exports[`ComputedPropertiesComponent renders 1`] = `
diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap index 10bf3b17a..3a710db53 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/__snapshots__/SubSettingsComponent.test.tsx.snap @@ -167,10 +167,12 @@ exports[`SubSettingsComponent analyticalTimeToLive hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -652,10 +654,12 @@ exports[`SubSettingsComponent analyticalTimeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1224,10 +1228,12 @@ exports[`SubSettingsComponent changeFeedPolicy hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -1760,10 +1766,12 @@ exports[`SubSettingsComponent renders 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, @@ -2330,10 +2338,12 @@ exports[`SubSettingsComponent timeToLiveSeconds hidden 1`] = ` options={ [ { + "ariaLabel": "geography-option", "key": "Geography", "text": "Geography", }, { + "ariaLabel": "geometry-option", "key": "Geometry", "text": "Geometry", }, diff --git a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx index 778634d71..47d7fd846 100644 --- a/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx +++ b/src/Explorer/Panes/UploadItemsPane/UploadItemsPane.tsx @@ -205,7 +205,7 @@ export const UploadItemsPane: FunctionComponent = ({ onUpl tooltip="Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB. You can perform multiple upload operations for larger data sets." /> {uploadFileData?.length > 0 && ( -
+
File upload status { diff --git a/test/fx.ts b/test/fx.ts index c1c2b6a47..1de8be90d 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -378,7 +378,9 @@ type PanelOpenOptions = { export enum CommandBarButton { Save = "Save", + Execute = "Execute", ExecuteQuery = "Execute Query", + UploadItem = "Upload Item", } /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 95cdd112a..5d17c22c3 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -1,7 +1,18 @@ import { expect, test } from "@playwright/test"; -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; -import { retry, setPartitionKeys } from "../testData"; +import { existsSync, mkdtempSync, rmdirSync, unlinkSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { CommandBarButton, DataExplorer, DocumentsTab, ONE_MINUTE_MS, TestAccount } from "../fx"; +import { + createTestSQLContainer, + itemsPerPartition, + partitionCount, + retry, + setPartitionKeys, + TestContainerContext, + TestData, +} from "../testData"; import { documentTestCases } from "./testCases"; let explorer: DataExplorer = null!; @@ -95,3 +106,105 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { } }); } + +test.describe.serial("Upload Item", () => { + let context: TestContainerContext = null!; + let uploadDocumentDirPath: string = null!; + let uploadDocumentFilePath: string = null!; + + test.beforeAll("Create Test database and open documents tab", async ({ browser }) => { + uploadDocumentDirPath = mkdtempSync(path.join(tmpdir(), "upload-document-")); + uploadDocumentFilePath = path.join(uploadDocumentDirPath, "uploadDocument.json"); + + const page = await browser.newPage(); + context = await createTestSQLContainer(); + explorer = await DataExplorer.open(page, TestAccount.SQL); + + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + const containerMenuNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await containerMenuNode.element.click(); + // We need to click twice in order to remove a tooltip + await containerMenuNode.element.click(); + }); + + test.afterAll("Delete Test Database and uploadDocument temp folder", async () => { + if (existsSync(uploadDocumentFilePath)) { + unlinkSync(uploadDocumentFilePath); + } + if (existsSync(uploadDocumentDirPath)) { + rmdirSync(uploadDocumentDirPath); + } + if (!process.env.CI) { + await context?.dispose(); + } + }); + + test.afterEach("Close Upload Items panel if still open", async () => { + const closeUploadItemsPanelButton = explorer.frame.getByLabel("Close Upload Items"); + if (await closeUploadItemsPanelButton.isVisible()) { + await closeUploadItemsPanelButton.click(); + } + }); + + test("upload document", async () => { + // Create file to upload + const TestDataJsonString: string = JSON.stringify(TestData, null, 2); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload success message + const fileUploadStatusExpected: string = `${partitionCount * itemsPerPartition} created, 0 throttled, 0 errors`; + const fileUploadStatus = explorer.frame.getByTestId("file-upload-status"); + await expect(fileUploadStatus).toContainText(fileUploadStatusExpected, { + timeout: ONE_MINUTE_MS, + }); + + // Select file to upload again + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + await uploadButton.click(); + + // Verify upload failure message + const errorIcon = explorer.frame.getByRole("img", { name: "error" }); + await expect(errorIcon).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(fileUploadStatus).toContainText( + `0 created, 0 throttled, ${partitionCount * itemsPerPartition} errors`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("upload invalid json", async () => { + // Create file to upload + let TestDataJsonString: string = JSON.stringify(TestData, null, 2); + // Remove the first '[' so that it becomes invalid json + TestDataJsonString = TestDataJsonString.substring(1); + writeFileSync(uploadDocumentFilePath, TestDataJsonString); + + const uploadItemCommandBar = explorer.commandBarButton(CommandBarButton.UploadItem); + await uploadItemCommandBar.click(); + + // Select file to upload + await explorer.frame.setInputFiles("#importFileInput", uploadDocumentFilePath); + + const uploadButton = explorer.frame.getByTestId("Panel/OkButton"); + await uploadButton.click(); + + // Verify upload failure message + const fileUploadErrorList = explorer.frame.getByLabel("error list"); + // The parsing error will show up differently in different browsers so just check for the word "JSON" + await expect(fileUploadErrorList).toContainText("JSON", { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scaleAndSettings/computedProperties.spec.ts b/test/sql/scaleAndSettings/computedProperties.spec.ts new file mode 100644 index 000000000..d1f95e53e --- /dev/null +++ b/test/sql/scaleAndSettings/computedProperties.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from "@playwright/test"; +import * as DataModels from "../../../src/Contracts/DataModels"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Computed Properties", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open Settings tab under Scale & Settings", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.expand(); + + // Click Scale & Settings and open Settings tab + await explorer.openScaleAndSettings(context); + const computedPropertiesTab = explorer.frame.getByTestId("settings-tab-header/ComputedPropertiesTab"); + await computedPropertiesTab.click(); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add valid computed property", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT VALUE LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid query", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Failed to update container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); + + test("Add computed property with invalid json", async ({ page }) => { + await clearComputedPropertiesTextBoxContent({ page }); + + // Create computed property with no VALUE keyword in query + const computedProperties: DataModels.ComputedProperties = [ + { + name: "cp_lowerName", + query: "SELECT LOWER(c.name) FROM c", + }, + ]; + const computedPropertiesString: string = JSON.stringify(computedProperties); + await page.keyboard.type(computedPropertiesString + "]"); + + // Save button should remain disabled due to invalid json + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeDisabled(); + }); + + const clearComputedPropertiesTextBoxContent = async ({ page }: { page: Page }): Promise => { + // Get computed properties text box + await explorer.frame.waitForSelector(".monaco-scrollable-element", { state: "visible" }); + const computedPropertiesEditor = explorer.frame.getByTestId("computed-properties-editor"); + await computedPropertiesEditor.click(); + + // Clear existing content (Ctrl+A + Backspace does not work with webkit) + for (let i = 0; i < 100; i++) { + await page.keyboard.press("Backspace"); + } + }; +}); diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index f82c5413f..3f14422eb 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -11,7 +11,7 @@ test.describe("Settings under Scale & Settings", () => { const page = await browser.newPage(); explorer = await DataExplorer.open(page, TestAccount.SQL); - // Click Scale & Settings and open Scale tab + // Click Scale & Settings and open Settings tab await explorer.openScaleAndSettings(context); const settingsTab = explorer.frame.getByTestId("settings-tab-header/SubSettingsTab"); await settingsTab.click(); @@ -53,4 +53,28 @@ test.describe("Settings under Scale & Settings", () => { }, ); }); + + test("Set Geospatial Config to Geometry then Geography", async () => { + const geometryRadioButton = explorer.frame.getByRole("radio", { name: "geometry-option" }); + await geometryRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + + const geographyRadioButton = explorer.frame.getByRole("radio", { name: "geography-option" }); + await geographyRadioButton.click(); + + await explorer.commandBarButton(CommandBarButton.Save).click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); }); diff --git a/test/sql/scripts/storedProcedure.spec.ts b/test/sql/scripts/storedProcedure.spec.ts new file mode 100644 index 000000000..35fb4e0f8 --- /dev/null +++ b/test/sql/scripts/storedProcedure.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Stored Procedures", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("Add, execute, and delete stored procedure", async ({ page }, testInfo) => { + void page; + // Open container context menu and click New Stored Procedure + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Stored Procedure").click(); + + // Type stored procedure id and use stock procedure + const storedProcedureIdTextBox = explorer.frame.getByLabel("Stored procedure id"); + await storedProcedureIdTextBox.isVisible(); + const storedProcedureName = `stored-procedure-${testInfo.testId}`; + await storedProcedureIdTextBox.fill(storedProcedureName); + + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created stored procedure ${storedProcedureName}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Execute stored procedure + const executeButton = explorer.commandBarButton(CommandBarButton.Execute); + await executeButton.click(); + const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton"); + await executeSidePanelButton.click(); + + const executeStoredProcedureResult = explorer.frame.getByLabel("Execute stored procedure result"); + await expect(executeStoredProcedureResult).toBeAttached({ + timeout: ONE_MINUTE_MS, + }); + + // Delete stored procedure + await containerNode.expand(); + const storedProceduresNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures`, + ); + await storedProceduresNode.expand(); + const storedProcedureNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Stored Procedures/${storedProcedureName}`, + ); + + await storedProcedureNode.openContextMenu(); + await storedProcedureNode.contextMenuItem("Delete Stored Procedure").click(); + const deleteStoredProcedureButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteStoredProcedureButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted stored procedure ${storedProcedureName}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/sql/scripts/trigger.spec.ts b/test/sql/scripts/trigger.spec.ts new file mode 100644 index 000000000..6874c2aac --- /dev/null +++ b/test/sql/scripts/trigger.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("Triggers", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const triggerBody = `function validateToDoItemTimestamp() { + var context = getContext(); + var request = context.getRequest(); + + var itemToCreate = request.getBody(); + + if (!("timestamp" in itemToCreate)) { + var ts = new Date(); + itemToCreate["timestamp"] = ts.getTime(); + } + + request.setBody(itemToCreate); + }`; + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add and delete trigger", async ({ page }, testInfo) => { + // Open container context menu and click New Trigger + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New Trigger").click(); + + // Assign Trigger id + const triggerIdTextBox = explorer.frame.getByLabel("Trigger Id"); + const triggerId: string = `validateItemTimestamp-${testInfo.testId}`; + await triggerIdTextBox.fill(triggerId); + + // Create Trigger body that validates item timestamp + const triggerBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await triggerBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(triggerBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully created trigger ${triggerId}`, { + timeout: 2 * ONE_MINUTE_MS, + }); + + // Delete Trigger + await containerNode.expand(); + const triggersNode = await explorer.waitForNode(`${context.database.id}/${context.container.id}/Triggers`); + await triggersNode.expand(); + const triggerNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/Triggers/${triggerId}`, + ); + + await triggerNode.openContextMenu(); + await triggerNode.contextMenuItem("Delete Trigger").click(); + const deleteTriggerButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteTriggerButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully deleted trigger ${triggerId}`, { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/sql/scripts/userDefinedFunction.spec.ts b/test/sql/scripts/userDefinedFunction.spec.ts new file mode 100644 index 000000000..911b1f4ce --- /dev/null +++ b/test/sql/scripts/userDefinedFunction.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { CommandBarButton, DataExplorer, ONE_MINUTE_MS, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +test.describe("User Defined Functions", () => { + let context: TestContainerContext = null!; + let explorer: DataExplorer = null!; + const udfBody: string = `function extractDocumentId(doc) { + return { + id: doc.id + }; + }`; + + test.beforeAll("Create Test Database", async () => { + context = await createTestSQLContainer(); + }); + + test.beforeEach("Open container", async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + if (!process.env.CI) { + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + } + + test("Add, execute, and delete user defined function", async ({ page }, testInfo) => { + // Open container context menu and click New UDF + const containerNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("New UDF").click(); + + // Assign UDF id + const udfIdTextBox = explorer.frame.getByLabel("User Defined Function Id"); + const udfId: string = `extractDocumentId-${testInfo.testId}`; + await udfIdTextBox.fill(udfId); + + // Create UDF body that extracts the document id from a document + const udfBodyTextArea = explorer.frame.getByTestId("EditorReact/Host/Loaded"); + await udfBodyTextArea.click(); + + // Clear existing content + const isMac: boolean = process.platform === "darwin"; + await page.keyboard.press(isMac ? "Meta+A" : "Control+A"); + await page.keyboard.press("Backspace"); + + await page.keyboard.type(udfBody); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully created user defined function ${udfId}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + + // Delete UDF + await containerNode.expand(); + const udfsNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions`, + ); + await udfsNode.expand(); + const udfNode = await explorer.waitForNode( + `${context.database.id}/${context.container.id}/User Defined Functions/${udfId}`, + ); + await udfNode.openContextMenu(); + await udfNode.contextMenuItem("Delete User Defined Function").click(); + const deleteUserDefinedFunctionButton = explorer.frame.getByTestId("DialogButton:Delete"); + await deleteUserDefinedFunctionButton.click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully deleted user defined function ${udfId}`, + { + timeout: ONE_MINUTE_MS, + }, + ); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index b440f565c..7e5a1f26c 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -37,27 +37,35 @@ export interface PartitionKey { value: string | null; } -const partitionCount = 4; +export const partitionCount = 4; // If we increase this number, we need to split bulk creates into multiple batches. // Bulk operations are limited to 100 items per partition. -const itemsPerPartition = 100; +export const itemsPerPartition = 100; function createTestItems(): TestItem[] { const items: TestItem[] = []; for (let i = 0; i < partitionCount; i++) { for (let j = 0; j < itemsPerPartition; j++) { - const id = crypto.randomBytes(32).toString("base64"); + const id = createSafeRandomString(32); items.push({ id, partitionKey: `partition_${i}`, - randomData: crypto.randomBytes(32).toString("base64"), + randomData: createSafeRandomString(32), }); } } return items; } +// Document IDs cannot contain '/', '\', or '#' +function createSafeRandomString(byteLength: number): string { + return crypto + .randomBytes(byteLength) + .toString("base64") + .replace(/[/\\#]/g, "_"); +} + export const TestData: TestItem[] = createTestItems(); export class TestContainerContext {