-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Arun George <[email protected]>
- Loading branch information
1 parent
8cd25ac
commit ec31b9d
Showing
4 changed files
with
562 additions
and
264 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@itwin-oidc-signin-tool-71a3e3bd-755a-47e3-a617-3ec2a7429cca.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "minor", | ||
"comment": "export reusable browser automation for desktop and electron", | ||
"packageName": "@itwin/oidc-signin-tool", | ||
"email": "[email protected]", | ||
"dependentChangeType": "patch" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,355 @@ | ||
/*--------------------------------------------------------------------------------------------- | ||
* Copyright (c) Bentley Systems, Incorporated. All rights reserved. | ||
* See LICENSE.md in the project root for license terms and full copyright notice. | ||
*--------------------------------------------------------------------------------------------*/ | ||
|
||
import * as os from "node:os"; | ||
import type { Browser, LaunchOptions, Page } from "@playwright/test"; | ||
import type { TestUserCredentials } from "./TestUsers"; | ||
import { testSelectors } from "./TestSelectors"; | ||
|
||
/** @internal configuration for automated sign in */ | ||
export interface AutomatedSignInConfig { | ||
issuer: string; | ||
/** optional endpoint configuration to verify when handling ping login page */ | ||
authorizationEndpoint?: string; | ||
} | ||
|
||
/** @internal base context for automated sign in and sign out functions */ | ||
interface AutomatedContextBase<T> { | ||
page: Page; | ||
/** a promise that resolves once the sign in callback is reached, | ||
* with any data, e.g. a callback URL | ||
* @defaults Promise.resolve() | ||
*/ | ||
waitForCallback?: Promise<T>; | ||
/** A function that takes the waitForCallback result data (e.g. a callback url) | ||
* and finalizes the sign in process | ||
*/ | ||
resultFromCallback?: (t: T) => any | Promise<any>; | ||
/** optionally provide the abort controller for errors, | ||
* in case you need to cancel your waitForCallbackUrl */ | ||
abortController?: AbortController; | ||
|
||
/** whether or not to kill the entire browser when cleaning up */ | ||
doNotKillBrowser?: boolean; | ||
} | ||
|
||
/** @internal context for automated sign in functions */ | ||
export interface AutomatedSignInContext<T> extends AutomatedContextBase<T> { | ||
signInInitUrl: string; | ||
user: TestUserCredentials; | ||
config: AutomatedSignInConfig; | ||
} | ||
|
||
/** @internal context for automated sign in functions */ | ||
export interface AutomatedSignOutContext<T> extends AutomatedContextBase<T> { | ||
signOutInitUrl: string; | ||
} | ||
|
||
/** | ||
* given a context with configuration, user info, a playwright page, | ||
* and iTwin services sign in url, sign in | ||
* @internal | ||
*/ | ||
export async function automatedSignIn<T>( | ||
context: AutomatedSignInContext<T>, | ||
): Promise<void> { | ||
const { page } = context; | ||
const waitForCallback = context.waitForCallback ?? Promise.resolve() as Promise<T>; | ||
const controller = context.abortController ?? new AbortController(); | ||
|
||
try { | ||
await page.goto(context.signInInitUrl); | ||
|
||
try { | ||
await handleErrorPage(context); | ||
|
||
await handleLoginPage(context); | ||
|
||
await handlePingLoginPage(context); | ||
|
||
// Handle federated sign-in | ||
await handleFederatedSignin(context); | ||
} catch (err) { | ||
controller.abort(); | ||
throw new Error(`Failed OIDC signin for ${context.user.email}.\n${err}`); | ||
} | ||
|
||
try { | ||
await handleConsentPage(context); | ||
} catch (error) { | ||
// ignore, if we get the callback Url, we're good. | ||
} | ||
|
||
if (context.resultFromCallback) | ||
// if we do not await here, logic in resultFromCallback can escape the cleanup in finally | ||
// eslint-disable-next-line @typescript-eslint/return-await | ||
return await context.resultFromCallback(await waitForCallback); | ||
} finally { | ||
await cleanup(page, controller.signal, waitForCallback, context.doNotKillBrowser); | ||
} | ||
} | ||
|
||
/** | ||
* given a context with configuration, user info, a playwright page, | ||
* and iTwin services sign out url, sign out | ||
* @internal | ||
*/ | ||
export async function automatedSignOut<T>( | ||
context: AutomatedSignOutContext<T>, | ||
): Promise<void> { | ||
const { page } = context; | ||
const waitForCallback = context.waitForCallback ?? Promise.resolve() as Promise<T>; | ||
const controller = context.abortController ?? new AbortController(); | ||
|
||
try { | ||
await page.goto(context.signOutInitUrl); | ||
} finally { | ||
await cleanup(page, controller.signal, waitForCallback, context.doNotKillBrowser); | ||
} | ||
} | ||
|
||
async function handleErrorPage<T>({ page }: AutomatedContextBase<T>): Promise<void> { | ||
await page.waitForLoadState("networkidle"); | ||
const pageTitle = await page.title(); | ||
let errMsgText; | ||
|
||
if (pageTitle.toLocaleLowerCase() === "error") | ||
errMsgText = await page.content(); | ||
|
||
if (null === errMsgText) | ||
throw new Error("Unknown error page detected."); | ||
|
||
if (undefined !== errMsgText) | ||
throw new Error(errMsgText); | ||
} | ||
|
||
async function handleLoginPage<T>(context: AutomatedSignInContext<T>): Promise<void> { | ||
const loginUrl = new URL("/IMS/Account/Login", context.config.issuer); | ||
const { page } = context; | ||
if (page.url().startsWith(loginUrl.toString())) { | ||
await page.waitForSelector(testSelectors.imsEmail); | ||
await page.type(testSelectors.imsEmail, context.user.email); | ||
await page.waitForSelector(testSelectors.imsPassword); | ||
await page.type(testSelectors.imsPassword, context.user.password); | ||
|
||
const submit = page.locator(testSelectors.imsSubmit); | ||
await submit.click(); | ||
} | ||
|
||
// Check if there were any errors when performing sign-in | ||
await checkErrorOnPage(page, "#errormessage"); | ||
} | ||
|
||
async function handlePingLoginPage<T>(context: AutomatedSignInContext<T>): Promise<void> { | ||
const { page } = context; | ||
if ( | ||
context.config.authorizationEndpoint !== undefined && ( | ||
!page.url().startsWith(context.config.authorizationEndpoint) || | ||
-1 === page.url().indexOf("ims") | ||
) | ||
) | ||
return; | ||
|
||
await page.waitForSelector(testSelectors.pingEmail); | ||
await page.type(testSelectors.pingEmail, context.user.email); | ||
|
||
await page.waitForSelector(testSelectors.pingAllowSubmit); | ||
let allow = page.locator(testSelectors.pingAllowSubmit); | ||
await allow.click(); | ||
|
||
// Cut out for federated sign-in | ||
if (-1 !== page.url().indexOf("microsoftonline")) | ||
return; | ||
|
||
await page.waitForSelector(testSelectors.pingPassword); | ||
await page.type(testSelectors.pingPassword, context.user.password); | ||
|
||
await page.waitForSelector(testSelectors.pingAllowSubmit); | ||
allow = page.locator(testSelectors.pingAllowSubmit); | ||
await allow.click(); | ||
|
||
await page.waitForLoadState("networkidle"); | ||
const error = page.getByText( | ||
"We didn't recognize the email address or password you entered. Please try again." | ||
); | ||
|
||
const count = await error.count(); | ||
|
||
if (count) { | ||
throw new Error( | ||
"We didn't recognize the email address or password you entered. Please try again." | ||
); | ||
} | ||
|
||
// Check if there were any errors when performing sign-in | ||
await checkErrorOnPage(page, ".ping-error"); | ||
} | ||
|
||
// Bentley-specific federated login. This will get called if a redirect to a url including "microsoftonline". | ||
async function handleFederatedSignin<T>(context: AutomatedSignInContext<T>): Promise<void> { | ||
const { page } = context; | ||
|
||
await page.waitForLoadState("networkidle"); | ||
if (-1 === page.url().indexOf("microsoftonline")) | ||
return; | ||
|
||
if (await checkSelectorExists(page, testSelectors.msUserNameField)) { | ||
await page.type(testSelectors.msUserNameField, context.user.email); | ||
const msSubmit = await page.waitForSelector(testSelectors.msSubmit); | ||
await msSubmit.click(); | ||
|
||
// Checks for the error in username entered | ||
await checkErrorOnPage(page, "#usernameError"); | ||
} else { | ||
const fedEmail = await page.waitForSelector(testSelectors.fedEmail); | ||
await fedEmail.type(context.user.email); | ||
} | ||
|
||
const fedPassword = await page.waitForSelector(testSelectors.fedPassword); | ||
await fedPassword.type(context.user.password); | ||
const submit = await page.waitForSelector(testSelectors.fedSubmit); | ||
await submit.click(); | ||
|
||
// Need to check for invalid username/password directly after the submit button is pressed | ||
let errorExists = false; | ||
try { | ||
errorExists = await checkSelectorExists(page, "#errorText"); | ||
} catch (err) { | ||
// continue with navigation even if throws | ||
} | ||
|
||
if (errorExists) | ||
await checkErrorOnPage(page, "#errorText"); | ||
|
||
// May need to accept an additional prompt. | ||
if ( | ||
-1 !== page.url().indexOf("microsoftonline") && | ||
(await checkSelectorExists(page, testSelectors.msSubmit)) | ||
) { | ||
const msSubmit = await page.waitForSelector(testSelectors.msSubmit); | ||
await msSubmit.click(); | ||
} | ||
} | ||
|
||
async function handleConsentPage<T>(context: AutomatedSignInContext<T>): Promise<void> { | ||
const { page } = context; | ||
|
||
if ((await page.title()) === "localhost") | ||
return; // we're done | ||
|
||
const consentUrl = new URL("/consent", context.config.issuer); | ||
if (page.url().startsWith(consentUrl.toString())) | ||
await page.click("button[value=yes]"); | ||
|
||
const pageTitle = await page.title(); | ||
|
||
if (pageTitle === "Request for Approval") { | ||
const pingSubmit = await page.waitForSelector( | ||
testSelectors.pingAllowSubmit | ||
); | ||
await pingSubmit.click(); | ||
} else if ((await page.title()) === "Permissions") { | ||
// Another new consent page... | ||
const acceptButton = await page.waitForSelector( | ||
"xpath=(//button/span[text()='Accept'] | //div[contains(@class, 'ping-buttons')]/a[text()='Accept'])[1]" | ||
); | ||
await acceptButton.click(); | ||
} | ||
} | ||
|
||
async function checkSelectorExists( | ||
page: Page, | ||
selector: string | ||
): Promise<boolean> { | ||
const element = await page.$(selector); | ||
return !!element; | ||
} | ||
|
||
async function checkErrorOnPage(page: Page, selector: string): Promise<void> { | ||
await page.waitForLoadState("networkidle"); | ||
const errMsgElement = await page.$(selector); | ||
if (errMsgElement) { | ||
const errMsgText = await errMsgElement.textContent(); | ||
if (undefined !== errMsgText && null !== errMsgText) | ||
throw new Error(errMsgText); | ||
} | ||
} | ||
|
||
/** @internal use playwright to launch the default automation page, which is a chromium instance */ | ||
export async function launchDefaultAutomationPage(enableSlowNetworkConditions = false): Promise<Page> { | ||
const launchOptions: LaunchOptions = {}; | ||
|
||
if (process.env.ODIC_SIGNIN_TOOL_EXTRA_LAUNCH_OPTS) { | ||
const extraLaunchOpts = JSON.parse(process.env.ODIC_SIGNIN_TOOL_EXTRA_LAUNCH_OPTS); | ||
Object.assign(launchOptions, extraLaunchOpts); | ||
} | ||
|
||
if (os.platform() === "linux") { | ||
launchOptions.args = [...launchOptions.args ?? [], "--no-sandbox"]; | ||
} | ||
|
||
const proxyUrl = process.env.HTTPS_PROXY; | ||
|
||
if (proxyUrl) { | ||
const proxyUrlObj = new URL(proxyUrl); | ||
launchOptions.proxy = { | ||
server: `${proxyUrlObj.protocol}//${proxyUrlObj.host}`, | ||
username: proxyUrlObj.username, | ||
password: proxyUrlObj.password, | ||
}; | ||
} | ||
|
||
let browser: Browser; | ||
try { | ||
const { chromium } = await import("@playwright/test"); | ||
browser = await chromium.launch(launchOptions); | ||
} catch (err) { | ||
/* eslint-disable no-console */ | ||
console.error("Original error:"); | ||
console.error(err); | ||
/* eslint-enable no-console */ | ||
throw Error( | ||
"Could not load @playwright/test. Do you have multiple playwright dependencies active? " | ||
+ "If so, then you should provide your own playwright Page to automation APIs to avoid us " | ||
+ "attempting to make our own by importing playwright" | ||
); | ||
} | ||
|
||
let page: Page; | ||
if (enableSlowNetworkConditions) { | ||
const context = await browser.newContext(); | ||
page = await context.newPage(); | ||
const session = await context.newCDPSession(page); | ||
await session.send("Network.emulateNetworkConditions", { | ||
offline: false, | ||
downloadThroughput: 200 * 1024, | ||
uploadThroughput: 50 * 1024, | ||
latency: 1000, | ||
}); | ||
} else { | ||
page = await browser.newPage(); | ||
} | ||
|
||
return page; | ||
} | ||
|
||
async function cleanup( | ||
page: Page, | ||
signal: AbortSignal, | ||
waitForCallbackUrl: Promise<any>, | ||
doNotKillBrowser = false, | ||
) { | ||
if (signal.aborted) | ||
await page.reload(); | ||
await waitForCallbackUrl; | ||
await page.close(); | ||
|
||
const doKillBrowser = !doNotKillBrowser; | ||
|
||
if (doKillBrowser) { | ||
await page.context().close(); | ||
await page.context().browser()?.close(); | ||
} | ||
} |
Oops, something went wrong.