diff --git a/src/botmation/actions/abort.ts b/src/botmation/actions/abort.ts index b803eb37b..afa499ac1 100644 --- a/src/botmation/actions/abort.ts +++ b/src/botmation/actions/abort.ts @@ -2,6 +2,8 @@ import { BotAction } from "../interfaces" import { AbortLineSignal } from '../types' import { PipeValue } from "../types/pipe-value" import { createAbortLineSignal } from "../helpers/abort" +import { pipeCase } from "./pipe" +import { CasesSignal, CaseValue } from "../types/cases" /** * BotAction to return an AbortLineSignal to be processed by an assembler for an effect of aborting assembled lines (including parent(s) if specified a number greater than 1 or 0 for all) @@ -11,4 +13,16 @@ import { createAbortLineSignal } from "../helpers/abort" or pass it along to the next assembler if the assembledLines is greater than 1 as the pipeValue is returned by the final aborted assembler */ export const abort = (assembledLines = 1, pipeValue?: PipeValue): BotAction => - async() => createAbortLineSignal(assembledLines, pipeValue) \ No newline at end of file + async() => createAbortLineSignal(assembledLines, pipeValue) + +/** + * Return an AbortLineSignal of 1 assembledLine if the value provided equals the pipe value or the value provided is a callback function when given the pipe value returns true + * cb gets the pipeValue. If cb returns true, then abort the pipe line + * @param value the value to test against the pipeValue for equality unless function then call function with value and if function returns truthy then Abort + * @param abortPipeValue the pipeValue of the AbortLineSignal returned + * @param assembledLines the assembledLines of the AbortLineSignal returned + */ +export const abortPipe = (value: CaseValue, abortPipeValue: PipeValue = undefined, assembledLines: number = 1): BotAction => + pipeCase(value)( + abort(assembledLines + 2, abortPipeValue) + ) // returns AbortLineSignal(1, abortPipeValue?) if value(pipeValue) is truthy || value === pipeValue diff --git a/src/botmation/actions/assembly-lines.ts b/src/botmation/actions/assembly-lines.ts index 5d07433ac..8ff0cdb24 100644 --- a/src/botmation/actions/assembly-lines.ts +++ b/src/botmation/actions/assembly-lines.ts @@ -10,8 +10,7 @@ import { import { PipeValue } from "../types/pipe-value" import { AbortLineSignal, isAbortLineSignal } from "../types/abort-line-signal" import { processAbortLineSignal } from "../helpers/abort" -import { isMatchesSignal, MatchesSignal } from "botmation/types/matches-signal" -import { hasAtLeastOneMatch } from "botmation/helpers/matches" +import { isCasesSignal, CasesSignal } from "../types/cases" /** * @description chain() BotAction for running a chain of BotAction's safely and optimized @@ -121,29 +120,14 @@ export const pipe = * @param toPipe BotAction to resolve and inject as a wrapped Pipe object in EACH assembled BotAction */ export const switchPipe = - (toPipe?: BotAction | Exclude) => - (...actions: BotAction[]): BotAction => + (toPipe?: PipeValue) => + (...actions: BotAction[]): BotAction => async(page, ...injects) => { // fallback is injects pipe value if (!toPipe) { toPipe = getInjectsPipeValue(injects) } - // if function, it's an async BotAction function - // resolve it to Pipe the resolved value - if (typeof toPipe === 'function') { - if(injectsHavePipe(injects)) { - toPipe = await toPipe(page, ...injects) - } else { - // simulate pipe - toPipe = await toPipe(page, ...injects, createEmptyPipe()) - } - - if (isAbortLineSignal(toPipe)) { - return processAbortLineSignal(toPipe) - } - } - // remove pipe from injects if there is one to set the one for all actions if (injectsHavePipe(injects)) { injects = injects.slice(0, injects.length - 1) @@ -162,7 +146,7 @@ export const switchPipe = // resolvedActionResult can be of 3 things // 1. MatchesSignal 2. AbortLineSignal 3. PipeValue // switchPipe will return (if not aborted) an array of all the resolved results of each BotAction assembled in the switchPipe()() 2nd call - if (isMatchesSignal(resolvedActionResult) && hasAtLeastOneMatch(resolvedActionResult)) { + if (isCasesSignal(resolvedActionResult) && resolvedActionResult.conditionPass) { hasAtLeastOneCaseMatch = true actionsResults.push(resolvedActionResult) } else if (isAbortLineSignal(resolvedActionResult)) { diff --git a/src/botmation/actions/navigation.ts b/src/botmation/actions/navigation.ts index 6b8e10ef5..f7aef5748 100644 --- a/src/botmation/actions/navigation.ts +++ b/src/botmation/actions/navigation.ts @@ -1,7 +1,9 @@ import { DirectNavigationOptions, NavigationOptions } from 'puppeteer' import { BotAction } from '../interfaces/bot-actions' -import { enrichGoToPageOptions, sleep } from '../helpers/navigation' +import { enrichGoToPageOptions, sleep, scrollToElement } from '../helpers/navigation' +import { chain } from './assembly-lines' +import { evaluate } from './scrapers' /** * @description Go to url provided in the current page @@ -63,3 +65,13 @@ export const waitForNavigation: BotAction = async(page) => { export const wait = (milliseconds: number): BotAction => async() => { await sleep(milliseconds) } + +/** + * + * @param htmlSelector + */ +export const scrollTo = (htmlSelector: string): BotAction => + chain( + evaluate(scrollToElement, htmlSelector), + wait(2500) // wait for scroll to complete + ) \ No newline at end of file diff --git a/src/botmation/actions/pipe.ts b/src/botmation/actions/pipe.ts index bec082770..a5cc2ce47 100644 --- a/src/botmation/actions/pipe.ts +++ b/src/botmation/actions/pipe.ts @@ -1,10 +1,11 @@ import { BotAction } from "../interfaces" import { PipeValue } from "../types/pipe-value" import { getInjectsPipeValue, injectsHavePipe } from "../helpers/pipe" -import { MatchesSignal } from "../types/matches-signal" +import { CasesSignal, CaseValue } from "../types/cases" import { AbortLineSignal, Dictionary, isAbortLineSignal } from "../types" import { pipe } from "./assembly-lines" -import { createMatchesSignal } from "../helpers/matches" +import { createCasesSignal } from "../helpers/cases" +import { processAbortLineSignal } from "../helpers/abort" // // BotAction's Focused on Piping @@ -15,11 +16,11 @@ import { createMatchesSignal } from "../helpers/matches" * @description Mapper function for Mapping Piped Values to whatever you want through a function * If the Pipe is missing from the `injects`, undefined will be past into the mapFunction, like an empty Pipe * @param mapFunction pure function to change the piped value to something else + * Rename pipeMap ? */ -export const map = (mapFunction: (pipedValue: any) => R): BotAction => +export const map = (mapFunction: (pipedValue: any) => R): BotAction => async (page, ...injects) => mapFunction(getInjectsPipeValue(injects)) - /** * @description Sets the Pipe's value for the next BotAction @@ -39,12 +40,13 @@ export const emptyPipe: BotAction = async () => undefined * @return AbortLineSignal|MatchesSignal * If no matches are found or matches are found, a MatchesSignal is returned * It is determined if signal has matches by using hasAtLeastOneMatch() helper - * If assembled BotAction aborts(1), it still returns a MatchesSignal with the matches - * If assembled BotAction aborts(2+), it returns a processed AbortLineSignal + * If assembled BotAction aborts(1), it breaks line & returns a MatchesSignal with the matches + * If assembled BotAction aborts(2), it breaks line & returns AbortLineSignal.pipeValue + * If assembled BotAction aborts(3+), it returns AbortLineSignal(2-) */ export const pipeCase = - (...valuesToTest: PipeValue[]) => - (...actions: BotAction[]): BotAction => + (...valuesToTest: CaseValue[]) => + (...actions: BotAction[]): BotAction => async(page, ...injects) => { // if any of the values matches the injected pipe object value // then run the assembled actions @@ -69,14 +71,69 @@ export const pipeCase = const returnValue:PipeValue|AbortLineSignal = await pipe()(...actions)(page, ...injects) if (isAbortLineSignal(returnValue)) { - return returnValue // processed by pipe() + return processAbortLineSignal(returnValue) } else { // signal that a case matched - return createMatchesSignal(matches, returnValue) + return createCasesSignal(matches, true, returnValue) } + } else { + return createCasesSignal(matches, false, pipeObjectValue) // pass through original pipe object as CasesSignal.pipeValue } } // no pipe (nothing to test) or the test resulted in no matches - return createMatchesSignal() // empty matches signal (no matches) - } \ No newline at end of file + return createCasesSignal() // empty matches signal (no matches) + } + +/** + * runs assembled actions ONLY if ALL cases pass otherwise it breaks the case checking and immediately returns an empty MatchesSignal + * it's like if (case && case && case ...) + * Same AbortLineSignal behavior as pipeCase()() + * @param valuesToTest + */ +export const pipeCases = + (...valuesToTest: CaseValue[]) => + (...actions: BotAction[]): BotAction => + async(page, ...injects) => { + // if any of the values matches the injected pipe object value + // then run the assembled actions + const matches: Dictionary = {} + + if (injectsHavePipe(injects)) { + const pipeObjectValue = getInjectsPipeValue(injects) + + for (const [i, value] of valuesToTest.entries()) { + if (typeof value === 'function') { + if (value(pipeObjectValue)) { + matches[i] = value + } else { + break + } + } else { + if (value === pipeObjectValue) { + matches[i] = value + } else { + break + } + } + } + + // only run assembled BotAction's if ALL cases pass + if (Object.keys(matches).length === valuesToTest.length) { + const returnValue:PipeValue|AbortLineSignal = await pipe()(...actions)(page, ...injects) + + if (isAbortLineSignal(returnValue)) { + return processAbortLineSignal(returnValue) // processed by pipe() to abort the line + // processed a 2nd time to abort the returning CasesSignal feature + } else { + // signal that All cases matched + return createCasesSignal(matches, true, returnValue) + } + } else { + return createCasesSignal(matches, false, pipeObjectValue) // pass through original pipe object as CasesSignal.pipeValue + } + } + + return createCasesSignal(matches) // partial matches signal with conditionPass false + } + diff --git a/src/botmation/actions/scrapers.ts b/src/botmation/actions/scrapers.ts index 9bbab3c57..14608d051 100644 --- a/src/botmation/actions/scrapers.ts +++ b/src/botmation/actions/scrapers.ts @@ -2,6 +2,7 @@ * BotAction's dedicated to scraping web pages */ +import { EvaluateFn } from 'puppeteer' import * as cheerio from 'cheerio' import { BotAction, ScraperBotAction } from '../interfaces/bot-actions' @@ -78,3 +79,10 @@ export const $$ = (htmlSelector: string, higherOrderHTMLPar return cheerioEls as any as R } +/** + * Evaluate functions inside the `page` context + * @param functionToEvaluate + * @param functionParams + */ +export const evaluate = (functionToEvaluate: EvaluateFn, ...functionParams: any[]): BotAction => + async(page) => await page.evaluate(functionToEvaluate, ...functionParams) diff --git a/src/botmation/helpers/cases.ts b/src/botmation/helpers/cases.ts new file mode 100644 index 000000000..859b32d46 --- /dev/null +++ b/src/botmation/helpers/cases.ts @@ -0,0 +1,21 @@ +import { Dictionary, PipeValue } from "../types" +import { CasesSignal, isCasesSignal } from "../types/cases" + +/** + * Create a CasesSignal object with safe defaults for no params provided (default is no matches, pipe value undefined, and conditionPass false) + * @param matches + * @param pipeValue + */ +export const createCasesSignal = (matches: Dictionary = {}, conditionPass: boolean = false, pipeValue?: PipeValue): CasesSignal => ({ + brand: 'Cases_Signal', + conditionPass, + matches, + pipeValue +}) + +/** + * If a CasesSignal is provided, its pipeValue is returned, otherwise `undefined` is returned + * @param casesSignal + */ +export const casesSignalToPipeValue = (casesSignal: CasesSignal|any): PipeValue => + isCasesSignal(casesSignal) ? casesSignal.pipeValue : undefined \ No newline at end of file diff --git a/src/botmation/helpers/matches.ts b/src/botmation/helpers/matches.ts deleted file mode 100644 index 1253fe8a9..000000000 --- a/src/botmation/helpers/matches.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Dictionary, PipeValue } from "../types" -import { MatchesSignal } from "../types/matches-signal" - -/** - * Create a MatchesSignal object with safe defaults for no params provided (default is no matches, and pipe value undefined) - * @param matches - * @param pipeValue - */ -export const createMatchesSignal = (matches: Dictionary = {}, pipeValue?: PipeValue): MatchesSignal => ({ - brand: 'Matches_Signal', - matches, - pipeValue -}) - -/** - * Does the MatchesSignal have at least one match represented? - * @param signal - */ -export const hasAtLeastOneMatch = (signal: MatchesSignal): boolean => - Object.keys(signal.matches).length > 0 \ No newline at end of file diff --git a/src/botmation/helpers/navigation.ts b/src/botmation/helpers/navigation.ts index a3e4c2cc8..e07395acb 100644 --- a/src/botmation/helpers/navigation.ts +++ b/src/botmation/helpers/navigation.ts @@ -15,4 +15,12 @@ export const enrichGoToPageOptions = (overloadDefaultOptions: Partial => - new Promise(resolve => setTimeout(resolve, milliseconds)) \ No newline at end of file + new Promise(resolve => setTimeout(resolve, milliseconds)) + +/** + * + * @param htmlSelector + */ +export const scrollToElement = (htmlSelector: string) => + document.querySelector(htmlSelector)?.scrollIntoView({behavior: 'smooth'}) + diff --git a/src/botmation/sites/linkedin/actions/feed.ts b/src/botmation/sites/linkedin/actions/feed.ts index ece8cc889..62a555454 100644 --- a/src/botmation/sites/linkedin/actions/feed.ts +++ b/src/botmation/sites/linkedin/actions/feed.ts @@ -1,50 +1,58 @@ import { - ConditionalBotAction, BotAction, pipe, $$, forAll, - givenThat, click, errors, map } from '../../..' -import { goToFeed } from './navigation' +import { switchPipe } from '../../../actions/assembly-lines' +import { pipeCase, emptyPipe } from '../../../actions/pipe' +import { abort } from '../../../actions/abort' + import { feedPostsSelector, feedPostAuthorSelector } from '../selectors' +import { log } from '../../../actions/console' +import { casesSignalToPipeValue } from '../../../helpers/cases' +import { scrollTo } from '../../../actions/navigation' +import { $ } from '../../../actions/scrapers' +import { + postHasntFullyLoadedYet, + postIsPromotion, + postIsJobPostings, + postIsUserInteraction, + postIsUserArticle, + postIsAuthoredByAPerson +} from '../helpers/feed' + /** * Returns an array of CheerioStatic HTML elements representing the Feed's posts - * @param filterPromotedContent optional, default is TRUE to remove posts from scraped feed if they are "Promoted" */ -export const getFeedPosts = (filterPromotedContent: boolean = true): BotAction => - pipe()( - goToFeed, - $$(feedPostsSelector), - map((cheerioPosts: CheerioStatic[]) => { - if (!filterPromotedContent) { - return cheerioPosts - } +export const scrapeFeedPosts: BotAction = $$(feedPostsSelector) - return cheerioPosts.filter(post => post('.feed-shared-actor__sub-description').text().toLowerCase() !== 'promoted') - }) - ) +/** + * + * @param postDataId + */ +export const scrapeFeedPost = (postDataId: string): BotAction => + $('.application-outlet .feed-outlet [role="main"] [data-id="'+ postDataId + '"]') /** - * Returns TRUE if at least one person name closely matches the author name of the provided Post, otherwise FALSE - * @future This function is coupled with the getFeedPosts. - * It would be nice to rely on ie Post.id as param to then find that "Like" button in page to click. In order to, de-couple this function + * If the post hasn't been populated (waits loading), then scroll to it to trigger lazy loading then scrape it to return the hydrated version of it * @param post - * @param peopleNames */ -export const postIsAuthoredByAPerson = (post: CheerioStatic, ...peopleNames: string[]): ConditionalBotAction => - async() => - // if the CheerioStatic post has close matching in author text box a name from peopleNames list, then TRUE else FALSE - peopleNames.some(name => name.toLowerCase() === post(feedPostAuthorSelector).text().toLowerCase()) - // TODO add helpers for fuzzy text matching using nGrams(3) -> trigrams with like 80% threshold and higher-order params override (ie for 100%) - // that way, adding/removing nicknames, middle initials, etc will not break script - // use https://www.npmjs.com/package/trigram-utils asDictionary(), build unique list of key's, and see how much overlaps - // don't forget to buffer the strings with spaces (1 before and 1 after to increase matching potential slightly, since this is a few words instead of a sentence(s)) - +export const ifPostNotLoadedTriggerLoadingThenScrape = (post: CheerioStatic): BotAction => + // linkedin lazily loads off screen posts, so check beforehand, and if not loaded, scroll to it, then scrape it again + pipe(post)( + errors('LinkedIn triggerLazyLoadingThenScrapePost()')( + pipeCase(postHasntFullyLoadedYet)( + scrollTo('.application-outlet .feed-outlet [role="main"] [data-id="'+ post('[data-id]').attr('data-id') + '"]'), + scrapeFeedPost(post('[data-id]').attr('data-id') + '') + ), + map(casesSignalToPipeValue) // if pipeCase doesn't run, it returns CasesSignal with original pipeValue otherwise it returns CasesSignal with new pipeValue from resolved inner pipe + ) + ) /** * Clicks the "Like" button inside the provided Post, if the Like button hasn't been pressed @@ -54,7 +62,7 @@ export const postIsAuthoredByAPerson = (post: CheerioStatic, ...peopleNames: str * It would be nice to rely on ie Post.id as param to then find that "Like" button in page to click. In order to, de-couple this function * @param post */ -export const like = (post: CheerioStatic): BotAction => +export const likeArticle = (post: CheerioStatic): BotAction => // Puppeteer.page.click() returned promise will reject if the selector isn't found // so if button is Pressed, it will reject since the aria-label value will not match errors('LinkedIn like() - Could not Like Post: Either already Liked or button not found')( @@ -63,22 +71,50 @@ export const like = (post: CheerioStatic): BotAction => // be cool if errors had the ability to be provided a BotAction to run in a simulated pipe and returned on error // so here, we can return ie a value signaling we did not like it because it was already liked - /** - * @description Clicks the "Like" button for every Post presently loaded in your feed (not including future lazily loaded, as triggered by scrolling near bottom), filtered by author name - * Does not load in lazily loaded "pages" of feed (on scroll), therefore would need to add a forAsLong()() to get a new list of feed posts, scroll/liking to the end, etc with this function on each "page" - * Maybe an exit condition by date? Stop going once posts are X days old + * @description Demonstration of what's currently possible, this function goes beyond the scope of its name, but to give an idea on how something more complex could be handled * @param peopleNames */ -export const likeAllFrom = (...peopleNames: string[]): BotAction => +export const likeArticlesFrom = (...peopleNames: string[]): BotAction => pipe()( - getFeedPosts(), + scrapeFeedPosts, forAll()( - post => givenThat(postIsAuthoredByAPerson(post, ...peopleNames))( - // scroll to post necessary to click off page link? ie click anchor link (new scrollTo() "navigation" BotAction?) - // the feature, auto-scroll, was added to `page.click()` but in a later Puppeteer version, irc - like(post) + post => pipe(post)( + ifPostNotLoadedTriggerLoadingThenScrape(post), + switchPipe()( + pipeCase(postIsPromotion)( + map((promotionPost: CheerioStatic) => promotionPost('[data-id]').attr('data-id')), + log('Promoted Content') + ), + abort(), + pipeCase(postIsJobPostings)( + map((jobPostingsPost: CheerioStatic) => jobPostingsPost('[data-id]').attr('data-id')), + log('Job Postings') + ), + abort(), + pipeCase(postIsUserInteraction)( + map((userInteractionPost: CheerioStatic) => userInteractionPost('[data-id]').attr('data-id')), + log(`Followed User's Interaction (ie like, comment, etc)`) + ), + abort(), + pipeCase(postIsUserArticle)( + pipeCase(postIsAuthoredByAPerson(...peopleNames))( + // scroll to post necessary to click off page link? ie use scrollTo() "navigation" BotAction + // the feature, auto-scroll, was added to `page.click()` in later Puppeteer version, irc + likeArticle(post), + log('User Article "liked"') + ), + emptyPipe, + log('User Article') + ), + abort(), + // default case to run if we got here by not aborting + pipe()( + map((unhandledPost: CheerioStatic) => unhandledPost('[data-id]').text()), + log('Unhandled Post Case') + ) + ) ) ) ) \ No newline at end of file diff --git a/src/botmation/sites/linkedin/helpers/feed.ts b/src/botmation/sites/linkedin/helpers/feed.ts new file mode 100644 index 000000000..dc2762e2c --- /dev/null +++ b/src/botmation/sites/linkedin/helpers/feed.ts @@ -0,0 +1,85 @@ +import { ConditionalCallback } from '../../../types/callbacks' +import { feedPostAuthorSelector } from '../selectors' + +/** + * User articles are posts created by users you're either connected too directly (1st) or are following + * @param peopleNames + */ +export const postIsUserArticle: ConditionalCallback = (post: CheerioStatic) => { + const sharedActorFeedSupplementaryInfo = post('.feed-shared-actor__supplementary-actor-info').text().trim().toLowerCase() + + return sharedActorFeedSupplementaryInfo.includes('1st') || sharedActorFeedSupplementaryInfo.includes('following') +} + +/** + * Returns TRUE if at least one person name closely matches the author name of the provided Post, otherwise FALSE + * @future This function is coupled with the getFeedPosts. + * It would be nice to rely on ie Post.id as param to then find that "Like" button in page to click. In order to, de-couple this function + * @param post + * @param peopleNames + */ +export const postIsAuthoredByAPerson = (...peopleNames: string[]): ConditionalCallback => (post: CheerioStatic) => + peopleNames.some(name => name.toLowerCase() === post(feedPostAuthorSelector).text().toLowerCase()) + +/** + * Is the post a Promoted piece of Content aka an ad? + * @param post + */ +export const postIsPromotion: ConditionalCallback = (post: CheerioStatic) => + post('.feed-shared-actor__sub-description').text().trim().toLowerCase().includes('promoted') + + +/** + * + * @param post + */ +export const postIsJobPostings: ConditionalCallback = (post: CheerioStatic) => { + const dataId = post('[data-id]').attr('data-id') // check initial div attribute data-id + // is it possible to select first element with a child/descendant selector? cheerio supported selectors https://github.com/fb55/css-select#supported-selectors + + if(!dataId) return false + + // example of what we're looking for: + // urn:li:aggregate:(urn:li:jobPosting:1990182920,urn:li:jobPosting:2001275620,urn:li:jobPosting:2156262070,urn:li:jobPosting:1989903273,urn:li:jobPosting:1974185047) + const dataIdParts = dataId.split(':') + + // dataIdParts.toString() ie = "urn,li,aggregate,(urn,li,jobPosting,1990182920,urn,li,jobPosting,2001275620,urn,li,jobPosting,2156262070,urn,li,jobPosting,1989903273,urn,li,jobPosting,1974185047)" + return dataIdParts.length >= 5 && // 4 is an empty aggregate + dataIdParts[2] === 'aggregate' && // looking for an aggregate of job posts + dataIdParts.slice(3).some(part => part === 'jobPosting') // again, looking for job postings - (this time in the aggregate "spread params" that basically look like jobPostingId's) + +} + +/** + * + * The post represents a "like" of a User the bot's account follows + * @param post + */ +export const postIsUserInteraction: ConditionalCallback = (post: CheerioStatic) => { + // post belongs to a followed user of the bot's account where they liked another post + const feedPostSiblingText = post('h2.visually-hidden:contains("Feed post") + div span').text().trim().toLowerCase() + + // console.log('feedPostSiblingText = ' + feedPostSiblingText) + + // look for h2 that :contains('Feed post') // has css class visually-hidden + // it's sibling div should contain "likes this" in a span + return feedPostSiblingText.includes('likes this') || + feedPostSiblingText.includes('loves this') || + feedPostSiblingText.includes('celebrates this') || + feedPostSiblingText.includes('commented on this') +} + +/** + * + * @param post + */ +export const postHasntFullyLoadedYet: ConditionalCallback = (post: CheerioStatic) => { + // linkedin cleverly loads its posts lazily, with DOM rendering in mind, which maintains a smoother scrolling UX + // Example (some spacing trimmed): + //
+ //
+ // + // + //
+ return post('[data-id]').text().trim() === '' +} \ No newline at end of file diff --git a/src/botmation/types/callbacks.ts b/src/botmation/types/callbacks.ts new file mode 100644 index 000000000..065de23b6 --- /dev/null +++ b/src/botmation/types/callbacks.ts @@ -0,0 +1,9 @@ +import { PipeValue } from "./pipe-value" + +/** + * Function that operates on a PipeValue to return a boolean + * like Checking the pipe value against a case (ie equals 5?) + */ +export interface ConditionalCallback extends Function { + (value: V) : boolean +} \ No newline at end of file diff --git a/src/botmation/types/cases.ts b/src/botmation/types/cases.ts new file mode 100644 index 000000000..79883ede7 --- /dev/null +++ b/src/botmation/types/cases.ts @@ -0,0 +1,31 @@ +import { Dictionary, isDictionary } from "./objects" +import { PipeValue } from "./pipe-value" +import { ConditionalCallback } from "./callbacks" + +/** + * A particular kind of return object for BotAction's that signal the results of an evaluated condition + * while givenThat()() does not use this, it could + * atm pipeCase()() && pipeCases()() return this object to standardize the interface for interpretation + */ +export type CasesSignal = { + brand: 'Cases_Signal', + matches: Dictionary, + conditionPass: boolean, + pipeValue?: PipeValue +} + +/** + * + * @param value + */ +export const isCasesSignal = (value: any): value is CasesSignal => + typeof value === 'object' && + value !== null && + value.brand === 'Cases_Signal' && + typeof value.conditionPass === 'boolean' && + isDictionary(value.matches) + +/** + * @description Value to test against a pipe value for equality or callback truthy + */ +export type CaseValue = Exclude|ConditionalCallback diff --git a/src/botmation/types/matches-signal.ts b/src/botmation/types/matches-signal.ts deleted file mode 100644 index 55d844863..000000000 --- a/src/botmation/types/matches-signal.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Dictionary, isDictionary } from "./objects" -import { PipeValue } from "./pipe-value" - -/** - * - */ -export type MatchesSignal = { - brand: 'Matches_Signal', - matches: Dictionary, - pipeValue?: PipeValue -} - -/** - * - * @param value - */ -export const isMatchesSignal = (value: any): value is MatchesSignal => - typeof value === 'object' && value !== null && value.brand === 'Matches_Signal' && isDictionary(value.matches) \ No newline at end of file diff --git a/src/examples/linkedin.ts b/src/examples/linkedin.ts index 90db3c869..c7c49ea6b 100644 --- a/src/examples/linkedin.ts +++ b/src/examples/linkedin.ts @@ -2,17 +2,15 @@ import puppeteer from 'puppeteer' // General BotAction's import { log } from 'botmation/actions/console' -// import { goTo } from 'botmation/actions/navigation' -import { screenshot } from 'botmation/actions/files' +// import { screenshot } from 'botmation/actions/files' import { loadCookies } from 'botmation/actions/cookies' // More advanced BotAction's -import { pipe, saveCookies, wait, errors, givenThat, forAll, emptyPipe } from 'botmation' +import { pipe, saveCookies, wait, errors, givenThat } from 'botmation' import { login, isGuest, isLoggedIn } from 'botmation/sites/linkedin/actions/auth' import { toggleMessagingOverlay } from 'botmation/sites/linkedin/actions/messaging' -import { getFeedPosts } from 'botmation/sites/linkedin/actions/feed' +import { likeArticlesFrom } from 'botmation/sites/linkedin/actions/feed' import { goHome } from 'botmation/sites/linkedin/actions/navigation' -import { feedPostAuthorSelector } from 'botmation/sites/linkedin/selectors' // Helper for creating filenames that sort naturally const generateTimeStamp = (): string => { @@ -33,7 +31,7 @@ const generateTimeStamp = (): string => { let browser: puppeteer.Browser try { - browser = await puppeteer.launch({headless: true}) + browser = await puppeteer.launch({headless: false}) const pages = await browser.pages() const page = pages.length === 0 ? await browser.newPage() : pages[0] @@ -52,23 +50,13 @@ const generateTimeStamp = (): string => { saveCookies('linkedin') ), - // at this point, you are logged in and looking at feed - wait(5000), // tons of stuff loads... no rush givenThat(isLoggedIn)( toggleMessagingOverlay, // by default, Messaging Overlay loads in open state - screenshot(generateTimeStamp()), // filename ie "2020-8-21-13-20.png" + // screenshot(generateTimeStamp()), // filename ie "2020-8-21-13-20.png" - // likeAllFrom('Peter Parker', 'Harry Potter'), - getFeedPosts(), - forAll()( - post => ([ - emptyPipe, - log('Post author = ' + post(feedPostAuthorSelector).text()) - ]) - ) - + likeArticlesFrom('Peter Parker', 'Harry Potter') ) ) diff --git a/src/tests/botmation/actions/abort.spec.ts b/src/tests/botmation/actions/abort.spec.ts index c61460f27..f18cfea73 100644 --- a/src/tests/botmation/actions/abort.spec.ts +++ b/src/tests/botmation/actions/abort.spec.ts @@ -1,15 +1,19 @@ import { Page } from 'puppeteer' -import { abort } from 'botmation/actions/abort' +import { abort, abortPipe } from 'botmation/actions/abort' +import { createCasesSignal } from 'botmation/helpers/cases' +import { createEmptyPipe, wrapValueInPipe } from 'botmation/helpers/pipe' jest.mock('botmation/helpers/abort', () => { // Require the original module to not be mocked... const originalModule = jest.requireActual('botmation/helpers/abort') + const originalCreateAbortLineSignal = originalModule.createAbortLineSignal + return { // __esModule: true, // Use it when dealing with esModules ...originalModule, - createAbortLineSignal: jest.fn() + createAbortLineSignal: jest.fn((assembledLines, pipeValue) => originalCreateAbortLineSignal(assembledLines, pipeValue)) } }) @@ -33,6 +37,27 @@ describe('[Botmation] actions/abort', () => { expect(mockCreateAbortLineSignal).toHaveBeenNthCalledWith(3, 7, 'abort-integration-test') }) + // + // pipeAbort unit-test -> send an AbortLineSignal(1, abortLineSignal.pipeValue?) if the mapFunction(pipeValue) equals true + it('pipeAbort()() returns an AbortLineSignal(1) with pipeValue provided if the provided conditional callback returns truthy (true) after passed the pipeValue', async() => { + const conditionalCBTrue = () => true + const conditionalCBFalse = () => false + + const {createAbortLineSignal} = jest.requireActual('botmation/helpers/abort') + + const pipeAborted = await abortPipe(conditionalCBTrue, 'pipe-value')(mockPage, createEmptyPipe()) + expect(pipeAborted).toEqual(createAbortLineSignal(1, 'pipe-value')) + + const pipeDoesNotAbort = await abortPipe(conditionalCBFalse)(mockPage, wrapValueInPipe('pipe-value-2')) + expect(pipeDoesNotAbort).toEqual(createCasesSignal({}, false, 'pipe-value-2')) + + const pipeAbortedNumericCase = await abortPipe(100, 'it was 100')(mockPage, wrapValueInPipe(100)) + expect(pipeAbortedNumericCase).toEqual(createAbortLineSignal(1, 'it was 100')) + + const pipeNotAbortNumericCase = await abortPipe(100, 'it was 100')(mockPage, wrapValueInPipe(10)) + expect(pipeNotAbortNumericCase).toEqual(createCasesSignal({}, false, 10)) + }) + // Clean up afterAll(async() => { jest.unmock('botmation/helpers/abort') diff --git a/src/tests/botmation/actions/assembly-lines.spec.ts b/src/tests/botmation/actions/assembly-lines.spec.ts index 72f8a4079..e6812f5a1 100644 --- a/src/tests/botmation/actions/assembly-lines.spec.ts +++ b/src/tests/botmation/actions/assembly-lines.spec.ts @@ -5,7 +5,7 @@ import { AbortLineSignal } from 'botmation/types/abort-line-signal' import { createEmptyPipe, wrapValueInPipe } from 'botmation/helpers/pipe' import { createAbortLineSignal } from 'botmation/helpers/abort' import { pipeCase } from 'botmation/actions/pipe' -import { createMatchesSignal } from 'botmation/helpers/matches' +import { createCasesSignal } from 'botmation/helpers/cases' /** * @description Assembly-Lines BotAction's @@ -858,31 +858,34 @@ describe('[Botmation] actions/assembly-lines', () => { expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(2, {}, wrapValueInPipe(55)) expect(mockActionPassThrough).toHaveBeenNthCalledWith(2, {}, wrapValueInPipe(55)) + // to support piping a function as a value, the BotAction as a value was removed + // since it can be ran before in a pipe then have its value piped into switchPipe() + // toPipe is a mock BotAction, injects don't have pipe objects - const mockToPipeAction = jest.fn(() => Promise.resolve(99)) + // const mockToPipeAction = jest.fn(() => Promise.resolve(99)) - const toPipeIsBotAction = await switchPipe(mockToPipeAction)( - mockActionPassThrough, - mockActionReturnsTwo - )(mockPage) + // const toPipeIsBotAction = await switchPipe(mockToPipeAction)( + // mockActionPassThrough, + // mockActionReturnsTwo + // )(mockPage) - expect(toPipeIsBotAction).toEqual([99, 2]) - expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) - expect(mockActionPassThrough).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) - expect(mockToPipeAction).toHaveBeenNthCalledWith(1, {}, createEmptyPipe()) + // expect(toPipeIsBotAction).toEqual([99, 2]) + // expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) + // expect(mockActionPassThrough).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) + // expect(mockToPipeAction).toHaveBeenNthCalledWith(1, {}, createEmptyPipe()) // toPipe is a mock BotAction and injects have Pipe object - const toPipeIsBotActionWithInjectedPipeValue = await switchPipe(mockToPipeAction)( - mockActionReturnsTwo, - mockActionPassThrough, - mockActionReturnsTwo - )(mockPage, {brand: 'Pipe', value: 200}) - - expect(toPipeIsBotActionWithInjectedPipeValue).toEqual([2, 99, 2]) - expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) - expect(mockActionPassThrough).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) - expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(4, {}, wrapValueInPipe(99)) - expect(mockToPipeAction).toHaveBeenNthCalledWith(2, {}, wrapValueInPipe(200)) + // const toPipeIsBotActionWithInjectedPipeValue = await switchPipe(mockToPipeAction)( + // mockActionReturnsTwo, + // mockActionPassThrough, + // mockActionReturnsTwo + // )(mockPage, {brand: 'Pipe', value: 200}) + + // expect(toPipeIsBotActionWithInjectedPipeValue).toEqual([2, 99, 2]) + // expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) + // expect(mockActionPassThrough).toHaveBeenNthCalledWith(3, {}, wrapValueInPipe(99)) + // expect(mockActionReturnsTwo).toHaveBeenNthCalledWith(4, {}, wrapValueInPipe(99)) + // expect(mockToPipeAction).toHaveBeenNthCalledWith(2, {}, wrapValueInPipe(200)) }) it('switchPipe() returns an array of results representing a 1:1 relationship with the assembled BotActions unless fully aborted out', async() => { @@ -908,9 +911,9 @@ describe('[Botmation] actions/assembly-lines', () => { expect(results1).toEqual([ 'mercury', 'venus', - createMatchesSignal({'0': 42}, 'earth'), + createCasesSignal({'0': 42}, true, 'earth'), 'mars', - createMatchesSignal(), // no matches so no ran code so returned pipeValue + createCasesSignal({}, false, 42), // no matches so no ran code so returned pipeValue undefined // breaks line but doesnt break array return so get abort pipeValue ]) }) @@ -997,7 +1000,7 @@ describe('[Botmation] actions/assembly-lines', () => { mockActionReturnsFive )(mockPage) expect(abortLineOneWithCaseMatches).toEqual([ - createMatchesSignal({'0': 10}, 5), + createCasesSignal({'0': 10}, true, 5), 'a-pipe-1-value-O_O' ]) expect(mockActionReturnsFive).toHaveBeenCalledTimes(5) @@ -1017,18 +1020,21 @@ describe('[Botmation] actions/assembly-lines', () => { // // toPipe botaction aborts - const mockActionNeverRuns = jest.fn(() => Promise.resolve()) - - const toPipeAbortsInfinity = await switchPipe(abort(0))(mockActionNeverRuns)(mockPage) - expect(toPipeAbortsInfinity).toEqual(createAbortLineSignal(0)) - expect(mockActionNeverRuns).not.toHaveBeenCalled() - - const toPipeAbortsOne = await switchPipe(abort(1, 'a-value'))(mockActionNeverRuns)(mockPage) - expect(toPipeAbortsOne).toEqual('a-value') - expect(mockActionNeverRuns).not.toHaveBeenCalled() - - const toPipeAbortsTwo = await switchPipe(abort(2, 'a-2-value'))(mockActionNeverRuns)(mockPage) - expect(toPipeAbortsTwo).toEqual(createAbortLineSignal(1, 'a-2-value')) - expect(mockActionNeverRuns).not.toHaveBeenCalled() + // toPipe as a BotAction was removed to support the passing in of a function + // if you need to pipe in the resolved value of a BotAction into a switchPipe, just run the BotAction + // before this one, while wrapping the two in a pipe()() + // const mockActionNeverRuns = jest.fn(() => Promise.resolve()) + + // const toPipeAbortsInfinity = await switchPipe(abort(0))(mockActionNeverRuns)(mockPage) + // expect(toPipeAbortsInfinity).toEqual(createAbortLineSignal(0)) + // expect(mockActionNeverRuns).not.toHaveBeenCalled() + + // const toPipeAbortsOne = await switchPipe(abort(1, 'a-value'))(mockActionNeverRuns)(mockPage) + // expect(toPipeAbortsOne).toEqual('a-value') + // expect(mockActionNeverRuns).not.toHaveBeenCalled() + + // const toPipeAbortsTwo = await switchPipe(abort(2, 'a-2-value'))(mockActionNeverRuns)(mockPage) + // expect(toPipeAbortsTwo).toEqual(createAbortLineSignal(1, 'a-2-value')) + // expect(mockActionNeverRuns).not.toHaveBeenCalled() }) }) diff --git a/src/tests/botmation/actions/navigation.spec.ts b/src/tests/botmation/actions/navigation.spec.ts index 480d682c1..e907a5f12 100644 --- a/src/tests/botmation/actions/navigation.spec.ts +++ b/src/tests/botmation/actions/navigation.spec.ts @@ -1,6 +1,6 @@ import { Page } from 'puppeteer' -import { goTo, waitForNavigation, goBack, goForward, reload, wait } from 'botmation/actions/navigation' +import { goTo, waitForNavigation, goBack, goForward, reload, wait, scrollTo } from 'botmation/actions/navigation' import { enrichGoToPageOptions } from 'botmation/helpers/navigation' import { click } from 'botmation/actions/input' @@ -12,7 +12,8 @@ jest.mock('botmation/helpers/navigation', () => { return { ...originalModule, - sleep: jest.fn(() => Promise.resolve()) + sleep: jest.fn(() => Promise.resolve()), + scrollToElement: jest.fn(() => {}) } }) @@ -30,14 +31,17 @@ describe('[Botmation] actions/navigation', () => { waitForNavigation: jest.fn(), goBack: jest.fn(), goForward: jest.fn(), - reload: jest.fn() + reload: jest.fn(), + evaluate: jest.fn((fn, ...params) => { + fn(...params) + return Promise.resolve() + }) } as any as Page }) beforeAll(async() => { await page.goto(BASE_URL, enrichGoToPageOptions()) }) - // // sleep() Integration Test it('should call setTimeout with the correct values', async() => { @@ -105,6 +109,21 @@ describe('[Botmation] actions/navigation', () => { await expect(page.title()).resolves.toMatch('Testing: Form Submit Success') }) + // + // scrollTo() integration + it('scrollTo() should call scrollToElement() inside the puppeteer page based on a html selector', async() => { + await scrollTo('some-element-far-away')(mockPage) + + const { + scrollToElement: mockScrollToElement, + sleep: mockSleep + } = require('botmation/helpers/navigation') + + expect(mockScrollToElement).toHaveBeenNthCalledWith(1, 'some-element-far-away') + expect(mockSleep).toHaveBeenNthCalledWith(2, 2500) + }) + + // clean up afterAll(() => { jest.unmock('botmation/helpers/navigation') }) diff --git a/src/tests/botmation/actions/pipe.spec.ts b/src/tests/botmation/actions/pipe.spec.ts index 15dbc5f88..9dce09358 100644 --- a/src/tests/botmation/actions/pipe.spec.ts +++ b/src/tests/botmation/actions/pipe.spec.ts @@ -1,10 +1,10 @@ import { Page } from 'puppeteer' -import { map, pipeValue, emptyPipe, pipeCase } from 'botmation/actions/pipe' +import { map, pipeValue, emptyPipe, pipeCase, pipeCases } from 'botmation/actions/pipe' import { createAbortLineSignal } from 'botmation/helpers/abort' import { wrapValueInPipe } from 'botmation/helpers/pipe' import { abort } from 'botmation/actions/abort' -import { createMatchesSignal } from 'botmation/helpers/matches' +import { createCasesSignal } from 'botmation/helpers/cases' /** * @description Pipe BotAction's @@ -41,7 +41,7 @@ describe('[Botmation] actions/pipe', () => { }) // - // pipeCase() Unit Test + // pipeCase() Unit Test || || || (at least one case passes) it('pipeCase()() should run assembled BotActions only if a value to test matches the pipe object value or if a value is a function then used as callback to evaluate for truthy with pipe object value as function param', async() => { // these mock actions act like log() where they return the pipe object value const mockActionRuns = jest.fn((p, pO) => Promise.resolve(pO.value)) @@ -52,10 +52,7 @@ describe('[Botmation] actions/pipe', () => { mockActionDoesntRun )(mockPage) - expect(noMatchesNoPipe).toEqual({ - brand: 'Matches_Signal', - matches: {}, - }) + expect(noMatchesNoPipe).toEqual(createCasesSignal()) expect(mockActionDoesntRun).not.toHaveBeenCalled() // no matches - with injected pipe @@ -63,10 +60,7 @@ describe('[Botmation] actions/pipe', () => { mockActionDoesntRun )(mockPage, wrapValueInPipe(44)) - expect(noMatchesWithPipe).toEqual({ - brand: 'Matches_Signal', - matches: {}, - }) + expect(noMatchesWithPipe).toEqual(createCasesSignal({}, false, 44)) expect(mockActionDoesntRun).not.toHaveBeenCalled() // single numerical match - with injected pipe @@ -74,13 +68,9 @@ describe('[Botmation] actions/pipe', () => { mockActionRuns )(mockPage, wrapValueInPipe(7)) - expect(singleNumericalMatch).toEqual({ - brand: 'Matches_Signal', - matches: { - '1': 7 // index 1, value 7 - }, - pipeValue: 7 // mockActionRuns returns pipe object value - }) + expect(singleNumericalMatch).toEqual(createCasesSignal({ + '1': 7 // index 1, value 7 + }, true, 7)) expect(mockActionRuns).toHaveBeenCalledTimes(1) // multiple matches via functions - with injected pipe @@ -93,12 +83,13 @@ describe('[Botmation] actions/pipe', () => { )(mockPage, wrapValueInPipe(2)) expect(multiNumericalMatches).toEqual({ - brand: 'Matches_Signal', + brand: 'Cases_Signal', matches: { 1: expect.any(Function), 2: expect.any(Function), 3: 2 }, + conditionPass: true, pipeValue: 2 // mockActionRuns returns pipe object value }) expect(mockActionRuns).toHaveBeenCalledTimes(2) @@ -106,21 +97,136 @@ describe('[Botmation] actions/pipe', () => { it('pipeCase() supports the AbortLineSignal similar to givenThat() in which its considered one line to abort', async() => { const abortedInfiniteLine = await pipeCase(10)( - abort(0) + abort(0, 'infinite') )(mockPage, wrapValueInPipe(10)) - expect(abortedInfiniteLine).toEqual(createAbortLineSignal(0)) + expect(abortedInfiniteLine).toEqual(createAbortLineSignal(0, 'infinite')) const abortedSingleLine = await pipeCase(100)( abort(1, 'an aborted pipe value') )(mockPage, wrapValueInPipe(100)) - expect(abortedSingleLine).toEqual(createMatchesSignal({'0': 100}, 'an aborted pipe value')) + expect(abortedSingleLine).toEqual(createCasesSignal({'0': 100}, true, 'an aborted pipe value')) - const abortedMultiLine = await pipeCase(1000)( + const abortedTwoLine = await pipeCase(1000)( abort(2, 'another aborted pipe value') )(mockPage, wrapValueInPipe(1000)) - expect(abortedMultiLine).toEqual(createAbortLineSignal(1, 'another aborted pipe value')) + expect(abortedTwoLine).toEqual('another aborted pipe value') // breaks line, and breaks the casesignal returning flow to return a processed (again) AbortLineSignal (therefore abortLineSignal.pipeValue) + + const abortedMultiLine = await pipeCase(1000)( + abort(3, 'another aborted pipe value 5') + )(mockPage, wrapValueInPipe(1000)) + + expect(abortedMultiLine).toEqual(createAbortLineSignal(1, 'another aborted pipe value 5')) }) + + // + // pipeCases() Unit Test && && && (all cases must pass) + it('pipeCases()() should run assembled BotActions only if ALL values tested against the pipe object value are equal or if a value is a function then used as callback to evaluate for truthy with pipe object value as function param', async() => { + // these mock actions act like log() where they return the pipe object value + const mockActionPassThrough = jest.fn((p, pO) => Promise.resolve(pO.value)) + const mockActionDoesntRun = jest.fn(() => Promise.resolve()) + + // no matches - no injected pipe + const noMatchesNoPipe = await pipeCases(4, 6)( + mockActionDoesntRun + )(mockPage) + + expect(noMatchesNoPipe).toEqual(createCasesSignal()) + expect(mockActionDoesntRun).not.toHaveBeenCalled() + + // no matches - with injected pipe + const noMatchesWithPipe = await pipeCases(77, 123)( + mockActionDoesntRun + )(mockPage, wrapValueInPipe(44)) + + expect(noMatchesWithPipe).toEqual(createCasesSignal({}, false, 44)) + expect(mockActionDoesntRun).not.toHaveBeenCalled() + + // single numerical match - with injected pipe + const singleNumericalMatchButDoesntGetToo = await pipeCases(3, 7, 18)( + mockActionPassThrough + )(mockPage, wrapValueInPipe(7)) + + expect(singleNumericalMatchButDoesntGetToo).toEqual(createCasesSignal({}, false, 7)) + expect(mockActionPassThrough).toHaveBeenCalledTimes(0) + + const singleNumericalMatchThenBreak = await pipeCases(7, 3, 18)( + mockActionPassThrough + )(mockPage, wrapValueInPipe(7)) + + expect(singleNumericalMatchThenBreak).toEqual(createCasesSignal({ + 0: 7 // index 0, value 7 + }, false, 7)) // no pipe value since the assembled botactions did not run + expect(mockActionPassThrough).toHaveBeenCalledTimes(0) + + // all matches via functions - with injected pipe + const trueForTwoOrTen = (value: number): boolean => value === 2 || value === 10 + const trueForTwoOrFive = (value: number): boolean => value === 2 || value === 5 + const trueForTwoOrSix = (value: number): boolean => value === 2 || value === 6 + + const multiNumericalMatches = await pipeCases(trueForTwoOrTen, trueForTwoOrFive, trueForTwoOrSix, 2)( + mockActionPassThrough + )(mockPage, wrapValueInPipe(2)) + + expect(multiNumericalMatches).toEqual({ + brand: 'Cases_Signal', + matches: { + 0: expect.any(Function), + 1: expect.any(Function), + 2: expect.any(Function), + 3: 2 + }, + conditionPass: true, + pipeValue: 2 // mockActionRuns returns pipe object value + }) + expect(mockActionPassThrough).toHaveBeenCalledTimes(1) + + // function matching where was is false-like to break the loop preventing following botactions from running + const multiMatchesFunctionFalse = await pipeCases(trueForTwoOrTen, trueForTwoOrFive, trueForTwoOrSix, 2)( + mockActionPassThrough // does not get called, so same called times as before + )(mockPage, wrapValueInPipe(10)) + + expect(multiMatchesFunctionFalse).toEqual({ + brand: 'Cases_Signal', + matches: { + 0: expect.any(Function), + }, + conditionPass: false, + pipeValue: 10 + }) + expect(mockActionPassThrough).toHaveBeenCalledTimes(1) + }) + + it('pipeCases()() supports AbortLineSignal in assembled BotActions with aborting behavior, 1 line of assembledLines to abort assembled lines but still return CasesSignal and 2+ assembledLines of aborting to fully abort out of the function', async() => { + const mockActionNotRun = jest.fn(() => Promise.resolve()) + + const abortLineOne = await pipeCases(100)( + abort(1, 'pipe-value-1'), + mockActionNotRun + )(mockPage, wrapValueInPipe(100)) + + expect(mockActionNotRun).not.toHaveBeenCalled() + expect(abortLineOne).toEqual(createCasesSignal({0: 100}, true, 'pipe-value-1')) + + const abortLineTwo = await pipeCases(100)( + abort(2, 'pipe-value-to-check') + )(mockPage, wrapValueInPipe(100)) + + expect(abortLineTwo).toEqual('pipe-value-to-check') + + const abortLineThree = await pipeCases(100)( + abort(3, 'pipe-value-3') + )(mockPage, wrapValueInPipe(100)) + + expect(abortLineThree).toEqual(createAbortLineSignal(1, 'pipe-value-3')) + + const abortInfinity = await pipeCases(100)( + abort(0, 'infinity') + )(mockPage, wrapValueInPipe(100)) + + expect(abortInfinity).toEqual(createAbortLineSignal(0, 'infinity')) + }) + }) diff --git a/src/tests/botmation/actions/scrapers.spec.ts b/src/tests/botmation/actions/scrapers.spec.ts index d678bb537..1817ccb39 100644 --- a/src/tests/botmation/actions/scrapers.spec.ts +++ b/src/tests/botmation/actions/scrapers.spec.ts @@ -1,7 +1,7 @@ import { Page } from 'puppeteer' import { BASE_URL } from 'tests/urls' -import { $, $$, htmlParser } from 'botmation/actions/scrapers' +import { $, $$, htmlParser, evaluate } from 'botmation/actions/scrapers' // Mock inject()() jest.mock('botmation/actions/inject', () => { @@ -84,6 +84,19 @@ describe('[Botmation] actions/scraping', () => { expect(mockInjectMethod).toHaveBeenNthCalledWith(1, expect.any(Function)) }) + it('evaluate() should call the function with the params provided via the Puppeteer page.evaluate() method', async() => { + mockPage = { + evaluate: jest.fn((fn, ...params) => fn(...params)) + } as any as Page + + const mockEvaluateFunction = jest.fn() + const mockParams = [5, 'testing', {sunshine: true}] + + await evaluate(mockEvaluateFunction, ...mockParams)(mockPage) + + expect(mockEvaluateFunction).toHaveBeenNthCalledWith(1, 5, 'testing', {sunshine: true}) + }) + // // Unit-Tests it('Should scrape joke (2 paragraph elements) and 1 home link (anchor element) and grab their text', async() => { @@ -102,6 +115,7 @@ describe('[Botmation] actions/scraping', () => { expect(homeLink('a').text()).toEqual('Home Link') }) + // clean up afterAll(() => { jest.unmock('botmation/actions/inject') }) diff --git a/src/tests/botmation/helpers/cases.spec.ts b/src/tests/botmation/helpers/cases.spec.ts new file mode 100644 index 000000000..e3de94fc0 --- /dev/null +++ b/src/tests/botmation/helpers/cases.spec.ts @@ -0,0 +1,69 @@ +import { createCasesSignal, casesSignalToPipeValue } from 'botmation/helpers/cases' + +/** + * @description Cases Helpers + * A case is like a piece of a condition to evaluate for true, such then some code may by executed ie if (case || case2 || case3) then {} + */ +describe('[Botmation] helpers/cases', () => { + + // + // Unit Test + it('createCasesSignal() creates a `CasesSignal` object with safe intuitive defaults if no params are provided to set the object\'s values', () => { + const noParams = createCasesSignal() + const overrideSafeDefaultsWithThoseValues = createCasesSignal({}, false, undefined) + const fiveMatches = createCasesSignal({'1': 'bear', '2': 'lion', '3': 'bird', '4': 'mountain', '5': 'cloud'}, true) // five passing cases to make the condition pass + const matchesWithPipeValue = createCasesSignal({'4': 'cat', '6': 'dog'}, true, 'test-pipe-value-983') + + const casesWithNumberKeysButNotCondition = createCasesSignal({5: 'sfd'}, false) // serialization will convert them into strings + const casesWithNumberKeysAndCondition = createCasesSignal({7: 'sfdhg'}, true) + + expect(noParams).toEqual({ + brand: 'Cases_Signal', + matches: {}, + conditionPass: false + }) + expect(overrideSafeDefaultsWithThoseValues).toEqual({ + brand: 'Cases_Signal', + matches: {}, + conditionPass: false + }) + expect(fiveMatches).toEqual({ + brand: 'Cases_Signal', + matches: {'1': 'bear', '2': 'lion', '3': 'bird', '4': 'mountain', '5': 'cloud'}, + conditionPass: true + }) + expect(matchesWithPipeValue).toEqual({ + brand: 'Cases_Signal', + matches: {'4': 'cat', '6': 'dog'}, + conditionPass: true, + pipeValue: 'test-pipe-value-983' + }) + + expect(casesWithNumberKeysButNotCondition).toEqual({ + brand: 'Cases_Signal', + matches: {'5': 'sfd'}, + conditionPass: false + }) + + expect(casesWithNumberKeysAndCondition).toEqual({ + brand: 'Cases_Signal', + matches: {'7': 'sfdhg'}, + conditionPass: true + }) + + }) + + it('casesSignalToPipeValue() safely returns a CasesSignal pipeValue property even if not given a CasesSignal', () => { + const undefinedValue = casesSignalToPipeValue(undefined) + const nullValue = casesSignalToPipeValue(null) + const casesSignalNoPipeValue = casesSignalToPipeValue(createCasesSignal()) + const casesSignalWithPipeValue = casesSignalToPipeValue(createCasesSignal({}, false, 'pipe-value-to-confirm')) + + expect(undefinedValue).toBeUndefined() + expect(nullValue).toBeUndefined() + + expect(casesSignalNoPipeValue).toBeUndefined() + expect(casesSignalWithPipeValue).toEqual('pipe-value-to-confirm') + }) + +}) diff --git a/src/tests/botmation/helpers/matches.spec.ts b/src/tests/botmation/helpers/matches.spec.ts deleted file mode 100644 index e9bcb347b..000000000 --- a/src/tests/botmation/helpers/matches.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createMatchesSignal, hasAtLeastOneMatch } from 'botmation/helpers/matches' - -/** - * @description Matches Helpers - */ -describe('[Botmation] helpers/matches', () => { - - // - // Unit Test - it('createMatchesSignal() creates a `MatchesSignal` object with safe intuitive defaults if no params are provided to set the object\'s values', () => { - const noParams = createMatchesSignal() - const overrideSafeDefaultsWithThoseValues = createMatchesSignal({}, undefined) - const fiveMatches = createMatchesSignal({'1': 'bear', '2': 'lion', '3': 'bird', '4': 'mountain', '5': 'cloud'}) - const matchesWithPipeValue = createMatchesSignal({'4': 'cat', '6': 'dog'}, 'test-pipe-value-983') - - const matchesWithNumberKeys = createMatchesSignal({5: 'sfd'}) // serialization will convert them into strings - - expect(noParams).toEqual({ - brand: 'Matches_Signal', - matches: {} - }) - expect(overrideSafeDefaultsWithThoseValues).toEqual({ - brand: 'Matches_Signal', - matches: {} - }) - expect(fiveMatches).toEqual({ - brand: 'Matches_Signal', - matches: {'1': 'bear', '2': 'lion', '3': 'bird', '4': 'mountain', '5': 'cloud'} - }) - expect(matchesWithPipeValue).toEqual({ - brand: 'Matches_Signal', - matches: {'4': 'cat', '6': 'dog'}, - pipeValue: 'test-pipe-value-983' - }) - - expect(matchesWithNumberKeys).toEqual({ - brand: 'Matches_Signal', - matches: {'5': 'sfd'} - }) - }) - - it('hasAtLeastOneMatch() returns a boolean, TRUE if the provided MatchesSignal has at least one match otherwise FALSE', () => { - const signalWithZeroMatches = createMatchesSignal() - const signalWithZeroMatchesAndPipeValue = createMatchesSignal({}, 'a pipe vlaue') - - const signalWithOneMatch = createMatchesSignal({8: 'cat'}) - const signalWithOneMatchAndPipeValue = createMatchesSignal({8: 'cat'}, 'another pipe value') - - const signalWithMultipleMatches = createMatchesSignal({2: 'dog', 93: 'elephant'}) - const signalWithMultipleMatchesAndPipeValue = createMatchesSignal({2: 'dog', 93: 'elephant'}, 'another another pipe value') - - expect(hasAtLeastOneMatch(signalWithZeroMatches)).toEqual(false) - expect(hasAtLeastOneMatch(signalWithZeroMatchesAndPipeValue)).toEqual(false) - - expect(hasAtLeastOneMatch(signalWithOneMatch)).toEqual(true) - expect(hasAtLeastOneMatch(signalWithOneMatchAndPipeValue)).toEqual(true) - expect(hasAtLeastOneMatch(signalWithMultipleMatches)).toEqual(true) - expect(hasAtLeastOneMatch(signalWithMultipleMatchesAndPipeValue)).toEqual(true) - - }) - -}) diff --git a/src/tests/botmation/helpers/navigation.spec.ts b/src/tests/botmation/helpers/navigation.spec.ts index 19398c3fd..bb3747c4c 100644 --- a/src/tests/botmation/helpers/navigation.spec.ts +++ b/src/tests/botmation/helpers/navigation.spec.ts @@ -1,6 +1,27 @@ import { DirectNavigationOptions } from 'puppeteer' -import { enrichGoToPageOptions, sleep } from 'botmation/helpers/navigation' +import { enrichGoToPageOptions, sleep, scrollToElement } from 'botmation/helpers/navigation' + +const mockScrollIntoView = jest.fn() +const mockQuerySelectorFactory = (timesRan = 0) => jest.fn(() => { + if (timesRan === 0) { + timesRan++ + return { + scrollIntoView: mockScrollIntoView + } + } else { + return null // for the case of not element found, querySelector() returns null + } +}) + +const mockQuerySelector = mockQuerySelectorFactory() + +// Setup document query selector methods +Object.defineProperty(global, 'document', { + value: { + querySelector: mockQuerySelector, + } +}) /** * @description Navigation Helpers @@ -8,6 +29,20 @@ import { enrichGoToPageOptions, sleep } from 'botmation/helpers/navigation' describe('[Botmation] helpers/navigation', () => { let setTimeoutFn = setTimeout + it('scrollToElement() is a function evaluated in the browser the gets an element based on html selector then calls its scrollIntoView() method', () => { + scrollToElement('teddy bear') + + // 1st time running mock document object, it will have a scrollIntoView function + expect(mockQuerySelector).toHaveBeenNthCalledWith(1, 'teddy bear') + expect(mockScrollIntoView).toHaveBeenNthCalledWith(1, {behavior: 'smooth'}) + + // subsequent calls of mock document object querySelector will result in null + scrollToElement('blanket') + + expect(mockQuerySelector).toHaveBeenNthCalledWith(2, 'blanket') + expect(mockScrollIntoView).not.toHaveBeenCalledTimes(2) + }) + it('enrichGoToPageOptions() should take a partial of Puppeteer.DirectNavigationOptions to overload the default values it provides in one as a safe fallback', () => { const directNavigationOptionsEmpty: Partial = {} const directNavigationOptionsWaitUntil: Partial = {waitUntil: 'domcontentloaded'} diff --git a/src/tests/botmation/types/cases-signal.spec.ts b/src/tests/botmation/types/cases-signal.spec.ts new file mode 100644 index 000000000..ba8dd8046 --- /dev/null +++ b/src/tests/botmation/types/cases-signal.spec.ts @@ -0,0 +1,69 @@ +import { CasesSignal, isCasesSignal } from 'botmation/types/cases' +import { wrapValueInPipe } from 'botmation/helpers/pipe' + +/** + * @description CasesSignal Type guard function + */ +describe('[Botmation] types/cases-signal', () => { + + // + // Unit Test + it('isCasesSignal() returns boolean true if the provided param is an Object that matches the minimum requirements for the MatchesSignal type', () => { + // pass + const emptyCasesSignal: CasesSignal = { + brand: 'Cases_Signal', + conditionPass: false, + matches: {} + } + const casesSignal: CasesSignal = { + brand: 'Cases_Signal', + conditionPass: true, + matches: { + 3: 'dog', + 7: 'cat' + } + } + const casesSignalWithPipeValue: CasesSignal = { + brand: 'Cases_Signal', + conditionPass: true, + matches: { + 2: 'bird', + 17: 'cow' + }, + pipeValue: 'aim for the stars and land on the moon' + } + + // fails + const pipeObject = wrapValueInPipe('hey') + const likeCasesSignalButArray = { + brand: 'Cases_Signal', + matches: [] + } + const likeCasesSignalButNull = { + brand: 'Cases_Signal', + matches: null + } + const likeCasesSignalButWrongBrand = { + brand: 'Cases_Signal_', + matches: {} + } + const likeCasesSignalButWrongConditionPassType = { + brand: 'Cases_Signal', + matches: {}, + conditionPass: 'pass' + } + + // pass + expect(isCasesSignal(emptyCasesSignal)).toEqual(true) + expect(isCasesSignal(casesSignal)).toEqual(true) + expect(isCasesSignal(casesSignalWithPipeValue)).toEqual(true) + + // fail + expect(isCasesSignal(pipeObject)).toEqual(false) + expect(isCasesSignal(likeCasesSignalButArray)).toEqual(false) + expect(isCasesSignal(likeCasesSignalButNull)).toEqual(false) + expect(isCasesSignal(likeCasesSignalButWrongBrand)).toEqual(false) + expect(isCasesSignal(likeCasesSignalButWrongConditionPassType)).toEqual(false) + }) + +}) diff --git a/src/tests/botmation/types/matches-signal.spec.ts b/src/tests/botmation/types/matches-signal.spec.ts deleted file mode 100644 index 52559d442..000000000 --- a/src/tests/botmation/types/matches-signal.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { MatchesSignal, isMatchesSignal } from 'botmation/types/matches-signal' -import { wrapValueInPipe } from 'botmation/helpers/pipe' - -/** - * @description MatchesSignal Type guard function - */ -describe('[Botmation] types/matches-signal', () => { - - // - // Unit Test - it('isMatchesSignal() returns boolean true if the provided param is an Object that matches the minimum requirements for the MatchesSignal type', () => { - // pass - const emptyMatchesSignal: MatchesSignal = { - brand: 'Matches_Signal', - matches: {} - } - const matchesSignal: MatchesSignal = { - brand: 'Matches_Signal', - matches: { - 3: 'dog', - 7: 'cat' - } - } - const matchesSignalWithPipeValue: MatchesSignal = { - brand: 'Matches_Signal', - matches: { - 2: 'bird', - 17: 'cow' - }, - pipeValue: 'aim for the stars and land on the moon' - } - - // fails - const pipeObject = wrapValueInPipe('hey') - const likeMatchesSignalButArray = { - brand: 'Matches_Signal', - matches: [] - } - const likeMatchesSignalButNull = { - brand: 'Matches_Signal', - matches: null - } - const likeMatchesSignalButWrongBrand = { - brand: 'Matches_Signal_', - matches: {} - } - - // pass - expect(isMatchesSignal(emptyMatchesSignal)).toEqual(true) - expect(isMatchesSignal(matchesSignal)).toEqual(true) - expect(isMatchesSignal(matchesSignalWithPipeValue)).toEqual(true) - - // fail - expect(isMatchesSignal(pipeObject)).toEqual(false) - expect(isMatchesSignal(likeMatchesSignalButArray)).toEqual(false) - expect(isMatchesSignal(likeMatchesSignalButNull)).toEqual(false) - expect(isMatchesSignal(likeMatchesSignalButWrongBrand)).toEqual(false) - }) - -})