2024-06-05 20:46:32 +01:00
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-06-05 20:46:32 +01:00
}
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 } ` ;
2024-06-05 20:46:32 +01:00
}
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 ) ;
2024-08-01 18:02:36 +01:00
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" ) ;
2024-06-05 20:46:32 +01:00
if ( iframeSrc ) {
2024-08-01 18:02:36 +01:00
params . set ( "iframeSrc" , iframeSrc ) ;
2024-06-05 20:46:32 +01:00
}
2024-08-01 18:02:36 +01:00
return ` https://localhost:1234/testExplorer.html? ${ params . toString ( ) } ` ;
2024-06-05 20:46:32 +01:00
}
/** 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-06-05 20:46:32 +01:00
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-06-05 20:46:32 +01:00
}
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-06-05 20:46:32 +01:00
}
}
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 ;
} ;
2024-06-05 20:46:32 +01:00
/** 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 ) ;
}
2024-08-01 18:02:36 +01:00
/ * * S e l e c t t h e p r i m a r y g l o b a l c o m m a n d b u t t o n .
*
* 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 */
2024-06-05 20:46:32 +01:00
commandBarButton ( label : string ) : Locator {
return this . frame . getByTestId ( ` CommandBar/Button: ${ label } ` ) . and ( this . frame . locator ( "css=button" ) ) ;
}
2024-08-01 18:02:36 +01:00
/** Select the side panel with the specified title */
2024-06-05 20:46:32 +01:00
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 } ` ) ;
}
2024-08-01 18:02:36 +01:00
/** Select the tree node with the specified id */
2024-06-05 20:46:32 +01:00
treeNode ( id : string ) : TreeNode {
return new TreeNode ( this . frame . getByTestId ( ` TreeNode: ${ id } ` ) , this . frame , id ) ;
}
2024-08-01 18:02:36 +01:00
/** 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 || = { } ;
2024-06-05 20:46:32 +01:00
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 } ) ;
2024-06-05 20:46:32 +01:00
}
2024-08-01 18:02:36 +01:00
/** Waits for the Data Explorer app to load */
2024-06-05 20:46:32 +01:00
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 ) ;
}
2024-08-01 18:02:36 +01:00
/** Opens the Data Explorer app using the specified test account (and optionally, the provided IFRAME src url). */
2024-06-05 20:46:32 +01:00
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 ) ;
}
}