@elastic/synthetics
Version:
Elastic synthetic monitoring agent
505 lines • 19.8 kB
JavaScript
"use strict";
/**
* 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 });
const path_1 = require("path");
const promises_1 = require("fs/promises");
const reporters_1 = require("../reporters");
const helpers_1 = require("../helpers");
const plugins_1 = require("../plugins");
const gatherer_1 = require("./gatherer");
const logger_1 = require("./logger");
const monitor_1 = require("../dsl/monitor");
const monitor_2 = require("../push/monitor");
class Runner {
#active = false;
#reporter;
#currentJourney = null;
#journeys = [];
#hooks = { beforeAll: [], afterAll: [] };
#screenshotPath = (0, path_1.join)(helpers_1.CACHE_PATH, 'screenshots');
#driver;
#browserDelay = -1;
#hookError;
#monitor;
config;
get currentJourney() {
return this.#currentJourney;
}
get journeys() {
return this.#journeys;
}
get hooks() {
return this.#hooks;
}
async captureScreenshot(page, step) {
try {
const buffer = await page.screenshot({
type: 'jpeg',
quality: 80,
timeout: 5000,
});
/**
* Write the screenshot image buffer with additional details (step
* information) which could be extracted at the end of
* each journey without impacting the step timing information
*/
const fileName = `${(0, helpers_1.generateUniqueId)()}.json`;
const screenshot = {
step,
timestamp: (0, helpers_1.getTimestamp)(),
data: buffer.toString('base64'),
};
await (0, promises_1.writeFile)((0, path_1.join)(this.#screenshotPath, fileName), JSON.stringify(screenshot));
(0, logger_1.log)(`Runner: captured screenshot for (${step.name})`);
}
catch (_) {
// Screenshot may fail sometimes, log and continue.
(0, logger_1.log)(`Runner: failed to capture screenshot for (${step.name})`);
}
}
_addHook(type, callback) {
this.#hooks[type].push(callback);
}
/**
* @deprecated Since version Please do not rely on the internal methods.
* Alias _addHook for backwards compatibility
*/
addHook(type, callback) {
this._addHook(type, callback);
}
buildHookArgs() {
return {
env: this.config.environment,
params: this.config.params,
info: this,
};
}
_updateMonitor(config) {
if (!this.#monitor) {
this.#monitor = new monitor_1.Monitor(config);
return;
}
this.#monitor.update(config);
}
/**
* @deprecated Since version Please do not rely on the internal methods.
* Alias _addJourney for backwards compatibility
*/
updateMonitor(config) {
this._updateMonitor(config);
}
_addJourney(journey) {
this.#journeys.push(journey);
this.#currentJourney = journey;
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _addJourney for backwards compatibility
*/
addJourney(journey) {
this._addJourney(journey);
}
setReporter(options) {
/**
* Set up the corresponding reporter and fallback
* to default reporter if not provided
*/
const { reporter, outfd, dryRun } = options;
const Reporter = typeof reporter === 'function'
? reporter
: reporters_1.reporters[reporter] || reporters_1.reporters['default'];
this.#reporter = new Reporter({ fd: outfd, dryRun });
}
async #runBeforeAllHook(args) {
(0, logger_1.log)(`Runner: beforeAll hooks`);
await (0, helpers_1.runParallel)(this.#hooks.beforeAll, args);
}
async #runAfterAllHook(args) {
(0, logger_1.log)(`Runner: afterAll hooks`);
await (0, helpers_1.runParallel)(this.#hooks.afterAll, args);
}
async #runBeforeHook(journey, args) {
(0, logger_1.log)(`Runner: before hooks for (${journey.name})`);
await (0, helpers_1.runParallel)(journey._getHook('before'), args);
}
async #runAfterHook(journey, args) {
(0, logger_1.log)(`Runner: after hooks for (${journey.name})`);
await (0, helpers_1.runParallel)(journey._getHook('after'), args);
}
async #runStep(step, options) {
(0, logger_1.log)(`Runner: start step (${step.name})`);
const { metrics, screenshots, filmstrips, trace } = options;
/**
* URL needs to be the first navigation request of any step
* Listening for request solves the case where `about:blank` would be
* reported for failed navigations
*/
const captureUrl = req => {
if (!step.url && req.isNavigationRequest()) {
step.url = req.url();
}
this.#driver.context.off('request', captureUrl);
};
this.#driver.context.on('request', captureUrl);
const data = {};
const traceEnabled = trace || filmstrips;
try {
/**
* Set up plugin manager context and also register
* step level plugins
*/
gatherer_1.Gatherer.pluginManager.onStep(step);
traceEnabled && (await gatherer_1.Gatherer.pluginManager.start('trace'));
// invoke the step callback by extracting to a variable to get better stack trace
const cb = step.cb;
await cb();
step.status = 'succeeded';
}
catch (error) {
step.status = 'failed';
step.error = error;
}
finally {
/**
* Collect all step level metrics and trace events
*/
if (metrics) {
data.pagemetrics = await gatherer_1.Gatherer.pluginManager.get('performance').getMetrics();
}
if (traceEnabled) {
const traceOutput = await gatherer_1.Gatherer.pluginManager.stop('trace');
Object.assign(data, traceOutput);
}
/**
* Capture screenshot for the newly created pages
* via popup or new windows/tabs
*
* Last open page will get us the correct screenshot
*/
const pages = this.#driver.context.pages();
const page = pages[pages.length - 1];
if (page) {
step.url ??= page.url();
if (screenshots && screenshots !== 'off') {
await this.captureScreenshot(page, step);
}
}
}
(0, logger_1.log)(`Runner: end step (${step.name})`);
return data;
}
async #runSteps(journey, options) {
const results = [];
const isOnlyExists = journey.steps.filter(s => s.only).length > 0;
let skipStep = false;
for (const step of journey.steps) {
step._startTime = (0, helpers_1.monotonicTimeInSeconds)();
this.#reporter?.onStepStart?.(journey, step);
let data = {};
/**
* Skip the step
* - if the step is marked as skip
* - if the previous step fails and the current step is not marked as soft
* - if the step is not marked as only and there are steps marked as only
*/
if (step.skip ||
(skipStep && !step.only) ||
(isOnlyExists && !step.only)) {
step.status = 'skipped';
}
else {
data = await this.#runStep(step, options);
/**
* skip next steps if the previous step returns error
*/
if (step.error && !step.soft)
skipStep = true;
}
step.duration = (0, helpers_1.monotonicTimeInSeconds)() - step._startTime;
this.#reporter?.onStepEnd?.(journey, step, data);
if (options.pauseOnError && step.error) {
await new Promise(r => process.stdin.on('data', r));
}
results.push(data);
}
return results;
}
async #startJourney(journey, options) {
journey._startTime = (0, helpers_1.monotonicTimeInSeconds)();
this.#driver = await gatherer_1.Gatherer.setupDriver(options);
await gatherer_1.Gatherer.beginRecording(this.#driver, options);
/**
* For each journey we create the screenshots folder for
* caching all screenshots and clear them at end of each journey
*/
await (0, promises_1.mkdir)(this.#screenshotPath, { recursive: true });
const params = options.params;
this.#reporter?.onJourneyStart?.(journey, {
timestamp: (0, helpers_1.getTimestamp)(),
params,
});
/**
* Exeucute the journey callback which registers the steps for current journey
*/
journey.cb({ ...this.#driver, params, info: this });
}
async #endJourney(journey, result, options) {
// Enhance the journey results
const pOutput = await gatherer_1.Gatherer.pluginManager.output();
const bConsole = (0, plugins_1.filterBrowserMessages)(pOutput.browserconsole, journey.status);
await this.#reporter?.onJourneyEnd?.(journey, {
browserDelay: this.#browserDelay,
timestamp: (0, helpers_1.getTimestamp)(),
options,
networkinfo: pOutput.networkinfo,
browserconsole: bConsole,
});
await gatherer_1.Gatherer.endRecording();
await gatherer_1.Gatherer.dispose(this.#driver);
// clear screenshots cache after each journey
await (0, promises_1.rm)(this.#screenshotPath, { recursive: true, force: true });
return Object.assign(result, {
networkinfo: pOutput.networkinfo,
browserconsole: bConsole,
...journey,
});
}
/**
* Simulate a journey run to capture errors in the beforeAll hook
*/
async #runFakeJourney(journey, options) {
const start = (0, helpers_1.monotonicTimeInSeconds)();
this.#reporter.onJourneyStart?.(journey, {
timestamp: (0, helpers_1.getTimestamp)(),
params: options.params,
});
// Mark the journey as failed and report the hook error as journey error
journey.status = 'failed';
journey.error = this.#hookError;
journey.duration = (0, helpers_1.monotonicTimeInSeconds)() - start;
await this.#reporter.onJourneyEnd?.(journey, {
timestamp: (0, helpers_1.getTimestamp)(),
options,
browserDelay: this.#browserDelay,
});
return journey;
}
async _runJourney(journey, options) {
this.#currentJourney = journey;
(0, logger_1.log)(`Runner: start journey (${journey.name})`);
let result = {};
const hookArgs = {
env: options.environment,
params: options.params,
info: this,
};
try {
await this.#startJourney(journey, options);
await this.#runBeforeHook(journey, hookArgs);
const stepResults = await this.#runSteps(journey, options);
journey.status = 'succeeded';
// Mark journey as failed if any one of the step fails
for (const step of journey.steps) {
if (step.status === 'failed') {
journey.status = step.status;
journey.error = step.error;
}
}
result.stepsresults = stepResults;
}
catch (e) {
journey.status = 'failed';
journey.error = e;
}
finally {
journey.duration = (0, helpers_1.monotonicTimeInSeconds)() - journey._startTime;
// Run after hook on journey failure and capture the uncaught error as
// journey error, hook is purposely run before to capture errors during reporting
await this.#runAfterHook(journey, hookArgs).catch(e => {
journey.status = 'failed';
journey.error = e;
});
result = await this.#endJourney(journey, result, options);
}
(0, logger_1.log)(`Runner: end journey (${journey.name})`);
return result;
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _runJourney for backwards compatibility
*/
runJourney(journey, options) {
this._runJourney(journey, options);
}
_buildMonitors(options) {
/**
* Update the global monitor configuration required for setting defaults
*/
this._updateMonitor({
throttling: options.throttling,
schedule: options.schedule,
locations: options.locations,
privateLocations: options.privateLocations,
params: options.params,
playwrightOptions: options.playwrightOptions,
screenshot: options.screenshots,
tags: options.tags,
alert: options.alert,
retestOnFailure: options.retestOnFailure,
enabled: options.enabled,
fields: options.fields,
spaces: Array.from(new Set([...(options.spaces ?? []), options.space])),
namespace: options.namespace,
maintenanceWindows: options.maintenanceWindows,
});
const monitors = [];
for (const journey of this.#journeys) {
this.#currentJourney = journey;
if (journey.skip) {
throw new Error(`Journey ${journey.name} is skipped. Please remove the journey.skip annotation and try again.`);
}
/**
* Before pushing a browser monitor, three things need to be done:
*
* - execute callback `monitor.use` in particular to get monitor configurations
* - update the monitor config with global configuration
* - filter out monitors based on matched tags and name after applying both
* global and local monitor configurations
*/
// TODO: Fix backwards compatibility with 1.14 and prior
(journey.cb ?? journey.callback)({ params: options.params });
const monitor = journey.monitor ?? journey?._getMonitor();
monitor.update({
...this.#monitor?.config,
...(0, monitor_2.parseSpaces)(monitor.config, options),
});
if (!monitor.isMatch(options.grepOpts?.match, options.grepOpts?.tags)) {
continue;
}
monitor.validate();
monitors.push(monitor);
}
return monitors;
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _buildMonitors for backwards compatibility
*/
buildMonitors(options) {
this._buildMonitors(options);
}
async #init(options) {
this.setReporter(options);
this.#reporter.onStart?.({
numJourneys: this.#journeys.length,
networkConditions: options.networkConditions,
});
/**
* Set up the directory for caching screenshots
*/
await (0, promises_1.mkdir)(helpers_1.CACHE_PATH, { recursive: true });
}
async _run(options) {
this.config = options;
let result = {};
if (this.#active) {
return result;
}
this.#active = true;
(0, logger_1.log)(`Runner: run ${this.#journeys.length} journeys`);
this.#init(options);
const hookArgs = this.buildHookArgs();
await this.#runBeforeAllHook(hookArgs).catch(e => (this.#hookError = e));
const { dryRun, grepOpts } = options;
// collect all journeys with `.only` annotation and skip the rest
const onlyJournerys = this.#journeys.filter(j => j.only);
if (onlyJournerys.length > 0) {
this.#journeys = onlyJournerys;
}
else {
// filter journeys based on tags and skip annotations
this.#journeys = this.#journeys.filter(j => j._isMatch(grepOpts?.match, grepOpts?.tags) && !j.skip);
}
// Used by heartbeat to gather all registered journeys
if (dryRun) {
this.#journeys.forEach(journey => this.#reporter.onJourneyRegister?.(journey));
}
else if (this.#journeys.length > 0) {
result = await this._runJourneys(options);
}
await this.#runAfterAllHook(hookArgs).catch(async () => await this._reset());
await this._reset();
return result;
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _run for backwards compatibility
*/
run(options) {
return this._run(options);
}
async _runJourneys(options) {
const result = {};
const browserStart = (0, helpers_1.monotonicTimeInSeconds)();
await gatherer_1.Gatherer.launchBrowser(options);
this.#browserDelay = (0, helpers_1.monotonicTimeInSeconds)() - browserStart;
for (const journey of this.#journeys) {
const journeyResult = this.#hookError
? await this.#runFakeJourney(journey, options)
: await this._runJourney(journey, options);
result[journey.name] = journeyResult;
}
await gatherer_1.Gatherer.stop();
return result;
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _runJourneys for backwards compatibility
*/
runJourneys(options) {
return this._runJourneys(options);
}
async _reset() {
this.#currentJourney = null;
this.#journeys = [];
this.#active = false;
/**
* Clear all cache data stored for post processing by
* the current synthetic agent run
*/
await (0, promises_1.rm)(helpers_1.CACHE_PATH, { recursive: true, force: true });
await this.#reporter?.onEnd?.();
}
/**
* @deprecated Since version 1.17.0. Please do not rely on the internal methods.
* Alias _reset for backwards compatibility
*/
reset() {
return this._reset();
}
}
exports.default = Runner;
//# sourceMappingURL=runner.js.map