import { AzureCliCredentials } from "@azure/ms-rest-nodeauth"; import { expect, Frame, Locator, Page } from "@playwright/test"; import crypto from "crypto"; const RETRY_COUNT = 3; export interface TestNameOptions { length?: number; timestampped?: boolean; prefixed?: boolean; } export function generateUniqueName(baseName, options?: TestNameOptions): string { const length = options?.length ?? 1; const timestamp = options?.timestampped === undefined ? true : options.timestampped; const prefixed = options?.prefixed === undefined ? true : options.prefixed; const prefix = prefixed ? "t_" : ""; const suffix = timestamp ? `_${Date.now()}` : ""; return `${prefix}${baseName}${crypto.randomBytes(length).toString("hex")}${suffix}`; } export async function getAzureCLICredentials(): Promise { return await AzureCliCredentials.create(); } export async function getAzureCLICredentialsToken(): Promise { const credentials = await getAzureCLICredentials(); const token = (await credentials.getToken()).accessToken; return token; } export enum TestAccount { Tables = "Tables", Cassandra = "Cassandra", Gremlin = "Gremlin", Mongo = "Mongo", Mongo32 = "Mongo32", SQL = "SQL", } export const defaultAccounts: Record = { [TestAccount.Tables]: "portal-tables-runner", [TestAccount.Cassandra]: "portal-cassandra-runner", [TestAccount.Gremlin]: "portal-gremlin-runner", [TestAccount.Mongo]: "portal-mongo-runner", [TestAccount.Mongo32]: "portal-mongo32-runner", [TestAccount.SQL]: "portal-sql-runner-west-us", }; export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "runners"; export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c"; 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] ); } export async function getTestExplorerUrl(accountType: TestAccount, iframeSrc?: string): Promise { // 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"); if (iframeSrc) { params.set("iframeSrc", iframeSrc); } return `https://localhost:1234/testExplorer.html?${params.toString()}`; } /** 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); } } type PanelOpenOptions = { closeTimeout?: number; }; /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ export class DataExplorer { constructor(public frame: Frame) {} tab(tabId: string): Locator { return this.frame.getByTestId(`Tab:${tabId}`); } queryTab(tabId: string): QueryTab { const tab = this.tab(tabId); const queryTab = tab.getByTestId("QueryTab"); return new QueryTab(this.frame, tabId, tab, queryTab); } /** Select the primary global command button. * * There's only a single "primary" button, but we still require you to pass the label to confirm you're selecting the right button. */ globalCommandButton(label: string): Locator { return this.frame.getByTestId("GlobalCommands").getByText(label); } /** Select the command bar button with the specified label */ commandBarButton(label: string): Locator { return this.frame.getByTestId(`CommandBar/Button:${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}`); } /** 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 }); } /** Waits for the Data Explorer app to load */ static async waitForExplorer(page: Page) { 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"); } 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); } }