@alleyway/add-tradingview-alerts-tool
Version:
Add TradingView Alerts in bulk
935 lines (809 loc) • 38.5 kB
text/typescript
import {
Browser as ChromeBrowser,
computeExecutablePath,
detectBrowserPlatform,
resolveBuildId,
} from "@puppeteer/browsers"
import exp from "constants"
import {accessSync, constants, rmSync, writeFileSync} from "fs"
import {mkdir} from "fs/promises"
import kleur from "kleur"
import path from "path"
import type {Browser} from "puppeteer-core"
import puppeteer from "puppeteer-core"
import RegexParser from "regex-parser"
import {
AddAlertInvocationError,
ErrorWithScreenShot,
InvalidSymbolError,
NoInputFoundError,
SelectionError,
} from "../classes.js"
import {ISingleAlertSettings} from "../interfaces.js"
import {isEnvEnabled, waitForTimeout} from "./common-service.js"
import log from "./log.js"
// data-dialog-name="gopro"
const screenshot = isEnvEnabled(process.env.SCREENSHOT)
const doXpath = async (page, selector: string) => {
/* istanbul ignore next */
const response = await page.evaluate((sel) => {
const el = document.evaluate(sel, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
.singleNodeValue as HTMLElement
return !!el
}, selector)
return response
}
export const isXpathVisible = async (page, selector: string, screenShotOnFail = false) => {
log.debug(kleur.gray(`...isXpathVisible?: ${kleur.yellow(selector)}`))
const visible = await doXpath(page, selector)
log.debug(`..isXpathVisible: ${visible}`)
return visible
}
export const fetchFirstXPath = async (page, selector: string, timeout = 20000, screenshotOnFail = true) => {
log.debug(kleur.gray(`...selector: ${kleur.yellow(selector)}`))
try {
await page.waitForSelector("xpath/." + selector, {timeout})
} catch (e) {
if (screenshotOnFail) await takeScreenshot(page, "waitForSelectorFailed")
throw e
}
const elements = await page.$$("xpath/." + selector)
return elements[0]
}
export const takeScreenshot = async (page, name: string = "unnamed") => {
if (screenshot) {
const username = await page.evaluate(() => {
// @ts-expect-error
const user = window.user
if (user) {
return "_" + user.username
} else {
return ""
}
})
const screenshotPath = `screenshot_${new Date().getTime()}${username}_${name}`
log.debug(`saving screenshot: ${screenshotPath}`)
await page.screenshot({
path: screenshotPath + ".webp",
})
const html = await page.content()
writeFileSync(screenshotPath + ".xml", html, {encoding: "utf-8"})
}
}
export const minimizeFooterChartPanel = async (page) => {
log.debug(`minimizing footer chart panel`)
try {
const footerPanelMinimizeButton = await fetchFirstXPath(
page,
`//div[@id='footer-chart-panel']//button[@data-name='toggle-visibility-button' and @data-active='false']`,
5000
)
footerPanelMinimizeButton.click()
await waitForTimeout(0.4)
} catch (e) {
log.debug("no minimize button found, footer chart panel must be hidden already")
}
}
export const convertIntervalForTradingView = (interval: string) => {
return interval
.split("")
.filter((val) => val !== "m")
.join("")
}
export const configureInterval = async (interval: string, page) => {
log.debug(`set ${kleur.blue("interval")}: ${kleur.yellow(interval)}`)
await page.keyboard.press(",")
await waitForTimeout(0.5, "after pressing interval shortcut key")
try {
convertIntervalForTradingView(interval)
.split("")
.map((char) => page.keyboard.press(char))
} catch (e) {
throw Error(
"configuration: interval specified incorrectly, should be something like '5m' or '1h' - see documentation"
)
}
await page.keyboard.press("Enter")
}
// queries used on the alert conditions
const dropdownXpathQueries = {
primaryLeft: "//span[contains(@data-qa-id, 'main-series-select')]",
primaryRight: "//span[contains(@data-qa-id, 'main-series-plot-select')]",
secondary: "//button[contains(@data-qa-id, 'operator-dropdown')]",
// Only get listbox when there's either just one value
// "//*[contains(@class, 'operatorRow-')]/..//span[@data-name='start-band-select']"
// Or get when there's an upper and lower bound
// "//legend[text()='Upper Bound']/../..//span[@data-name='start-band-select']"
tertiaryLeft:
"//*[contains(@class, 'operatorRow-')]/..//span[contains(@data-qa-id, 'start-band-select')] | //legend[text()='Upper bound']/../..//span[@data-name='start-band-select']",
tertiaryRight:
"//*[contains(@class, 'operatorRow-')]/..//span[contains(@data-qa-id, 'end-band-select')] | //legend[text()='Upper bound']/../..//span[@data-name='end-band-select']",
quaternaryLeft: "//legend[text()='Lower bound']/../..//span[contains(@data-qa-id, 'start-band-select')]",
quaternaryRight: "//legend[text()='Lower bound']/../..//span[contains(@data-qa-id, 'end-band-select')]",
}
const dropdownOptionsXpathQueries = {
primaryLeft: "//div[@role='option']//div[@data-qa-id='main-series-select-title']/span",
primaryRight: "//div[@role='option']//div[@data-qa-id='main-series-select-plot-title']/span",
secondary: "//div[@role='option']//div[contains(@class, 'title-')]",
tertiaryLeft: "//div[contains(@data-qa-id, 'start-band-select')]//div[@data-qa-id='start-band-select-title']/span",
}
const dropdownSoundXpathQueries = {
nameTarget: "//button[@role='button' and @data-qa-id='sound-title-select']",
classicSection: "//button[@role='group' and @aria-expanded='false']//div[text()='Classic']",
nameListItems:
"//div[@data-qa-id='popup-menu-container']//div[@role='option']//div[contains(@class, 'title-')]//div[contains(@class, 'title-')]",
durationTarget: "//button[@data-qa-id='sound-duration-select']",
durationListItems: "//div[@data-qa-id='popup-menu-container']//div[@role='option']//div[contains(@class, 'title-')]",
}
const inputXpathQueries = {
tertiaryLeft:
"//*[contains(@class, 'operatorRow-')]/..//input[contains(@data-qa-id, 'start-band-range')] | //legend[text()='Upper bound']/../..//input[contains(@data-qa-id, 'start-band-range')]",
tertiaryRight:
"//*[contains(@class, 'operatorRow-')]/..//input[contains(@data-qa-id, 'end-band-range')] | //legend[text()='Upper bound']/../..//input[contains(@data-qa-id, 'end-band-range')]",
quaternaryLeft:
"//*[contains(@class, 'operatorRow-')]/..//input[contains(@data-qa-id, 'start-band-range')] | //legend[text()='Lower bound']/../..//input[contains(@data-qa-id, 'start-band-range')]",
quaternaryRight:
"//*[contains(@class, 'operatorRow-')]/..//input[contains(@data-qa-id, 'end-band-range')] | //legend[text()='Lower bound']/../..//input[contains(@data-qa-id, 'end-band-range')]",
}
const readOnlyInputQueries = {
tertiaryLeft:
"(//div[contains(@class, 'tv-alert-dialog__group-item--left ') and contains(@class, 'js-second-operand-')])[1]//input[@type='text']",
quaternaryLeft:
"(//div[contains(@class, 'tv-alert-dialog__group-item--left ') and contains(@class, 'js-second-operand-')])[2]//input[@type='text']",
}
const alertActionCorresponding = {
notifyOnApp: "notify-on-app",
showPopup: "show-popup",
sendEmail: "send-email",
webhook: "webhook",
playSound: "play-sound",
}
const clickInputAndDelete = async (page, inputElement) => {
await inputElement.click()
await waitForTimeout(0.5)
/* istanbul ignore next */
await page.evaluate((el) => {
el.value = ""
}, inputElement)
await waitForTimeout(0.3)
}
export const launchBrowser = async (headless: boolean, url?: string): Promise<Browser> => {
const userDataDir = path.join(process.cwd(), "user_data") // where chrome will store it's stuff
try {
accessSync(userDataDir, constants.W_OK)
} catch {
log.info(`Attempting to create directory for Chrome user data\n ${kleur.yellow(userDataDir)}`)
await mkdir(userDataDir)
}
const chromeBuildId = await resolveBuildId(ChromeBrowser.CHROME, detectBrowserPlatform(), "stable")
const chromeExecutable = computeExecutablePath({
browser: ChromeBrowser.CHROME,
buildId: chromeBuildId,
cacheDir: process.cwd(),
platform: detectBrowserPlatform(),
})
const args = [
"--no-sandbox",
"--enable-experimental-web-platform-features", // adds support for :has selector in styleOverrides. In theory its not experimental in chrome 105
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-accelerated-2d-canvas",
"--no-first-run",
"--no-zygote",
"--disable-gpu",
"--disable-session-crashed-bubble",
"--disable-infobars",
"--disable-restore-session-state",
"--window-size=1920,1080", // otherwise headless doesn't work
!url ? "" : `${url}`,
]
return puppeteer.launch({
executablePath: chromeExecutable,
headless: headless,
userDataDir,
defaultViewport: {width: 1920, height: 1080, isMobile: false, hasTouch: false},
args,
})
}
export const login = async (page, username, pass, backupCode) => {
try {
const emailSignInButton = await fetchFirstXPath(
page,
`//div[@data-dialog-name='sign-in']//button[@name='Email']`,
5000
)
emailSignInButton.click()
await waitForTimeout(1)
} catch (e) {
log.warn("no email toggle button showing!")
}
const usernameInput = await fetchFirstXPath(page, "//input[@name='id_username']")
await usernameInput.type(`${username}`)
await waitForTimeout(0.5)
await takeScreenshot(page, "shouldbe_before_password_entry")
const passwordInput = await fetchFirstXPath(page, "//input[@name='id_password']")
log.debug("typing password")
await passwordInput.type(pass)
await waitForTimeout(0.5)
const submitButton = await fetchFirstXPath(
page,
"//div[@data-dialog-name='sign-in']//button[contains(@class, 'submitButton')]"
)
log.debug("clicking submit button")
submitButton.click()
await waitForTimeout(2)
let twoFactorOrBackupCodeInput = null
try {
twoFactorOrBackupCodeInput = await fetchFirstXPath(
page,
"//div[@data-dialog-name='sign-in']//input[@id='id_code']",
5000
)
} catch (e) {
log.warn("Two Factor Authentication / Backup Code input not showing")
}
if (twoFactorOrBackupCodeInput !== null) {
if (!backupCode) {
log.warn(`${kleur.yellow("MANUAL ACTION NEEDED: ")} You have 30 seconds to enter your 2FA or backup code`)
await waitForTimeout(30 * 1000)
} else {
await twoFactorOrBackupCodeInput.type(`${backupCode}`)
await waitForTimeout(3)
}
}
let possibleErrorElement = null
try {
possibleErrorElement = await fetchFirstXPath(
page,
"//div[@data-dialog-name='sign-in']//div[contains(@class, 'mainProblem')]//div[contains(@class,'text-wrap')]/span",
5000
)
} catch (e) {
log.warn("doesn't seem like there's an error..")
}
if (possibleErrorElement) {
const errorText = await page.evaluate((element) => element.innerText, possibleErrorElement)
await takeScreenshot(page, "loginError")
throw new ErrorWithScreenShot(errorText, "loginError")
}
}
export const logout = async (page) => {
/* istanbul ignore next */
await page.evaluate(() => {
fetch("/accounts/logout/", {
method: "POST",
headers: {accept: "html"},
credentials: "same-origin",
}).then((res) => {
log.info(`Logged out of TradingView`)
})
})
await page.reload({
waitUntil: "networkidle2",
})
}
export const checkForInvalidSymbol = async (page, symbol: string) => {
// this function could be used when navigating by typing or by the url using ?symbol=ASDF
// now see if it's invalid symbol, could be multi-chart so check active
if (
await isXpathVisible(
page,
"//div[contains(@class,'chart-container') and contains(@class,' active')]//*/div[contains(@class, 'invalidSymbol') and not(contains(@class, 'js-hidden'))]"
)
) {
log.error("currently showing an invalid symbol")
const invalidSymbolError = new InvalidSymbolError()
invalidSymbolError.symbol = symbol
throw invalidSymbolError
}
}
export const navigateToSymbol = async (page, symbol: string) => {
await page.keyboard.press("Escape")
await waitForTimeout(0.5)
await page.keyboard.press("Escape")
await waitForTimeout(0.5)
await page.keyboard.type(`A`, {delay: 0.3}) // just type a letter <- allows formulas to work, eg. 1/USD...
await page.keyboard.press("Backspace")
await waitForTimeout(0.3)
await page.keyboard.type(`${symbol}`, {delay: 0.3})
await waitForTimeout(0.3)
await page.keyboard.press("Enter")
await waitForTimeout(1.5)
}
const isMatch = (needle: string, haystack: string) => {
if (needle.startsWith("/")) {
log.debug(
`Parsing what appears to be regular expression: ${kleur.yellow(needle)} ... Haystack: ${kleur.gray(haystack)}`
)
const regexp: RegExp = RegexParser(needle)
return !!regexp.exec(haystack)
} else {
return haystack.toLowerCase().indexOf(needle.toLowerCase()) > -1
}
}
export const configureSingleAlertSettings = async (page, singleAlertSettings: ISingleAlertSettings) => {
const {condition, name, expireOpenEnded, expireInterval, trigger, message, actions} = singleAlertSettings
await takeScreenshot(page, "alert_begin_configure")
try {
const gotItButton = await fetchFirstXPath(
page,
"//button//span[contains(@data-overflow-tooltip-text, 'Got it')]",
1000,
false
)
log.warn("Clicking 'Got it' informational bubble...")
gotItButton.evaluate((e) => e.click())
} catch (e) {
log.trace("Checked for 'Got it' button, but found nothing")
}
const selectFromDropDown = async (conditionToMatchArg: string, selector: string) => {
log.debug(`..selectFromDropDown() using selector: ${kleur.yellow(selector)}`)
await page.waitForSelector(`xpath/.${selector}`, {timeout: 8000})
const elements = await page.$$("xpath/." + selector)
if (elements.length === 0) {
log.warn("zero dropdown options found with selector")
await takeScreenshot(page, "zero_dropdown_options")
}
let conditionToMatch = conditionToMatchArg
let targetOccurrence = 0
const match = conditionToMatch.match(/(.*?)\[(\d+)\]$/)
// if match is not null, then the number to look for should be match[1]
if (match) {
conditionToMatch = match[1]
targetOccurrence = Number.parseInt(match[2])
log.debug(
`Indexed condition used: ${kleur.yellow(conditionToMatchArg)}\n Setting occurrence to ${kleur.blue(targetOccurrence)}`
)
}
log.debug(`searching menu for ${kleur.yellow(conditionToMatch)}`)
let found = false
const foundOptions = []
let occurrenceCount = 0
for (const el of elements) {
/* istanbul ignore next */
let optionText = await page.evaluate((element) => element.innerText, el)
optionText = optionText.replace(/[\u200B]/g, "") // this is to remove invisible "zero width" characters like for the following:
// Loner S/R (modified, 28, 5, Standard, -20, modified, 21, 3, 40, 10, 20, 5, 64, 1.5, both)
foundOptions.push(optionText)
if (isMatch(conditionToMatch, optionText)) {
if (occurrenceCount === targetOccurrence) {
log.debug(`Found! Clicking ${kleur.yellow(optionText)}`)
found = true
el.click()
return
} else {
log.debug(
`Matching option found, but not occurrenceCount ${kleur.blue(occurrenceCount)} not matching targetOccurrence `
)
occurrenceCount += 1
}
}
}
if (!found) throw new SelectionError(conditionToMatch, foundOptions)
}
const performActualEntry = async (key) => {
const conditionOrInputValue = String(condition[key])
log.debug(`Processing ${kleur.blue(key)}: ${kleur.yellow(conditionOrInputValue)}`)
await waitForTimeout(0.8)
if (conditionOrInputValue !== "null" && String(conditionOrInputValue).length > 0) {
try {
log.debug(`Looking for DROPDOWN xpath of ${kleur.yellow(key)}`)
const targetElement = await fetchFirstXPath(page, dropdownXpathQueries[key], 3000)
// must be a dropdown...
log.debug(`Found dropdown! Clicking element of ${kleur.yellow(key)}`)
targetElement.click()
try {
const showMoreElement = await fetchFirstXPath(
page,
"//button[@data-qa-id='ui-lib-title-popover-item']",
600,
false
)
log.warn("Clicking 'Show More' button...")
showMoreElement.evaluate((e) => e.click())
} catch (e) {
log.trace("Checked for 'Show More' button, but found nothing")
}
await waitForTimeout(0.9, "let dropdown populate")
let selector = dropdownOptionsXpathQueries[key];
if (selector === undefined || selector == null) {
console.error(`Oops, no selector found for key: ${key}`)
}
await selectFromDropDown(conditionOrInputValue, selector)
await waitForTimeout(0.4, "after selecting from dropdown")
} catch (e) {
if (e.constructor.name === "TimeoutError") {
if (!inputXpathQueries[key])
throw new NoInputFoundError(
`Unable to find dropdown xpath target for primaryLeft/secondary. Make sure chart layout is SAVED with an indicator that contains/matches this: ${conditionOrInputValue}`
)
log.debug(`Timed out looking for dropdown. Looking for INPUT xpath of ${kleur.yellow(key)}`)
try {
const valueInput = await fetchFirstXPath(page, inputXpathQueries[key], 1000)
log.debug(`Typing value: ${kleur.blue(conditionOrInputValue)}`)
await clickInputAndDelete(page, valueInput)
await valueInput.type(String(conditionOrInputValue))
} catch (inputError) {
if (inputError.constructor.name === "TimeoutError") {
if (!readOnlyInputQueries[key])
throw new NoInputFoundError(
`Unable to find 'readonlyInput' xpath target for ${key} which doesn't have inputs, so won't even try`
)
log.debug(`Timed out looking for input. Looking for READ-ONLY INPUT xpath of ${kleur.yellow(key)}`)
try {
const valueReadonlyInput = await fetchFirstXPath(page, readOnlyInputQueries[key], 1000)
/* istanbul ignore next */
const readOnlyValue = await page.evaluate((el) => el.value, valueReadonlyInput)
if (readOnlyValue === conditionOrInputValue) {
log.debug(`looks like the readonly input is actually ${conditionOrInputValue} as expected`)
} else {
throw new Error(
`Read only input value for ${key} is ${readOnlyValue}, but expected ${conditionOrInputValue}`
)
}
} catch (readOnlyInputError) {
if (readOnlyInputError.constructor.name === "TimeoutError") {
throw new NoInputFoundError(
`Unable to find any inputs for '${key}' for configured value: '${conditionOrInputValue}'`
)
} else {
throw readOnlyInputError
}
}
} else {
throw inputError
}
}
} else {
throw e
}
}
}
}
await performActualEntry("primaryLeft")
try {
await performActualEntry("primaryRight")
await performActualEntry("secondary")
} catch (e) {
if (e instanceof NoInputFoundError) {
log.debug("NoInputFoundError, maybe we need to send secondary before setting primaryRight")
// sometimes the secondary must be set first before the primaryRight shows up
await performActualEntry("secondary")
await performActualEntry("primaryRight")
} else {
throw e
}
}
await performActualEntry("tertiaryLeft")
await performActualEntry("tertiaryRight")
await performActualEntry("quaternaryLeft")
await performActualEntry("quaternaryRight")
await waitForTimeout(0.4)
// if (!!option) {
// log.debug(`Looking for trigger: ${kleur.blue(option)}`)
//
// try {
//
// const targetElement = await fetchFirstXPath(page, "//fieldset[@aria-label='Trigger']//span[@role='button']", 3000)
// log.debug(`Found dropdown! Clicking element of ${kleur.yellow(option)}`)
// targetElement.evaluate((b) => b.click())
// await waitForTimeout(.3)
// await selectFromDropDown(option, "//div[@data-name='popup-menu-container']//div[@role='option']/span/span/div")
//
// } catch (e) {
// if (e.constructor.name === "TimeoutError") {
// throw new Error(`No fire rate 'option' available, but one was specified in alert configuration: ${option}`)
// } else {
// throw e
// }
// }
//
// }
if (trigger) {
log.debug(`Looking for trigger: ${kleur.blue(trigger)}`)
try {
const triggerDropdownButton = await fetchFirstXPath(
page,
"//button[contains(@data-qa-id, 'trigger-dropdown-button')]"
)
triggerDropdownButton.click()
await waitForTimeout(0.3)
await selectFromDropDown(
trigger,
"//div[@data-qa-id='trigger-dropdown-button-container']//div[@role='option']//div[contains(@class, 'title-')]"
)
} catch (e) {
if (e.constructor.name === "TimeoutError") {
throw new Error(`Trigger option available, but one was specified in alert configuration: ${trigger}`)
} else {
throw e
}
}
}
// if (!!option) {
// log.debug(`Looking for trigger: ${kleur.blue(option)}`)
//
// const selector = "//legend[text()='Trigger']/../..//button//span[contains(@class,'ellipsisContainer')]"
//
// try {
// await page.waitForSelector("xpath/." + selector, {timeout: 8000})
// } catch (e) {
// if (e.constructor.name === "TimeoutError") {
// throw new Error(`No fire rate 'option' available, but one was specified in alert configuration: ${option}`)
// } else {
// throw e
// }
// }
//
// const elements = await page.$$("xpath/." + selector)
//
// let found = false
// let foundOptions = []
// for (const el of elements) {
// /* istanbul ignore next */
// let optionText = await page.evaluate(element => element.innerText, el);
// foundOptions.push(optionText.trim())
// if (optionText.toLowerCase() === option.toLowerCase() && !found) {
// log.debug(`Found! Clicking ${kleur.yellow(optionText)}`)
// found = true
// await waitForTimeout(.4);
// await el.click()
// // /* istanbul ignore next */
// // await page.evaluate((text) => {
// // const el = document.evaluate(`//*[@class='js-fire-rate-row']//div[@data-title='${text}']`,
// // document,
// // null,
// // XPathResult.FIRST_ORDERED_NODE_TYPE,
// // null
// // ).singleNodeValue as HTMLElement
// // el.click()
// //
// // }, option)
// await waitForTimeout(.4);
// // const justClickedEl = await fetchFirstXPath(page, `//*[@class='js-fire-rate-row']//div[@data-title='${option}']/..`)
// //
// // /* istanbul ignore next */
// // const className = await page.evaluate(el => el.className, justClickedEl);
// //
// // if (className.indexOf("i-active") < 0) {
// // log.error("option element was clicked, but it's parent does not have the 'i-active' class assigned")
// // throw Error("Unable to select option correctly...a bug in the system")
// // }
//
// }
// }
// if (!found) throw new SelectionError(option, foundOptions)
// }
if (expireOpenEnded !== undefined) {
log.debug("set whether to expire open ended")
await waitForTimeout(0.1)
const expDropdownTarget = await fetchFirstXPath(page, "//legend[text()='Expiration']/../..//button")
await expDropdownTarget.click()
await waitForTimeout(0.3)
const expirationDropdownSelector = "//div[@data-qa-id='expiration-time-dropdown-container']//div[contains(@data-qa-id, 'expiration-time-dropdown-item')]//div[contains(@class, 'title-')]"
if (!expireOpenEnded && expireInterval) {
log.info(`Set custom expiration date`)
await selectFromDropDown("Custom date", expirationDropdownSelector)
log.info(`Set Expiration ${kleur.blue(expireInterval)} hours in the future`)
const dateInput = await fetchFirstXPath(page, "//div[contains(@class, 'pickerInput-')]//input")
const timeInput = await fetchFirstXPath(page, "//div[contains(@class, 'time-')]//input")
const dateISO = await page.evaluate((expireInterval) => {
console.log("expireInterval: " + expireInterval)
const currentDate = new Date()
currentDate.setTime(
currentDate.getTime() - currentDate.getTimezoneOffset() * 60000 + expireInterval * 60 * 60 * 1000
)
return currentDate.toISOString()
}, expireInterval)
const [splitDate, timeFull] = dateISO.split("T")
const [hours, min, secs] = timeFull.split(":")
const expDate = splitDate.replaceAll("-", "")
const expTime = `${hours}${min}`
log.debug(`exp_date: ${expDate}`)
log.debug(`exp_time: ${expTime}`)
await dateInput.click()
await waitForTimeout(500)
for (let i = 0; i < 11; i++) {
await page.keyboard.press('ArrowRight');
// Optional: tiny delay if the site is very laggy / virtualized
await waitForTimeout(20)
}
for (const l of "yyyy-mm-dd".split("")) {
await waitForTimeout(50)
await page.keyboard.press("Backspace")
}
await dateInput.type(String(expDate), {delay: 40})
await waitForTimeout(50)
await timeInput.click()
await waitForTimeout(50)
await page.keyboard.press("End")
for (const l of "hh:mm".split("")) {
await waitForTimeout(50)
await page.keyboard.press("Backspace")
}
await timeInput.type(String(expTime), {delay: 40})
await waitForTimeout(1000)
const submitTimeButton = await fetchFirstXPath(page, "//button[contains(@data-qa-id, 'expiration-set-button')]")
submitTimeButton.click()
} else if (expireOpenEnded) {
await selectFromDropDown("Open-ended", expirationDropdownSelector)
}
}
await waitForTimeout(0.2)
// Click message tab
const messageTab = await fetchFirstXPath(page, "//button[@data-qa-id='alert-message-button']")
await messageTab.click()
await waitForTimeout(0.4)
if (name) {
log.debug(`Setting Alert Name: ${kleur.blue(name)}`)
try {
const customAlertNameButton = await fetchFirstXPath(page, "//button[@id='alert-name']", 1000, false)
log.warn("Clicking custom alert name button")
customAlertNameButton.evaluate((e) => e.click())
} catch (e) {
log.trace("Tried to click custom alert name button, but got nothing")
}
const nameInput = await fetchFirstXPath(page, "//input[@id='alert-name']")
await clickInputAndDelete(page, nameInput)
await nameInput.type(name)
await waitForTimeout(0.5)
}
if (message) {
log.debug(`Setting message: ${kleur.blue(message)}`)
try {
const messageTextarea = await fetchFirstXPath(page, "//textarea[@id='alert-message']", 1000, false)
await clickInputAndDelete(page, messageTextarea)
await messageTextarea.type(message)
} catch (e) {
if (e.constructor.name === "TimeoutError") {
log.warn(`No message box found, but message specified: ${message}`)
} else {
throw e
}
}
}
await waitForTimeout(0.2)
const submitMessageForm = await fetchFirstXPath(page, "//button[@data-qa-id='submit']")
await submitMessageForm.click()
await waitForTimeout(0.7)
// Click notifications tab
const notificationsTab = await fetchFirstXPath(page, "//button[@data-qa-id='alert-notifications-button']")
await notificationsTab.click()
await waitForTimeout(0.4)
// alert actions
for (const [configKey, elementInputName] of Object.entries(alertActionCorresponding)) {
if (!!actions && !!actions[configKey] !== undefined) {
await waitForTimeout(0.3)
const el = await fetchFirstXPath(page, `//label[@data-qa-id='${elementInputName}']//input`)
/* istanbul ignore next */
const isChecked = await page.evaluate((element) => element.checked, el)
if (configKey === "webhook") {
if (isChecked !== actions.webhook.enabled) {
log.debug(`setting ${kleur.blue("webhook")} as checked`)
el.click()
await waitForTimeout(0.3)
}
if (actions.webhook.enabled && actions.webhook.url) {
await waitForTimeout(0.3)
log.debug(`typing webhook url: ${kleur.blue(actions.webhook.url)}`)
const webhookUrlEl = await fetchFirstXPath(page, `//input[contains(@placeholder, 'alert-hook')]`, 1000)
await clickInputAndDelete(page, webhookUrlEl)
await webhookUrlEl.type(String(actions.webhook.url))
}
} else if (configKey === "playSound") {
if (actions.playSound?.enabled !== undefined && isChecked !== actions.playSound?.enabled) {
log.debug(`setting ${kleur.blue("play-sound")} input as checked`)
el.click()
await waitForTimeout(0.3)
}
if (actions.playSound?.enabled && actions.playSound?.name && actions.playSound?.duration) {
{
await waitForTimeout(0.5)
try {
const classicSoundsSectionButton = await fetchFirstXPath(
page,
dropdownSoundXpathQueries["classicSection"],
3000,
false
)
classicSoundsSectionButton.evaluate((b) => b.click())
log.trace("Clicked to expand 'Classic' sounds")
} catch (e) {
log.trace("'Classic' sounds section already open")
}
await waitForTimeout(0.1)
log.debug(`Looking for DROPDOWN xpath of ${kleur.yellow("playSound.name")}`)
const targetElement = await fetchFirstXPath(page, dropdownSoundXpathQueries["nameTarget"], 3000)
log.debug(`Found dropdown! Clicking element of ${kleur.yellow(actions.playSound.name)}`)
targetElement.evaluate((b) => b.click())
await waitForTimeout(0.3)
await selectFromDropDown(actions.playSound.name, dropdownSoundXpathQueries["nameListItems"])
}
{
await waitForTimeout(0.5)
log.debug(`Looking for DROPDOWN xpath of ${kleur.yellow("playSound.duration")}`)
const targetElement = await fetchFirstXPath(page, dropdownSoundXpathQueries["durationTarget"], 3000)
log.debug(`Found dropdown! Clicking element of ${kleur.yellow(actions.playSound.duration)}`)
targetElement.evaluate((b) => b.click())
await waitForTimeout(0.3)
await selectFromDropDown(actions.playSound.duration, dropdownSoundXpathQueries["durationListItems"])
}
await waitForTimeout(0.5)
}
} else {
if (isChecked !== actions[configKey]) {
log.debug(`setting ${kleur.blue(configKey)} as checked`)
el.click()
}
}
}
}
await waitForTimeout(0.2)
const submitAlertsForm = await fetchFirstXPath(page, "//button[@data-qa-id='submit']")
await submitAlertsForm.click()
await waitForTimeout(0.5)
}
export const clickSubmit = async (page) => {
log.debug("clickSubmit()")
const submitButton = await fetchFirstXPath(
page,
"//div[contains(@data-qa-id, 'alerts-create-edit-dialog')]//button[@data-qa-id='submit']"
)
submitButton.evaluate((b) => b.click())
}
// sometimes there's a warning of "this alert may trigger differently than expected"
export const clickContinueIfWarning = async (page) => {
try {
log.debug("clickContinueIfWarning()")
const continueAnywayButton = await fetchFirstXPath(page, `//button[@name='continue']`, 3000, false)
continueAnywayButton.evaluate((b) => b.click())
await waitForTimeout(4, "waiting after clicking 'continue anyway' button")
} catch (error) {
log.debug("No warning dialog")
}
}
export const addAlert = async (page, singleAlertSettings: ISingleAlertSettings) => {
log.debug("addAlert()")
const typeShortcutForAlertDialog = async () => {
log.debug("addAlert()...pressing shortcut key")
await page.keyboard.press("Escape")
await waitForTimeout(0.5)
await page.keyboard.press("Escape")
await waitForTimeout(0.5)
await page.keyboard.down("AltLeft")
await page.keyboard.press("a")
await page.keyboard.up("AltLeft")
await waitForTimeout(0.5, "after keyboard shortcut for new alert dialog")
}
await typeShortcutForAlertDialog()
await waitForTimeout(0.7, "..make sure we're showing the alert dialog")
const isNotShowingAlertDialog = async () => {
return !(await isXpathVisible(page, "//div[contains(@data-qa-id, 'alerts-create-edit-dialog')]"))
}
if (await isNotShowingAlertDialog()) {
log.warn("NOT showing alert dialog! maybe invalid symbol?")
if (await isXpathVisible(page, '//*[text()="Can\'t create alert on invalid symbol"]')) {
log.error("Looks like we tried to create alert on invalid symbol, throwing error")
throw new InvalidSymbolError()
}
const MAX_TRIES = 3
let retryCount = 1
while ((await isNotShowingAlertDialog()) && retryCount <= MAX_TRIES + 1) {
if (retryCount === MAX_TRIES) {
await takeScreenshot(page, "unable_to_bring_up_alert_dialog")
throw new AddAlertInvocationError()
}
log.debug("Attempting to show alert dialog again...")
await waitForTimeout(retryCount, "pausing a little")
await typeShortcutForAlertDialog()
retryCount += 1
}
}
await configureSingleAlertSettings(page, singleAlertSettings)
await waitForTimeout(0.5)
await takeScreenshot(page, "before_submitting_alert")
await clickSubmit(page)
await waitForTimeout(2)
await clickContinueIfWarning(page)
await waitForTimeout(2, "waiting a little after adding")
}