UNPKG

@memlab/e2e

Version:

memlab browser E2E interaction libraries

431 lines (430 loc) 19.7 kB
"use strict"; /** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format * @oncall memory_lab */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const E2EUtils_1 = __importDefault(require("./lib/E2EUtils")); const core_1 = require("@memlab/core"); const lens_1 = require("@memlab/lens"); const InteractionUtils_1 = __importDefault(require("./lib/operations/InteractionUtils")); const TestPlanner_1 = __importDefault(require("./lib/operations/TestPlanner")); const NetworkManager_1 = __importDefault(require("./NetworkManager")); const { logMetaData, setPermissions, logTabProgress, maybeWaitForConsoleInput, applyAsyncWithRetry, compareURL, waitExtraForTab, checkURL, injectPageReloadChecker, checkPageReload, dispatchOperation, clearConsole, getNavigationHistoryLength, checkLastSnapshotChunk, getURLParameter, } = E2EUtils_1.default; class E2EInteractionManager { constructor(page, browser) { this.pageHistoryLength = []; this.evalFuncAfterInitLoad = null; this.page = page; this.browser = browser; this.networkManager = new NetworkManager_1.default(page); } getChosenCDPSession() { return __awaiter(this, void 0, void 0, function* () { if (core_1.config.isAnalyzingMainThread) { return this.getMainThreadCDPSession(); } // get web worker thread target const cdpSession = yield this.selectCDPSession(target => { var _a, _b; const t = target; const isWorker = ((_a = t._targetInfo) === null || _a === void 0 ? void 0 : _a.type) === 'worker'; let isTitleMatch = true; if (core_1.config.targetWorkerTitle != null) { isTitleMatch = ((_b = t._targetInfo) === null || _b === void 0 ? void 0 : _b.title) === core_1.config.targetWorkerTitle; } return isWorker && isTitleMatch; }); if (cdpSession == null) { throw core_1.utils.haltOrThrow('web worker or main thread heap under analysis not found'); } return cdpSession; }); } getMainThreadCDPSession() { return __awaiter(this, void 0, void 0, function* () { if (!this.mainThreadCdpsession) { this.mainThreadCdpsession = yield this.page.target().createCDPSession(); this.networkManager.setCDPSession(this.mainThreadCdpsession); } return this.mainThreadCdpsession; }); } selectCDPSession(predicate) { return __awaiter(this, void 0, void 0, function* () { const targets = yield this.browser.targets(); const target = targets.find(predicate); if (!target) { return null; } return yield target.createCDPSession(); }); } clearCDPSession() { this.mainThreadCdpsession = null; } setEvalFuncAfterInitLoad(func) { this.evalFuncAfterInitLoad = func; } enableLeakOutlineDisplay() { return __awaiter(this, void 0, void 0, function* () { if (core_1.config.displayLeakOutlines) { core_1.info.lowLevel('Enabling leak outlines display...'); // import the code from memlens const leakVisualizationScript = (0, lens_1.getBundleContent)(); yield this.page.evaluate((script) => { eval(script); }, leakVisualizationScript); } }); } initialLoad(page, url, opArgs = {}) { return __awaiter(this, void 0, void 0, function* () { if (core_1.config.verbose) { core_1.info.lowLevel(`loading: ${url}`); } core_1.info.overwrite('Connecting to web server...'); yield page.goto(url, { timeout: core_1.config.initialPageLoadTimeout, waitUntil: 'load', }); // wait extra 10s in continuous test env for the initial page load if (core_1.config.isContinuousTest) { yield InteractionUtils_1.default.waitFor(10000); } if (this.evalFuncAfterInitLoad) { yield page.evaluate(this.evalFuncAfterInitLoad); } yield InteractionUtils_1.default.waitUntilLoaded(page, opArgs); yield this.enableLeakOutlineDisplay(); }); } beforeInteractions() { return __awaiter(this, void 0, void 0, function* () { // tracking main thread for network interception const session = yield this.getMainThreadCDPSession(); if (core_1.config.interceptScript) { this.networkManager.setCDPSession(session); yield this.networkManager.interceptScript(); } if (core_1.config.verbose) { const browserVersion = yield this.page.browser().version(); core_1.info.lowLevel(`Browser version: ${browserVersion}`); const nodeVersion = process.version; core_1.info.lowLevel(`Node.js version: ${nodeVersion}`); } }); } visitAndGetSnapshots(options = {}) { var _a; return __awaiter(this, void 0, void 0, function* () { const visitPlan = options.testPlanner ? options.testPlanner.getVisitPlan() : TestPlanner_1.default.getVisitPlan(); const runConfig = (_a = options.config) !== null && _a !== void 0 ? _a : core_1.config; // notify the running mode the current visit plan runConfig.runningMode.beforeRunning(visitPlan); logMetaData(visitPlan); yield setPermissions(this.page, TestPlanner_1.default.getOrigin()); yield this.beforeInteractions(); const baseURL = core_1.utils.normalizeBaseUrl(visitPlan.baseURL); if (visitPlan.pageSetup) { yield visitPlan.pageSetup(this.page); } yield this.startTrackingHeap(); for (let i = 0; i < visitPlan.tabsOrder.length; i++) { core_1.info.beginSection(`step-${i}`); if (runConfig.verbose) { core_1.info.lowLevel(new Date().toString()); } const tab = visitPlan.tabsOrder[i]; const subUrl = tab.url.substring(tab.url.startsWith('/') ? 1 : 0); const url = `${baseURL}${subUrl}` + getURLParameter(tab, visitPlan); logTabProgress(i, visitPlan); yield maybeWaitForConsoleInput(i + 1); const opArgs = { isPageLoaded: visitPlan.isPageLoaded, scenario: visitPlan.scenario, }; yield applyAsyncWithRetry(this.getPageStatistics, this, [url, tab, opArgs], { retry: runConfig.interactionFailRetry, }); // dump browser console output in a readable file core_1.browserInfo.dump(); core_1.info.nextLine(); core_1.info.endSection(`step-${i}`); } // show progress on console core_1.info.topLevel(core_1.serializer.summarizeTabsOrder(visitPlan.tabsOrder, { color: true, progress: visitPlan.tabsOrder.length, }) + '\n'); // serialize the meta data again (with more runtime and browser info) logMetaData(visitPlan, { final: true }); // dump browser console output in a readable file core_1.browserInfo.dump(); }); } warmupInPage() { return __awaiter(this, void 0, void 0, function* () { const visitPlan = TestPlanner_1.default.getVisitPlan(); const baseURL = visitPlan.baseURL; const len = visitPlan.tabsOrder.length; const visited = Object.create(null); // randomize order const tabs = core_1.utils.shuffleArray(Array.from(visitPlan.tabsOrder)); const multipler = core_1.config.warmupRepeat; for (let i = 0; i < len * multipler; ++i) { const tab = tabs[i % len]; visited[tab.name] |= 0; if (++visited[tab.name] > multipler) { continue; } // print current progress if (core_1.config.isContinuousTest || core_1.config.verbose) { let progress = `[${i + 1}/${len * multipler}]`; progress = `${progress}: warming up web server (${tab.name})...`; core_1.info.lowLevel(progress); } else { core_1.info.progress(i, len * multipler, { message: 'Warming up web server' }); } // print url const urlParams = getURLParameter(tab, visitPlan); const url = `${baseURL}${tab.url}${urlParams}`; if (core_1.config.verbose) { core_1.info.lowLevel(`url: ${url}`); } // warm up page yield this.visitPage(url, { mute: true, isPageLoaded: visitPlan.isPageLoaded, }); compareURL(this.page, url); } }); } visitPage(url, options = {}) { return __awaiter(this, void 0, void 0, function* () { try { yield this.page.goto(url, { timeout: core_1.config.warmupPageLoadTimeout, waitUntil: 'domcontentloaded', }); yield InteractionUtils_1.default.waitUntilLoaded(this.page, options); } catch (ex) { core_1.info.overwrite(core_1.utils.getError(ex).message); } }); } getPageStatistics(url, tabInfo, opArgs = {}) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { if (core_1.config.verbose) { core_1.info.lowLevel('url: ' + url); } // visit the URL of the first step if (tabInfo.idx === 1) { const beforeInitLoad = (_a = opArgs.scenario) === null || _a === void 0 ? void 0 : _a.beforeInitialPageLoad; if (beforeInitLoad) { yield beforeInitLoad(this.page); } yield applyAsyncWithRetry(this.initialLoad, this, [this.page, url, opArgs], { retry: core_1.config.initialLoadFailRetry, delayBeforeRetry: 3000, }); // only use the interactions for steps other than the first step } else if (tabInfo.interactions) { yield this.interactWithPage(this.page, tabInfo.interactions, opArgs); } if (tabInfo.type === 'final' || tabInfo.type === 'target') { yield waitExtraForTab(tabInfo); } checkURL(this.page, url); // inject marker, which checks if the page is reloaded if (tabInfo.idx === 1) { // call setup callback if the scenario has one const setup = (_b = opArgs.scenario) === null || _b === void 0 ? void 0 : _b.setup; if (setup) { yield setup(this.page); } yield injectPageReloadChecker(this.page); } else { yield checkPageReload(this.page); } yield this.fullGC(tabInfo); // collect metrics yield this.collectMetrics(tabInfo); // take screenshot const screenShotIdx = tabInfo.screenshot ? tabInfo.idx : 0; if (core_1.config.runningMode.shouldTakeScreenShot(tabInfo) && screenShotIdx > 0) { yield InteractionUtils_1.default.screenshot(this.page, screenShotIdx); } // take heap snapshot const snapshotIdx = tabInfo.snapshot ? tabInfo.idx : 0; if (core_1.config.runningMode.shouldTakeHeapSnapshot(tabInfo) && snapshotIdx > 0) { const snapshotFile = path_1.default.join(core_1.config.curDataDir, `s${snapshotIdx}.heapsnapshot`); yield this.saveHeapSnapshotToFile(snapshotFile); } if (tabInfo.postInteractions) { yield this.interactWithPage(this.page, tabInfo.postInteractions, opArgs); } }); } interactWithPage(page, operations, opArgs = {}) { return __awaiter(this, void 0, void 0, function* () { const args = Object.assign(Object.assign({}, opArgs), { pageHistoryLength: this.pageHistoryLength }); if (typeof operations === 'function') { yield operations(page, args); yield InteractionUtils_1.default.waitUntilLoaded(page, args); } else if (Array.isArray(operations)) { for (const op of operations) { yield this.interactWithPage(page, op, args); } } else if (operations.kind) { yield dispatchOperation(page, operations, args); } else { core_1.utils.throwError(new Error('unknown operation')); } }); } startTrackingHeap() { return __awaiter(this, void 0, void 0, function* () { if (core_1.config.verbose) { core_1.info.lowLevel('Start tracking JS heap'); } const session = yield this.getMainThreadCDPSession(); yield session.send('HeapProfiler.enable'); }); } writeSnapshotFileFromCDPSession(file, session) { return __awaiter(this, void 0, void 0, function* () { const writeStream = fs_1.default.createWriteStream(file, { encoding: 'UTF-8' }); let lastChunk = ''; const dataHandler = data => { writeStream.write(data.chunk); lastChunk = data.chunk; }; const progressHandler = data => { const percent = ((100 * data.done) / data.total) | 0; if (!core_1.config.isContinuousTest) { core_1.info.overwrite(`heap snapshot ${percent}% complete`); } }; session.on('HeapProfiler.addHeapSnapshotChunk', dataHandler); session.on('HeapProfiler.reportHeapSnapshotProgress', progressHandler); // start taking heap snapshot yield session.send('HeapProfiler.takeHeapSnapshot', { reportProgress: true, captureNumericValue: true, }); checkLastSnapshotChunk(lastChunk); this.removeListener(session, 'HeapProfiler.addHeapSnapshotChunk', dataHandler); this.removeListener(session, 'HeapProfiler.reportHeapSnapshotProgress', progressHandler); writeStream.end(); }); } // this implement may remove all dataHandler of the specified eventType removeListener(session, eventType, dataHandler) { if (typeof session.removeListener === 'function') { session.removeListener(eventType, dataHandler); return; } if (typeof session.removeAllListeners === 'function') { session.removeAllListeners(eventType); return; } } saveHeapSnapshotToFile(file) { return __awaiter(this, void 0, void 0, function* () { core_1.info.beginSection('heap snapshot'); const start = Date.now(); const session = yield this.getChosenCDPSession(); yield this.writeSnapshotFileFromCDPSession(file, session); const spanMs = Date.now() - start; if (core_1.config.verbose) { core_1.info.lowLevel(`duration: ${core_1.utils.getReadableTime(spanMs)}`); } core_1.info.overwrite('snapshot saved to disk'); core_1.info.endSection('heap snapshot'); }); } fullGC(tabInfo) { return __awaiter(this, void 0, void 0, function* () { if (!core_1.config.runningMode.shouldGC(tabInfo)) { return; } if (core_1.config.clearConsole) { yield clearConsole(this.page); core_1.info.overwrite('running a full GC (clear console)...'); } else { core_1.info.overwrite('running a full GC...'); } // force GC 6 times to release feedback_cells yield this.forceMainThreadGC(6); }); } forceMainThreadGC(repeat = 1) { return __awaiter(this, void 0, void 0, function* () { const client = yield this.getMainThreadCDPSession(); for (let i = 0; i < repeat; i++) { yield client.send('HeapProfiler.collectGarbage'); // wait for a while and let GC do the job yield InteractionUtils_1.default.waitFor(200); } yield InteractionUtils_1.default.waitFor(core_1.config.waitAfterGC); }); } collectMetrics(tabInfo) { var _a; return __awaiter(this, void 0, void 0, function* () { // collect navigation history info const historyLength = yield getNavigationHistoryLength(this.page); this.pageHistoryLength.push(historyLength); if (!core_1.config.runningMode.shouldGetMetrics(tabInfo)) { return; } yield this.forceMainThreadGC(); // collect heap size const builtInMetrics = yield this.page.metrics(); const size = core_1.utils.getReadableBytes(builtInMetrics.JSHeapUsedSize); core_1.info.midLevel(`Heap size: ${size}`); tabInfo.JSHeapUsedSize = (_a = builtInMetrics.JSHeapUsedSize) !== null && _a !== void 0 ? _a : 0; // collect additional metrics const metrics = yield core_1.config.runningMode.getAdditionalMetrics(this.page, tabInfo); for (const key of Object.keys(metrics)) { if (Object.prototype.hasOwnProperty.call(tabInfo, key)) { core_1.info.warning(`overwriting metrics: ${key}`); } } tabInfo.metrics = metrics; }); } } exports.default = E2EInteractionManager;