cosmos-explorer/test/fx.ts

342 lines
12 KiB
TypeScript
Raw Normal View History

import { AzureCliCredentials } from "@azure/ms-rest-nodeauth";
import { expect, Frame, Locator, Page } from "@playwright/test";
import crypto from "crypto";
2024-08-15 21:29:57 +01:00
const RETRY_COUNT = 3;
export interface TestNameOptions {
length?: number;
timestampped?: boolean;
prefixed?: boolean;
}
2024-08-15 21:29:57 +01:00
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<AzureCliCredentials> {
return await AzureCliCredentials.create();
}
export async function getAzureCLICredentialsToken(): Promise<string> {
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, string> = {
[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<string> {
// 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<void> {
await this.element.click({ button: "right" });
}
contextMenuItem(name: string): Locator {
return this.frame.getByTestId(`TreeNode/ContextMenuItem:${name}`);
}
async expand(): Promise<void> {
const treeNodeContainer = this.frame.getByTestId(`TreeNodeContainer:${this.id}`);
2024-08-15 21:29:57 +01:00
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;
}
2024-08-15 21:29:57 +01:00
// 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.`);
}
2024-08-15 21:29:57 +01:00
// We did it. It took a lot of weird messing around, but we expanded a tree node... I hope.
}
}
export class Editor {
constructor(
public frame: Frame,
public locator: Locator,
) {}
text(): Promise<string | null> {
return this.locator.evaluate((e) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_getEditorContentForElement) {
return win._monaco_getEditorContentForElement(e);
}
return null;
});
}
async setText(text: string): Promise<void> {
// We trust that Monaco can handle the keyboard, and it's _extremely_ flaky to try and enter text using browser commands.
// So we use a hook we installed in 'window' to set the content of the editor.
// NOTE: This function is serialized and sent to the browser for execution
// So you can't use any variables from the outer scope, but we can send a string (via the second argument to evaluate)
await this.locator.evaluate((e, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = e.ownerDocument.defaultView as any;
if (win._monaco_setEditorContentForElement) {
win._monaco_setEditorContentForElement(e, content);
}
}, text);
expect(await this.text()).toEqual(text);
}
}
2024-08-15 21:29:57 +01:00
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) {}
2024-08-15 21:29:57 +01:00
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}`);
}
2024-08-15 21:29:57 +01:00
async waitForNode(treeNodeId: string): Promise<TreeNode> {
const node = this.treeNode(treeNodeId);
// Is the node already visible?
if (await node.element.isVisible()) {
return node;
}
// No, try refreshing the tree
const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton");
await refreshButton.click();
// Try a few times to find the node
for (let i = 0; i < RETRY_COUNT; i++) {
try {
await node.element.waitFor();
return node;
} catch {
// Just try again
}
}
// We tried 3 times, but the node never appeared
throw new Error(`Node ${treeNodeId} not found and did not appear after refreshing.`);
}
async waitForContainerNode(databaseId: string, containerId: string): Promise<TreeNode> {
const databaseNode = await this.waitForNode(databaseId);
// The container node may be auto-expanded. Wait 5s for that to happen
try {
const containerNode = this.treeNode(`${databaseId}/${containerId}`);
await containerNode.element.waitFor({ state: "visible", timeout: 5 * 1000 });
return containerNode;
} catch {
// It didn't auto-expand, that's fine, we'll expand it ourselves
}
// Ok, expand the database node.
await databaseNode.expand();
return await this.waitForNode(`${databaseId}/${containerId}`);
}
/** 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. */
2024-08-15 21:29:57 +01:00
async whilePanelOpen(
title: string,
action: (panel: Locator, okButton: Locator) => Promise<void>,
options?: PanelOpenOptions,
): Promise<void> {
options ||= {};
const panel = this.panel(title);
await panel.waitFor();
const okButton = panel.getByTestId("Panel/OkButton");
await action(panel, okButton);
2024-08-15 21:29:57 +01:00
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<DataExplorer> {
const url = await getTestExplorerUrl(testAccount, iframeSrc);
await page.goto(url);
return DataExplorer.waitForExplorer(page);
}
}