/* eslint-disable no-await-in-loop, no-restricted-syntax */ import { Page, PageScreenshotOptions } from 'playwright'; import path from 'path'; import { EventEmitter } from 'events'; import { Where, What, PageState, Workflow, WorkflowFile, ParamType, SelectorArray, CustomFunctions, } from './types/workflow'; import { operators, meta } from './types/logic'; import { arrayToObject } from './utils/utils'; import Concurrency from './utils/concurrency'; import Preprocessor from './preprocessor'; import log, { Level } from './utils/logger'; /** * Defines optional intepreter options (passed in constructor) */ interface InterpreterOptions { maxRepeats: number; maxConcurrency: number; serializableCallback: (output: any) => (void | Promise); binaryCallback: (output: any, mimeType: string) => (void | Promise); debug: boolean; debugChannel: Partial<{ activeId: Function, debugMessage: Function, }> } /** * Class for running the Smart Workflows. */ export default class Interpreter extends EventEmitter { private workflow: Workflow; private initializedWorkflow: Workflow | null; private options: InterpreterOptions; private concurrency : Concurrency; private stopper : Function | null = null; private log : typeof log; constructor(workflow: WorkflowFile, options?: Partial) { super(); this.workflow = workflow.workflow; this.initializedWorkflow = null; this.options = { maxRepeats: 5, maxConcurrency: 5, serializableCallback: (data) => { log(JSON.stringify(data), Level.WARN); }, binaryCallback: () => { log('Received binary data, thrashing them.', Level.WARN); }, debug: false, debugChannel: {}, ...options, }; this.concurrency = new Concurrency(this.options.maxConcurrency); this.log = (...args) => log(...args); const error = Preprocessor.validateWorkflow(workflow); if (error) { throw (error); } if (this.options.debugChannel?.debugMessage) { const oldLog = this.log; // @ts-ignore this.log = (...args: Parameters) => { if (args[1] !== Level.LOG) { this.options.debugChannel.debugMessage!(typeof args[0] === 'string' ? args[0] : args[0].message); } oldLog(...args); }; } } /** * Returns the context object from given Page and the current workflow.\ * \ * `workflow` is used for selector extraction - function searches for used selectors to * look for later in the page's context. * @param page Playwright Page object * @param workflow Current **initialized** workflow (array of where-what pairs). * @returns {PageState} State of the current page. */ private async getState(page: Page, workflow: Workflow) : Promise { /** * All the selectors present in the current Workflow */ const selectors = Preprocessor.extractSelectors(workflow); /** * Determines whether the element targetted by the selector is [actionable](https://playwright.dev/docs/actionability). * @param selector Selector to be queried * @returns True if the targetted element is actionable, false otherwise. */ const actionable = async (selector: string) : Promise => { try { const proms = [ page.isEnabled(selector, { timeout: 500 }), page.isVisible(selector, { timeout: 500 }), ]; return await Promise.all(proms).then((bools) => bools.every((x) => x)); } catch (e) { // log(e, Level.ERROR); return false; } }; /** * Object of selectors present in the current page. */ const presentSelectors : SelectorArray = await Promise.all( selectors.map(async (selector) => { if (await actionable(selector)) { return [selector]; } return []; }), ).then((x) => x.flat()); return { url: page.url(), cookies: (await page.context().cookies([page.url()])) .reduce((p, cookie) => ( { ...p, [cookie.name]: cookie.value, }), {}), selectors: presentSelectors, }; } /** * Tests if the given action is applicable with the given context. * @param where Tested *where* condition * @param context Current browser context. * @returns True if `where` is applicable in the given context, false otherwise */ private applicable(where: Where, context: PageState, usedActions : string[] = []) : boolean { /** * Given two arbitrary objects, determines whether `subset` is a subset of `superset`.\ * \ * For every key in `subset`, there must be a corresponding key with equal scalar * value in `superset`, or `inclusive(subset[key], superset[key])` must hold. * @param subset Arbitrary non-cyclic JS object (where clause) * @param superset Arbitrary non-cyclic JS object (browser context) * @returns `true` if `subset <= superset`, `false` otherwise. */ const inclusive = (subset: Record, superset: Record) : boolean => ( Object.entries(subset).every( ([key, value]) => { /** * Arrays are compared without order (are transformed into objects before comparison). */ const parsedValue = Array.isArray(value) ? arrayToObject(value) : value; const parsedSuperset : Record = {}; parsedSuperset[key] = Array.isArray(superset[key]) ? arrayToObject(superset[key]) : superset[key]; // Every `subset` key must exist in the `superset` and // have the same value (strict equality), or subset[key] <= superset[key] return parsedSuperset[key] && ( (parsedSuperset[key] === parsedValue) || ((parsedValue).constructor.name === 'RegExp' && (parsedValue).test(parsedSuperset[key])) || ( (parsedValue).constructor.name !== 'RegExp' && typeof parsedValue === 'object' && inclusive(parsedValue, parsedSuperset[key]) ) ); }, ) ); // Every value in the "where" object should be compliant to the current state. return Object.entries(where).every( ([key, value]) => { if (operators.includes(key)) { const array = Array.isArray(value) ? value as Where[] : Object.entries(value).map((a) => Object.fromEntries([a])); // every condition is treated as a single context switch (key as keyof typeof operators) { case '$and': return array?.every((x) => this.applicable(x, context)); case '$or': return array?.some((x) => this.applicable(x, context)); case '$not': return !this.applicable(value, context); // $not should be a unary operator default: throw new Error('Undefined logic operator.'); } } else if (meta.includes(key)) { const testRegexString = (x: string) => { if (typeof value === 'string') { return x === value; } return (value).test(x); }; switch (key as keyof typeof meta) { case '$before': return !usedActions.find(testRegexString); case '$after': return !!usedActions.find(testRegexString); default: throw new Error('Undefined meta operator.'); } } else { // Current key is a base condition (url, cookies, selectors) return inclusive({ [key]: value }, context); } }, ); } }