From 584d09e60cc2582a9366c7a4d398fbaaf39f564c Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 18 Oct 2021 10:24:16 -0700 Subject: [PATCH] [Reporting] Revisit handling timeouts for different phases of screenshot capture (#113807) * [Reporting] Revisit handling timeouts for different phases of screenshot capture * remove translations for changed text * add wip unit test * simplify class * todo more testing * fix ts * update snapshots * simplify open_url * fixup me * move setupPage to a method of the ObservableHandler class * do not pass entire config object to helper functions * distinguish internal timeouts vs external timeout * add tests for waitUntil * checkIsPageOpen test * restore passing of renderErrors * updates per feedback * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * fix parsing * apply simplifications consistently * dont main waitUntil a higher order component * resolve the timeouts options outside of the service * comment correction Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Dokolin --- .../lib/screenshots/check_browser_open.ts | 22 -- .../screenshots/get_number_of_items.test.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 12 +- .../reporting/server/lib/screenshots/index.ts | 19 ++ .../server/lib/screenshots/observable.test.ts | 10 +- .../server/lib/screenshots/observable.ts | 179 ++++----------- .../screenshots/observable_handler.test.ts | 160 +++++++++++++ .../lib/screenshots/observable_handler.ts | 214 ++++++++++++++++++ .../server/lib/screenshots/open_url.ts | 19 +- .../server/lib/screenshots/wait_for_render.ts | 8 +- .../screenshots/wait_for_visualizations.ts | 13 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 13 files changed, 465 insertions(+), 206 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts create mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts create mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts diff --git a/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts b/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts deleted file mode 100644 index 95bfa7af870fe..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { HeadlessChromiumDriver } from '../../browsers'; -import { getChromiumDisconnectedError } from '../../browsers/chromium'; - -/* - * Call this function within error-handling `catch` blocks while setup and wait - * for the Kibana URL to be ready for screenshot. This detects if a block of - * code threw an exception because the page is closed or crashed. - * - * Also call once after `setup$` fires in the screenshot pipeline - */ -export const checkPageIsOpen = (browser: HeadlessChromiumDriver) => { - if (!browser.isPageOpen()) { - throw getChromiumDisconnectedError(); - } -}; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts index 0ca622d67283c..f160fcb8b27ad 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts @@ -6,6 +6,7 @@ */ import { set } from 'lodash'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; import { createMockBrowserDriverFactory, @@ -25,6 +26,7 @@ describe('getNumberOfItems', () => { let layout: LayoutInstance; let logger: jest.Mocked; let browser: HeadlessChromiumDriver; + let timeout: number; beforeEach(async () => { const schema = createMockConfigSchema(set({}, 'capture.timeouts.waitForElements', 0)); @@ -34,6 +36,7 @@ describe('getNumberOfItems', () => { captureConfig = config.get('capture'); layout = createMockLayoutInstance(captureConfig); logger = createMockLevelLogger(); + timeout = durationToNumber(captureConfig.timeouts.waitForElements); await createMockBrowserDriverFactory(core, logger, { evaluate: jest.fn( @@ -62,7 +65,7 @@ describe('getNumberOfItems', () => {
`; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(10); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(10); }); it('should determine the number of items by selector ', async () => { @@ -72,7 +75,7 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(3); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(3); }); it('should fall back to the selector when the attribute is empty', async () => { @@ -82,6 +85,6 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(2); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(2); }); }); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts index 9bbd8e07898be..9e5dfa180fd0f 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts @@ -6,15 +6,13 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, layout: LayoutInstance, logger: LevelLogger @@ -33,7 +31,6 @@ export const getNumberOfItems = async ( // the dashboard is using the `itemsCountAttribute` attribute to let us // know how many items to expect since gridster incrementally adds panels // we have to use this hint to wait for all of them - const timeout = durationToNumber(captureConfig.timeouts.waitForElements); await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, { timeout }, @@ -65,11 +62,8 @@ export const getNumberOfItems = async ( logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { - defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, - values: { - error: err, - configKey: 'xpack.reporting.capture.timeouts.waitForElements', - }, + defaultMessage: `An error occurred when trying to read the page for visualization panel info: {error}`, + values: { error: err }, }) ); } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index 1ca8b5e00fee4..2b8a0d6207a9b 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -12,6 +12,19 @@ import { LayoutInstance } from '../layouts'; export { getScreenshots$ } from './observable'; +export interface PhaseInstance { + timeoutValue: number; + configValue: string; + label: string; +} + +export interface PhaseTimeouts { + openUrl: PhaseInstance; + waitForElements: PhaseInstance; + renderComplete: PhaseInstance; + loadDelay: number; +} + export interface ScreenshotObservableOpts { logger: LevelLogger; urlsOrUrlLocatorTuples: UrlOrUrlLocatorTuple[]; @@ -49,6 +62,12 @@ export interface Screenshot { description: string | null; } +export interface PageSetupResults { + elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + timeRange: string | null; + error?: Error; +} + export interface ScreenshotResults { timeRange: string | null; screenshots: Screenshot[]; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index c33bdad44f9e7..3dc06996f0f04 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -98,7 +98,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -173,7 +172,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -225,7 +223,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -314,8 +311,7 @@ describe('Screenshot Observable Pipeline', () => { }, }, ], - "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], - "renderErrors": undefined, + "error": [Error: The "wait for elements" phase encountered an error: Error: An error occurred when trying to read the page for visualization panel info: Error: Mock error!], "screenshots": Array [ Object { "data": Object { @@ -357,8 +353,7 @@ describe('Screenshot Observable Pipeline', () => { }, }, ], - "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], - "renderErrors": undefined, + "error": [Error: An error occurred when trying to read the page for visualization panel info: Error: Mock error!], "screenshots": Array [ Object { "data": Object { @@ -465,7 +460,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts index b8fecdc91a3f2..173dbaaf99557 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts @@ -8,138 +8,70 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriverFactory } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { ElementsPositionAndAttribute, ScreenshotObservableOpts, ScreenshotResults } from './'; -import { checkPageIsOpen } from './check_browser_open'; -import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; -import { getElementPositionAndAttributes } from './get_element_position_data'; -import { getNumberOfItems } from './get_number_of_items'; -import { getScreenshots } from './get_screenshots'; -import { getTimeRange } from './get_time_range'; -import { getRenderErrors } from './get_render_errors'; -import { injectCustomCss } from './inject_css'; -import { openUrl } from './open_url'; -import { waitForRenderComplete } from './wait_for_render'; -import { waitForVisualizations } from './wait_for_visualizations'; +import { + ElementPosition, + ElementsPositionAndAttribute, + PageSetupResults, + ScreenshotObservableOpts, + ScreenshotResults, +} from './'; +import { ScreenshotObservableHandler } from './observable_handler'; -const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200; -const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800; +export { ElementPosition, ElementsPositionAndAttribute, ScreenshotResults }; -interface ScreenSetupData { - elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; - timeRange: string | null; - renderErrors?: string[]; - error?: Error; -} +const getTimeouts = (captureConfig: CaptureConfig) => ({ + openUrl: { + timeoutValue: durationToNumber(captureConfig.timeouts.openUrl), + configValue: `xpack.reporting.capture.timeouts.openUrl`, + label: 'open URL', + }, + waitForElements: { + timeoutValue: durationToNumber(captureConfig.timeouts.waitForElements), + configValue: `xpack.reporting.capture.timeouts.waitForElements`, + label: 'wait for elements', + }, + renderComplete: { + timeoutValue: durationToNumber(captureConfig.timeouts.renderComplete), + configValue: `xpack.reporting.capture.timeouts.renderComplete`, + label: 'render complete', + }, + loadDelay: durationToNumber(captureConfig.loadDelay), +}); export function getScreenshots$( captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory, - { - logger, - urlsOrUrlLocatorTuples, - conditionalHeaders, - layout, - browserTimezone, - }: ScreenshotObservableOpts + opts: ScreenshotObservableOpts ): Rx.Observable { const apmTrans = apm.startTransaction(`reporting screenshot pipeline`, 'reporting'); - const apmCreatePage = apmTrans?.startSpan('create_page', 'wait'); - const create$ = browserDriverFactory.createPage({ browserTimezone }, logger); + const { browserTimezone, logger } = opts; - return create$.pipe( + return browserDriverFactory.createPage({ browserTimezone }, logger).pipe( mergeMap(({ driver, exit$ }) => { apmCreatePage?.end(); exit$.subscribe({ error: () => apmTrans?.end() }); - return Rx.from(urlsOrUrlLocatorTuples).pipe( - concatMap((urlOrUrlLocatorTuple, index) => { - const setup$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => { - // If we're moving to another page in the app, we'll want to wait for the app to tell us - // it's loaded the next page. - const page = index + 1; - const pageLoadSelector = - page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + const screen = new ScreenshotObservableHandler(driver, opts, getTimeouts(captureConfig)); - return openUrl( - captureConfig, - driver, - urlOrUrlLocatorTuple, - pageLoadSelector, - conditionalHeaders, - logger - ); - }), - mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), - mergeMap(async (itemsCount) => { - // set the viewport to the dimentions from the job, to allow elements to flow into the expected layout - const viewport = layout.getViewport(itemsCount) || getDefaultViewPort(); - await Promise.all([ - driver.setViewport(viewport, logger), - waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), - ]); - }), - mergeMap(async () => { - // Waiting till _after_ elements have rendered before injecting our CSS - // allows for them to be displayed properly in many cases - await injectCustomCss(driver, layout, logger); - - const apmPositionElements = apmTrans?.startSpan('position_elements', 'correction'); - if (layout.positionElements) { - // position panel elements for print layout - await layout.positionElements(driver, logger); - } - if (apmPositionElements) apmPositionElements.end(); - - await waitForRenderComplete(captureConfig, driver, layout, logger); - }), - mergeMap(async () => { - return await Promise.all([ - getTimeRange(driver, layout, logger), - getElementPositionAndAttributes(driver, layout, logger), - getRenderErrors(driver, layout, logger), - ]).then(([timeRange, elementsPositionAndAttributes, renderErrors]) => ({ - elementsPositionAndAttributes, - timeRange, - renderErrors, - })); - }), + return Rx.from(opts.urlsOrUrlLocatorTuples).pipe( + concatMap((urlOrUrlLocatorTuple, index) => { + return Rx.of(1).pipe( + screen.setupPage(index, urlOrUrlLocatorTuple, apmTrans), catchError((err) => { - checkPageIsOpen(driver); // if browser has closed, throw a relevant error about it + screen.checkPageIsOpen(); // this fails the job if the browser has closed logger.error(err); - return Rx.of({ - elementsPositionAndAttributes: null, - timeRange: null, - error: err, - }); - }) - ); - - return setup$.pipe( + return Rx.of({ ...defaultSetupResult, error: err }); // allow failover screenshot capture + }), takeUntil(exit$), - mergeMap(async (data: ScreenSetupData): Promise => { - checkPageIsOpen(driver); // re-check that the browser has not closed - - const elements = data.elementsPositionAndAttributes - ? data.elementsPositionAndAttributes - : getDefaultElementPosition(layout.getViewport(1)); - const screenshots = await getScreenshots(driver, elements, logger); - const { timeRange, error: setupError, renderErrors } = data; - return { - timeRange, - screenshots, - error: setupError, - renderErrors, - elementsPositionAndAttributes: elements, - }; - }) + screen.getScreenshots() ); }), - take(urlsOrUrlLocatorTuples.length), + take(opts.urlsOrUrlLocatorTuples.length), toArray() ); }), @@ -147,30 +79,7 @@ export function getScreenshots$( ); } -/* - * If Kibana is showing a non-HTML error message, the viewport might not be - * provided by the browser. - */ -const getDefaultViewPort = () => ({ - height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, - width: DEFAULT_SCREENSHOT_CLIP_WIDTH, - zoom: 1, -}); -/* - * If an error happens setting up the page, we don't know if there actually - * are any visualizations showing. These defaults should help capture the page - * enough for the user to see the error themselves - */ -const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { - const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; - const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; - - const defaultObject = { - position: { - boundingClientRect: { top: 0, left: 0, height, width }, - scroll: { x: 0, y: 0 }, - }, - attributes: {}, - }; - return [defaultObject]; +const defaultSetupResult: PageSetupResults = { + elementsPositionAndAttributes: null, + timeRange: null, }; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts new file mode 100644 index 0000000000000..25a8bed370d86 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as Rx from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { HeadlessChromiumDriver } from '../../browsers'; +import { ReportingConfigType } from '../../config'; +import { ConditionalHeaders } from '../../export_types/common'; +import { + createMockBrowserDriverFactory, + createMockConfigSchema, + createMockLayoutInstance, + createMockLevelLogger, + createMockReportingCore, +} from '../../test_helpers'; +import { LayoutInstance } from '../layouts'; +import { PhaseTimeouts, ScreenshotObservableOpts } from './'; +import { ScreenshotObservableHandler } from './observable_handler'; + +const logger = createMockLevelLogger(); + +describe('ScreenshotObservableHandler', () => { + let captureConfig: ReportingConfigType['capture']; + let layout: LayoutInstance; + let conditionalHeaders: ConditionalHeaders; + let opts: ScreenshotObservableOpts; + let timeouts: PhaseTimeouts; + let driver: HeadlessChromiumDriver; + + beforeAll(async () => { + captureConfig = { + timeouts: { + openUrl: 30000, + waitForElements: 30000, + renderComplete: 30000, + }, + loadDelay: 5000, + } as unknown as typeof captureConfig; + + layout = createMockLayoutInstance(captureConfig); + + conditionalHeaders = { + headers: { testHeader: 'testHeadValue' }, + conditions: {} as unknown as ConditionalHeaders['conditions'], + }; + + opts = { + conditionalHeaders, + layout, + logger, + urlsOrUrlLocatorTuples: [], + }; + + timeouts = { + openUrl: { + timeoutValue: 60000, + configValue: `xpack.reporting.capture.timeouts.openUrl`, + label: 'open URL', + }, + waitForElements: { + timeoutValue: 30000, + configValue: `xpack.reporting.capture.timeouts.waitForElements`, + label: 'wait for elements', + }, + renderComplete: { + timeoutValue: 60000, + configValue: `xpack.reporting.capture.timeouts.renderComplete`, + label: 'render complete', + }, + loadDelay: 5000, + }; + }); + + beforeEach(async () => { + const reporting = await createMockReportingCore(createMockConfigSchema()); + const driverFactory = await createMockBrowserDriverFactory(reporting, logger); + ({ driver } = await driverFactory.createPage({}, logger).pipe(first()).toPromise()); + driver.isPageOpen = jest.fn().mockImplementation(() => true); + }); + + describe('waitUntil', () => { + it('catches TimeoutError and references the timeout config in a custom message', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.interval(1000).pipe( + screenshots.waitUntil({ + timeoutValue: 200, + configValue: 'test.config.value', + label: 'Test Config', + }) + ); + + const testPipeline = () => test$.toPromise(); + await expect(testPipeline).rejects.toMatchInlineSnapshot( + `[Error: The "Test Config" phase took longer than 0.2 seconds. You may need to increase "test.config.value": TimeoutError: Timeout has occurred]` + ); + }); + + it('catches other Errors and explains where they were thrown', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.throwError(new Error(`Test Error to Throw`)).pipe( + screenshots.waitUntil({ + timeoutValue: 200, + configValue: 'test.config.value', + label: 'Test Config', + }) + ); + + const testPipeline = () => test$.toPromise(); + await expect(testPipeline).rejects.toMatchInlineSnapshot( + `[Error: The "Test Config" phase encountered an error: Error: Test Error to Throw]` + ); + }); + + it('is a pass-through if there is no Error', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of('nice to see you').pipe( + screenshots.waitUntil({ + timeoutValue: 20, + configValue: 'xxxxxxxxxxxxxxxxx', + label: 'xxxxxxxxxxx', + }) + ); + + await expect(test$.toPromise()).resolves.toBe(`nice to see you`); + }); + }); + + describe('checkPageIsOpen', () => { + it('throws a decorated Error when page is not open', async () => { + driver.isPageOpen = jest.fn().mockImplementation(() => false); + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of(234455).pipe( + map((input) => { + screenshots.checkPageIsOpen(); + return input; + }) + ); + + await expect(test$.toPromise()).rejects.toMatchInlineSnapshot( + `[Error: Browser was closed unexpectedly! Check the server logs for more info.]` + ); + }); + + it('is a pass-through when the page is open', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of(234455).pipe( + map((input) => { + screenshots.checkPageIsOpen(); + return input; + }) + ); + + await expect(test$.toPromise()).resolves.toBe(234455); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts new file mode 100644 index 0000000000000..87c247273ef04 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import apm from 'elastic-apm-node'; +import * as Rx from 'rxjs'; +import { catchError, mergeMap, timeout } from 'rxjs/operators'; +import { numberToDuration } from '../../../common/schema_utils'; +import { UrlOrUrlLocatorTuple } from '../../../common/types'; +import { HeadlessChromiumDriver } from '../../browsers'; +import { getChromiumDisconnectedError } from '../../browsers/chromium'; +import { + PageSetupResults, + PhaseInstance, + PhaseTimeouts, + ScreenshotObservableOpts, + ScreenshotResults, +} from './'; +import { getElementPositionAndAttributes } from './get_element_position_data'; +import { getNumberOfItems } from './get_number_of_items'; +import { getRenderErrors } from './get_render_errors'; +import { getScreenshots } from './get_screenshots'; +import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; +import { openUrl } from './open_url'; +import { waitForRenderComplete } from './wait_for_render'; +import { waitForVisualizations } from './wait_for_visualizations'; + +export class ScreenshotObservableHandler { + private conditionalHeaders: ScreenshotObservableOpts['conditionalHeaders']; + private layout: ScreenshotObservableOpts['layout']; + private logger: ScreenshotObservableOpts['logger']; + private waitErrorRegistered = false; + + constructor( + private readonly driver: HeadlessChromiumDriver, + opts: ScreenshotObservableOpts, + private timeouts: PhaseTimeouts + ) { + this.conditionalHeaders = opts.conditionalHeaders; + this.layout = opts.layout; + this.logger = opts.logger; + } + + /* + * Decorates a TimeoutError with context of the phase that has timed out. + */ + public waitUntil(phase: PhaseInstance) { + const { timeoutValue, label, configValue } = phase; + return (source: Rx.Observable) => { + return source.pipe( + timeout(timeoutValue), + catchError((error: string | Error) => { + if (this.waitErrorRegistered) { + throw error; // do not create a stack of errors within the error + } + + this.logger.error(error); + let throwError = new Error(`The "${label}" phase encountered an error: ${error}`); + + if (error instanceof Rx.TimeoutError) { + throwError = new Error( + `The "${label}" phase took longer than` + + ` ${numberToDuration(timeoutValue).asSeconds()} seconds.` + + ` You may need to increase "${configValue}": ${error}` + ); + } + + this.waitErrorRegistered = true; + this.logger.error(throwError); + throw throwError; + }) + ); + }; + } + + private openUrl(index: number, urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple) { + return mergeMap(() => + openUrl( + this.timeouts.openUrl.timeoutValue, + this.driver, + index, + urlOrUrlLocatorTuple, + this.conditionalHeaders, + this.logger + ) + ); + } + + private waitForElements() { + const driver = this.driver; + const waitTimeout = this.timeouts.waitForElements.timeoutValue; + return (withPageOpen: Rx.Observable) => + withPageOpen.pipe( + mergeMap(() => getNumberOfItems(waitTimeout, driver, this.layout, this.logger)), + mergeMap(async (itemsCount) => { + // set the viewport to the dimentions from the job, to allow elements to flow into the expected layout + const viewport = this.layout.getViewport(itemsCount) || getDefaultViewPort(); + await Promise.all([ + driver.setViewport(viewport, this.logger), + waitForVisualizations(waitTimeout, driver, itemsCount, this.layout, this.logger), + ]); + }) + ); + } + + private completeRender(apmTrans: apm.Transaction | null) { + const driver = this.driver; + const layout = this.layout; + const logger = this.logger; + + return (withElements: Rx.Observable) => + withElements.pipe( + mergeMap(async () => { + // Waiting till _after_ elements have rendered before injecting our CSS + // allows for them to be displayed properly in many cases + await injectCustomCss(driver, layout, logger); + + const apmPositionElements = apmTrans?.startSpan('position_elements', 'correction'); + // position panel elements for print layout + await layout.positionElements?.(driver, logger); + apmPositionElements?.end(); + + await waitForRenderComplete(this.timeouts.loadDelay, driver, layout, logger); + }), + mergeMap(() => + Promise.all([ + getTimeRange(driver, layout, logger), + getElementPositionAndAttributes(driver, layout, logger), + getRenderErrors(driver, layout, logger), + ]).then(([timeRange, elementsPositionAndAttributes, renderErrors]) => ({ + elementsPositionAndAttributes, + timeRange, + renderErrors, + })) + ) + ); + } + + public setupPage( + index: number, + urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, + apmTrans: apm.Transaction | null + ) { + return (initial: Rx.Observable) => + initial.pipe( + this.openUrl(index, urlOrUrlLocatorTuple), + this.waitUntil(this.timeouts.openUrl), + this.waitForElements(), + this.waitUntil(this.timeouts.waitForElements), + this.completeRender(apmTrans), + this.waitUntil(this.timeouts.renderComplete) + ); + } + + public getScreenshots() { + return (withRenderComplete: Rx.Observable) => + withRenderComplete.pipe( + mergeMap(async (data: PageSetupResults): Promise => { + this.checkPageIsOpen(); // fail the report job if the browser has closed + + const elements = + data.elementsPositionAndAttributes ?? + getDefaultElementPosition(this.layout.getViewport(1)); + const screenshots = await getScreenshots(this.driver, elements, this.logger); + const { timeRange, error: setupError } = data; + + return { + timeRange, + screenshots, + error: setupError, + elementsPositionAndAttributes: elements, + }; + }) + ); + } + + public checkPageIsOpen() { + if (!this.driver.isPageOpen()) { + throw getChromiumDisconnectedError(); + } + } +} + +const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200; +const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800; + +const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { + const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; + const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; + + return [ + { + position: { + boundingClientRect: { top: 0, left: 0, height, width }, + scroll: { x: 0, y: 0 }, + }, + attributes: {}, + }, + ]; +}; + +/* + * If Kibana is showing a non-HTML error message, the viewport might not be + * provided by the browser. + */ +const getDefaultViewPort = () => ({ + height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, + width: DEFAULT_SCREENSHOT_CLIP_WIDTH, + zoom: 1, +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index 588cd792bdf06..63a5e80289e3e 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -6,21 +6,25 @@ */ import { i18n } from '@kbn/i18n'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; import { LevelLogger, startTrace } from '../'; -import { durationToNumber } from '../../../common/schema_utils'; +import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; import { HeadlessChromiumDriver } from '../../browsers'; import { ConditionalHeaders } from '../../export_types/common'; -import { CaptureConfig } from '../../types'; +import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; export const openUrl = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, + index: number, urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, - waitForSelector: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { + // If we're moving to another page in the app, we'll want to wait for the app to tell us + // it's loaded the next page. + const page = index + 1; + const waitForSelector = page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + const endTrace = startTrace('open_url', 'wait'); let url: string; let locator: undefined | LocatorParams; @@ -32,14 +36,13 @@ export const openUrl = async ( } try { - const timeout = durationToNumber(captureConfig.timeouts.openUrl); await browser.open(url, { conditionalHeaders, waitForSelector, timeout, locator }, logger); } catch (err) { logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { - defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, - values: { configKey: 'xpack.reporting.capture.timeouts.openUrl', error: err }, + defaultMessage: `An error occurred when trying to open the Kibana URL: {error}`, + values: { error: err }, }) ); } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index f8293bfce3346..1ac4b58b61507 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -6,15 +6,13 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LevelLogger, startTrace } from '../'; +import { HeadlessChromiumDriver } from '../../browsers'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, + loadDelay: number, browser: HeadlessChromiumDriver, layout: LayoutInstance, logger: LevelLogger @@ -69,7 +67,7 @@ export const waitForRenderComplete = async ( return Promise.all(renderedTasks).then(hackyWaitForVisualizations); }, - args: [layout.selectors.renderComplete, durationToNumber(captureConfig.loadDelay)], + args: [layout.selectors.renderComplete, loadDelay], }, { context: CONTEXT_WAITFORRENDER }, logger diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts index 0ab274da7e1cf..d4bf1db2a0c5a 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts @@ -6,10 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -25,7 +23,7 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, toEqual: number, layout: LayoutInstance, @@ -42,7 +40,6 @@ export const waitForVisualizations = async ( ); try { - const timeout = durationToNumber(captureConfig.timeouts.renderComplete); await browser.waitFor( { fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual, timeout }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, @@ -54,12 +51,8 @@ export const waitForVisualizations = async ( logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { - defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, - values: { - count: toEqual, - configKey: 'xpack.reporting.capture.timeouts.renderComplete', - error: err, - }, + defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. {error}`, + values: { count: toEqual, error: err }, }) ); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 79d092cebe366..c941cbd9ddf80 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19451,13 +19451,10 @@ "xpack.reporting.registerFeature.reportingDescription": "Discover、可視化、ダッシュボードから生成されたレポートを管理します。", "xpack.reporting.registerFeature.reportingTitle": "レポート", "xpack.reporting.screencapture.browserWasClosed": "ブラウザーは予期せず終了しました。詳細については、サーバーログを確認してください。", - "xpack.reporting.screencapture.couldntFinishRendering": "{count} 件のビジュアライゼーションのレンダリングが完了するのを待つ間にエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", - "xpack.reporting.screencapture.couldntLoadKibana": "Kibana URL を開こうとするときにエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.injectCss": "Kibana CSS をレポート用に更新しようとしたときにエラーが発生しました。{error}", "xpack.reporting.screencapture.injectingCss": "カスタム css の投入中", "xpack.reporting.screencapture.logWaitingForElements": "要素または項目のカウント属性を待ち、または見つからないため中断", "xpack.reporting.screencapture.noElements": "ビジュアライゼーションパネルのページを読み取る間にエラーが発生しました:パネルが見つかりませんでした。", - "xpack.reporting.screencapture.readVisualizationsError": "ビジュアライゼーションパネル情報のページを読み取ろうとしたときにエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.renderIsComplete": "レンダリングが完了しました", "xpack.reporting.screencapture.screenshotsTaken": "撮影したスクリーンショット:{numScreenhots}", "xpack.reporting.screencapture.takingScreenshots": "スクリーンショットの撮影中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c80cd968f38a8..e9e9f02c8fe99 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19733,13 +19733,10 @@ "xpack.reporting.registerFeature.reportingDescription": "管理您从 Discover、Visualize 和 Dashboard 生成的报告。", "xpack.reporting.registerFeature.reportingTitle": "Reporting", "xpack.reporting.screencapture.browserWasClosed": "浏览器已意外关闭!有关更多信息,请查看服务器日志。", - "xpack.reporting.screencapture.couldntFinishRendering": "尝试等候 {count} 个可视化完成渲染时发生错误。您可能需要增加“{configKey}”。{error}", - "xpack.reporting.screencapture.couldntLoadKibana": "尝试打开 Kibana URL 时发生了错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.injectCss": "尝试为 Reporting 更新 Kibana CSS 时发生错误。{error}", "xpack.reporting.screencapture.injectingCss": "正在注入定制 css", "xpack.reporting.screencapture.logWaitingForElements": "等候元素或项目计数属性;或未发现要中断", "xpack.reporting.screencapture.noElements": "读取页面以获取可视化面板时发生了错误:未找到任何面板。", - "xpack.reporting.screencapture.readVisualizationsError": "尝试页面以获取可视化面板信息时发生了错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.renderIsComplete": "渲染已完成", "xpack.reporting.screencapture.screenshotsTaken": "已捕获的屏幕截图:{numScreenhots}", "xpack.reporting.screencapture.takingScreenshots": "正在捕获屏幕截图",