UNPKG

@elastic/synthetics

Version:

Elastic synthetic monitoring agent

498 lines 19.5 kB
"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"); 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, }); 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); 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