229 lines
7.7 KiB
TypeScript
229 lines
7.7 KiB
TypeScript
/* 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<void>);
|
|
binaryCallback: (output: any, mimeType: string) => (void | Promise<void>);
|
|
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<InterpreterOptions>) {
|
|
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<typeof oldLog>) => {
|
|
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<PageState> {
|
|
/**
|
|
* 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<boolean> => {
|
|
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(<Error>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<string, unknown>, superset: Record<string, unknown>)
|
|
: 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<string, unknown> = {};
|
|
parsedSuperset[key] = Array.isArray(superset[key])
|
|
? arrayToObject(<any>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' && (<RegExp>parsedValue).test(<string>parsedSuperset[key]))
|
|
|| (
|
|
(parsedValue).constructor.name !== 'RegExp'
|
|
&& typeof parsedValue === 'object' && inclusive(<typeof subset>parsedValue, <typeof superset>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(<any>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(<Where>value, context); // $not should be a unary operator
|
|
default:
|
|
throw new Error('Undefined logic operator.');
|
|
}
|
|
} else if (meta.includes(<any>key)) {
|
|
const testRegexString = (x: string) => {
|
|
if (typeof value === 'string') {
|
|
return x === value;
|
|
}
|
|
|
|
return (<RegExp><unknown>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);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
} |