@elastic/synthetics
Version:
Elastic synthetic monitoring agent
360 lines (324 loc) • 10.8 kB
text/typescript
/**
* MIT License
*
* Copyright (c) 2020-present, Elastic NV
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
*/
import {
JavaScriptLanguageGenerator,
JavaScriptFormatter,
} from 'playwright-core/lib/server/codegen/javascript';
export type Step = {
actions: ActionInContext[];
name?: string;
};
export type Steps = Step[];
// from playwright-core
export type FrameDescription = {
pageAlias: string;
framePath: string[];
};
export type ActionInContext = {
action: Action;
committed?: boolean;
modified?: boolean;
title?: string;
frame: FrameDescription;
isOpen?: boolean;
isSoftDeleted?: boolean;
description?: string;
startTime?: number;
endTime?: number;
};
export type Action = {
name: string;
selector?: string;
url?: string;
key?: string;
signals: Signal[];
modifiers?: number;
button?: 'left' | 'middle' | 'right';
clickCount?: number;
text?: string;
value?: string;
isAssert?: boolean;
command?: string;
files?: string[];
options?: string[];
};
export type Signal = {
name: string;
url?: string;
isAsync?: boolean;
popupAlias?: string;
downloadAlias?: string;
dialogAlias?: string;
};
function toAssertCall(pageAlias: string, action: Action) {
const { command, selector, value } = action;
switch (command) {
case 'textContent':
case 'innerText':
return `expect(await ${pageAlias}.${command}(${quote(
selector ?? ''
)})).toMatch(${quote(value ?? '')});`;
case 'isVisible':
case 'isHidden':
case 'isChecked':
case 'isEditable':
case 'isEnabled':
case 'isDisabled':
return `expect(await ${pageAlias}.${command}(${quote(
selector ?? ''
)})).toBeTruthy();`;
}
}
function toSignalMap(action: Action) {
let popup: Signal | undefined;
let download: Signal | undefined;
let dialog: Signal | undefined;
for (const signal of action.signals) {
if (signal.name === 'popup') popup = signal;
else if (signal.name === 'download') download = signal;
else if (signal.name === 'dialog') dialog = signal;
}
return {
popup,
download,
dialog,
};
}
export function quote(text: string, char = "'"): string {
const stringified = JSON.stringify(text);
const escapedText = stringified
.substring(1, stringified.length - 1)
.replace(/\\"/g, '"');
if (char === "'") return char + escapedText.replace(/[']/g, "\\'") + char;
if (char === '"') return char + escapedText.replace(/["]/g, '\\"') + char;
if (char === '`') return char + escapedText.replace(/[`]/g, '`') + char;
throw new Error('Invalid escape char');
}
/**
* Generates an appropriate title string based on the action type/data.
* @param action Playwright action IR
* @returns title string
*/
export function actionTitle(action: Action) {
switch (action.name) {
case 'openPage':
return `Open new page`;
case 'closePage':
return `Close page`;
case 'check':
return `Check ${action.selector}`;
case 'uncheck':
return `Uncheck ${action.selector}`;
case 'click': {
if (action.clickCount === 1) return `Click ${action.selector}`;
if (action.clickCount === 2) return `Double click ${action.selector}`;
if (action.clickCount === 3) return `Triple click ${action.selector}`;
return `${action.clickCount}× click`;
}
case 'fill':
return `Fill ${action.selector}`;
case 'setInputFiles':
if (action.files?.length === 0) return `Clear selected files`;
else return `Upload ${action.files?.join(', ')}`;
case 'navigate':
return `Go to ${action.url}`;
case 'press':
return (
`Press ${action.key}` + (action.modifiers ? ' with modifiers' : '')
);
case 'select':
return `Select ${action.options?.join(', ')}`;
case 'assert':
return `Assert ${action.selector} ${action.command}`;
}
}
export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
private previousContext?: ActionInContext;
private insideStep: boolean;
private varsToHoist: string[];
constructor(private isProject: boolean) {
super(true);
this.insideStep = false;
this.previousContext = undefined;
this.varsToHoist = [];
}
/**
* Generate code for an action.
* @param actionInContext The action to create code for.
* @returns the strings generated for the action.
*/
generateAction(actionInContext: ActionInContext) {
const { action } = actionInContext;
const { pageAlias } = actionInContext.frame;
if (action.name === 'openPage') {
return '';
}
// Don't cleanup page object managed by Synthetics
const isCleanUp = action.name === 'closePage' && pageAlias === 'page';
if (isCleanUp) {
return '';
}
const stepIndent = this.insideStep ? 2 : 0;
const offset = this.isProject ? 2 + stepIndent : 0 + stepIndent;
const formatter = new JavaScriptFormatter(offset);
const locators = actionInContext.frame.framePath.map(
selector => `.${super._asLocator(selector)}.contentFrame()`
);
const subject = `${pageAlias}${locators.join('')}`;
const signals = toSignalMap(action);
if (signals.dialog) {
formatter.add(` ${pageAlias}.once('dialog', dialog => {
console.log(\`Dialog message: $\{dialog.message()}\`);
dialog.dismiss().catch(() => {});
});`);
}
if (signals.popup)
formatter.add(
`const ${signals.popup.popupAlias}Promise = ${pageAlias}.waitForEvent('popup');`
);
if (signals.download)
formatter.add(
`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`
);
// Add assertion from Synthetics.
const isAssert = action.name === 'assert' && action.isAssert;
if (isAssert && action.command) {
formatter.add(toAssertCall(pageAlias, action));
} else {
formatter.add(super._generateActionCall(subject, actionInContext));
}
if (signals.popup)
formatter.add(
`${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`
);
if (signals.download)
formatter.add(
`download${signals.download.downloadAlias} = await download${signals.download.downloadAlias}Promise;`
);
this.previousContext = actionInContext;
return formatter.format();
}
isNewStep(actioninContext: ActionInContext) {
const { action } = actioninContext;
if (action.name === 'navigate') {
return true;
} else if (action.name === 'click') {
return (
this.previousContext?.action.url === action.url &&
action.signals.length > 0
);
}
return false;
}
generateStepStart(name: string) {
this.insideStep = true;
const formatter = new JavaScriptFormatter(this.getDefaultOffset());
formatter.add(`step(${quote(name)}, async () => {`);
return formatter.format();
}
generateStepEnd() {
if (!this.insideStep) {
return '';
}
this.insideStep = false;
const formatter = new JavaScriptFormatter(this.getDefaultOffset());
formatter.add(`});`);
return formatter.format();
}
generateHeader() {
const formatter = new JavaScriptFormatter(0);
formatter.add(`
const { journey, step, expect } = require('@elastic/synthetics');
journey('Recorded journey', async ({ page, context }) => {`);
return formatter.format();
}
generateFooter() {
return `});`;
}
/**
* Generates JavaScript code from a custom set of steps and nested actions.
*
* This function makes no assumptions about where steps should be created,
* and instead follows the step definitions the caller has defined.
* @param steps IR to use for code generation
* @returns a list of the code strings outputted by the generator
*/
generateFromSteps(steps: Steps): string {
const text: string[] = [];
if (this.isProject) {
text.push(this.generateHeader());
}
this.varsToHoist = this.findVarsToHoist(steps);
text.push(this.generateHoistedVars());
for (const step of steps) {
if (step.actions.length === 0)
throw Error('Cannot process an empty step');
const name =
step.name ??
step.actions[0].title ??
actionTitle(step.actions[0].action);
text.push(this.generateStepStart(name ?? ''));
for (const action of step.actions) {
const actionText = this.generateAction(action);
if (actionText.length) text.push(actionText);
}
text.push(this.generateStepEnd());
}
if (this.isProject) {
text.push(this.generateFooter());
}
return text.filter(s => !!s).join('\n');
}
generateHoistedVars() {
const formatter = new JavaScriptFormatter(this.getDefaultOffset());
for (const varName of this.varsToHoist) {
formatter.add(`let ${varName};`);
}
return formatter.format();
}
isVarHoisted(varName: string) {
return this.varsToHoist.indexOf(varName) >= 0;
}
getDefaultOffset() {
return this.isProject ? 2 : 0;
}
/**
* We need to hoist any page or popup alias that appears in more than one step.
* @param steps the step IR to evaluate
* @returns an array that contains the names of all variables that need to be hoisted
*/
findVarsToHoist(steps: Steps): string[] {
const aliasSet = new Set<string>();
for (const step of steps) {
for (const actionContext of step.actions) {
actionContext.action.signals
.filter(({ name, popupAlias }) => name === 'popup' && popupAlias)
.forEach(({ popupAlias }) => aliasSet.add(popupAlias as string));
aliasSet.add(actionContext.frame.pageAlias);
}
}
return Array.from(aliasSet).filter(alias => alias !== 'page');
}
}