@elastic/synthetics
Version:
Elastic synthetic monitoring agent
280 lines (279 loc) • 10.8 kB
JavaScript
;
/**
* 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.
*
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.SyntheticsGenerator = exports.actionTitle = exports.quote = void 0;
const javascript_1 = require("playwright-core/lib/server/codegen/javascript");
function toAssertCall(pageAlias, 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) {
let popup;
let download;
let dialog;
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,
};
}
function quote(text, char = "'") {
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');
}
exports.quote = quote;
/**
* Generates an appropriate title string based on the action type/data.
* @param action Playwright action IR
* @returns title string
*/
function actionTitle(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}`;
}
}
exports.actionTitle = actionTitle;
class SyntheticsGenerator extends javascript_1.JavaScriptLanguageGenerator {
isProject;
previousContext;
insideStep;
varsToHoist;
constructor(isProject) {
super(true);
this.isProject = isProject;
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) {
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 javascript_1.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) {
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) {
this.insideStep = true;
const formatter = new javascript_1.JavaScriptFormatter(this.getDefaultOffset());
formatter.add(`step(${quote(name)}, async () => {`);
return formatter.format();
}
generateStepEnd() {
if (!this.insideStep) {
return '';
}
this.insideStep = false;
const formatter = new javascript_1.JavaScriptFormatter(this.getDefaultOffset());
formatter.add(`});`);
return formatter.format();
}
generateHeader() {
const formatter = new javascript_1.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) {
const text = [];
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 javascript_1.JavaScriptFormatter(this.getDefaultOffset());
for (const varName of this.varsToHoist) {
formatter.add(`let ${varName};`);
}
return formatter.format();
}
isVarHoisted(varName) {
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) {
const aliasSet = new Set();
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));
aliasSet.add(actionContext.frame.pageAlias);
}
}
return Array.from(aliasSet).filter(alias => alias !== 'page');
}
}
exports.SyntheticsGenerator = SyntheticsGenerator;
//# sourceMappingURL=javascript.js.map