From 903e75ee03203337441fd492e568bb999eb47952 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Mon, 6 Dec 2021 22:00:57 +0100 Subject: [PATCH] [Reporting] Decouple screenshotting plugin from the reporting (#120110) * Add screenshotting plugin * Move screenshotting plugin configuration options * Remove unused browser type configuration option --- docs/developer/plugin-list.asciidoc | 5 + .../src/get_server_watch_paths.test.ts | 2 +- .../src/get_server_watch_paths.ts | 2 +- packages/kbn-optimizer/limits.yml | 1 + src/dev/build/tasks/install_chromium.js | 22 +- .../__tests__/enumerate_patterns.test.js | 15 +- x-pack/.gitignore | 2 +- x-pack/.i18nrc.json | 1 + x-pack/examples/reporting_example/kibana.json | 3 +- .../public/containers/main.tsx | 13 +- x-pack/plugins/reporting/common/constants.ts | 11 - .../plugins/reporting/common/test/fixtures.ts | 1 - x-pack/plugins/reporting/common/types/base.ts | 2 +- .../common/types/export_types/png.ts | 2 +- .../common/types/export_types/png_v2.ts | 2 +- .../types/export_types/printable_pdf.ts | 2 +- .../types/export_types/printable_pdf_v2.ts | 2 +- .../plugins/reporting/common/types/index.ts | 17 - .../plugins/reporting/common/types/layout.ts | 24 - x-pack/plugins/reporting/kibana.json | 1 + x-pack/plugins/reporting/public/lib/job.tsx | 2 - .../components/report_info_flyout_content.tsx | 6 - x-pack/plugins/reporting/public/plugin.ts | 5 +- .../public/redirect/mount_redirect_app.tsx | 17 +- .../public/redirect/redirect_app.tsx | 13 +- .../public/share_context_menu/index.ts | 2 +- .../screen_capture_panel_content.tsx | 4 +- .../chromium/driver_factory/index.test.ts | 76 --- .../browsers/chromium/driver_factory/index.ts | 268 ---------- .../chromium/driver_factory/start_logs.ts | 144 ----- .../server/browsers/chromium/index.ts | 36 -- .../server/browsers/download/download.test.ts | 72 --- .../download/ensure_downloaded.test.ts | 120 ----- .../browsers/download/ensure_downloaded.ts | 101 ---- .../server/browsers/download/index.ts | 8 - .../reporting/server/browsers/index.ts | 35 -- .../reporting/server/browsers/install.ts | 72 --- .../config/__snapshots__/schema.test.ts.snap | 89 ---- .../server/config/create_config.test.ts | 48 -- .../reporting/server/config/create_config.ts | 41 +- .../default_chromium_sandbox_disabled.test.ts | 39 -- .../plugins/reporting/server/config/index.ts | 2 +- .../reporting/server/config/schema.test.ts | 35 -- .../plugins/reporting/server/config/schema.ts | 60 --- x-pack/plugins/reporting/server/core.ts | 53 +- .../export_types/common/generate_png.ts | 108 ++-- .../server/export_types/common/index.ts | 2 +- .../export_types/common/pdf/get_template.ts | 4 +- .../export_types/common/pdf/index.test.ts | 71 +-- .../server/export_types/common/pdf/index.ts | 6 +- .../png/execute_job/index.test.ts | 54 +- .../export_types/png/execute_job/index.ts | 29 +- .../export_types/png_v2/execute_job.test.ts | 62 +-- .../server/export_types/png_v2/execute_job.ts | 25 +- .../printable_pdf/execute_job/index.test.ts | 24 +- .../printable_pdf/execute_job/index.ts | 15 +- .../printable_pdf/lib/generate_pdf.ts | 174 +++---- .../export_types/printable_pdf/lib/tracker.ts | 17 +- .../printable_pdf_v2/execute_job.test.ts | 24 +- .../printable_pdf_v2/execute_job.ts | 13 +- .../printable_pdf_v2/lib/generate_pdf.ts | 195 ++++--- .../printable_pdf_v2/lib/tracker.ts | 17 +- .../server/lib/layouts/create_layout.ts | 29 -- .../reporting/server/lib/layouts/index.ts | 51 -- .../screenshots/get_number_of_items.test.ts | 90 ---- .../lib/screenshots/get_render_errors.test.ts | 82 --- .../lib/screenshots/get_time_range.test.ts | 76 --- .../reporting/server/lib/screenshots/index.ts | 83 --- .../server/lib/screenshots/observable.test.ts | 490 ------------------ .../server/lib/screenshots/observable.ts | 85 --- .../screenshots/observable_handler.test.ts | 160 ------ .../lib/screenshots/observable_handler.ts | 197 ------- .../reporting/server/lib/store/mapping.ts | 1 - .../reporting/server/lib/store/report.test.ts | 6 - .../reporting/server/lib/store/report.ts | 4 - .../reporting/server/lib/store/store.test.ts | 7 - .../reporting/server/lib/store/store.ts | 2 - .../server/lib/tasks/execute_report.ts | 1 - .../plugins/reporting/server/plugin.test.ts | 10 - x-pack/plugins/reporting/server/plugin.ts | 7 +- .../server/routes/diagnostic/browser.test.ts | 157 +----- .../server/routes/diagnostic/browser.ts | 4 +- .../routes/diagnostic/screenshot.test.ts | 8 +- .../server/routes/diagnostic/screenshot.ts | 13 +- .../server/routes/lib/request_handler.test.ts | 2 - .../create_mock_browserdriverfactory.ts | 146 ------ .../create_mock_reportingplugin.ts | 24 +- .../reporting/server/test_helpers/index.ts | 2 - x-pack/plugins/reporting/server/types.ts | 14 +- .../reporting_usage_collector.test.ts.snap | 7 - .../server/usage/get_reporting_usage.ts | 5 - .../plugins/reporting/server/usage/schema.ts | 1 - .../plugins/reporting/server/usage/types.ts | 1 - x-pack/plugins/reporting/tsconfig.json | 1 + x-pack/plugins/screenshotting/README.md | 11 + .../plugins/screenshotting/common/context.ts | 17 + .../driver => screenshotting/common}/index.ts | 4 +- .../plugins/screenshotting/common/layout.ts | 75 +++ x-pack/plugins/screenshotting/jest.config.js | 15 + x-pack/plugins/screenshotting/kibana.json | 14 + .../screenshotting/public/context_storage.ts | 20 + x-pack/plugins/screenshotting/public/index.ts | 18 + .../plugins/screenshotting/public/plugin.tsx | 42 ++ .../server/browsers/chromium/driver.ts} | 177 ++++--- .../browsers/chromium/driver_factory/args.ts | 28 +- .../chromium/driver_factory/index.test.ts | 84 +++ .../browsers/chromium/driver_factory/index.ts | 379 ++++++++++++++ .../chromium/driver_factory/metrics.test.ts | 0 .../chromium/driver_factory/metrics.ts | 20 +- .../server/browsers/chromium/index.ts | 21 + .../server/browsers/chromium/paths.ts | 0 .../server/browsers/download/checksum.test.ts | 0 .../server/browsers/download/checksum.ts | 15 +- .../server/browsers/download/fetch.test.ts | 57 ++ .../server/browsers/download/fetch.ts} | 35 +- .../server/browsers/download/index.test.ts | 104 ++++ .../server/browsers/download/index.ts | 94 ++++ .../browsers/extract/__fixtures__/file.md | 0 .../browsers/extract/__fixtures__/file.md.zip | Bin .../server/browsers/extract/extract.test.ts | 0 .../server/browsers/extract/extract.ts | 0 .../server/browsers/extract/extract_error.ts | 1 + .../server/browsers/extract/index.ts | 0 .../server/browsers/extract/unzip.test.ts | 0 .../server/browsers/extract/unzip.ts | 0 .../screenshotting/server/browsers/index.ts | 17 + .../screenshotting/server/browsers/install.ts | 61 +++ .../screenshotting/server/browsers/mock.ts | 95 ++++ .../server/browsers/network_policy.test.ts | 2 +- .../server/browsers/network_policy.ts | 4 +- .../server/browsers/safe_child_process.ts | 22 +- .../server/config/create_config.test.ts | 39 ++ .../server/config/create_config.ts | 57 ++ .../default_chromium_sandbox_disabled.test.ts | 35 ++ .../default_chromium_sandbox_disabled.ts | 15 +- .../screenshotting/server/config/index.ts | 49 ++ .../server/config/schema.test.ts | 146 ++++++ .../screenshotting/server/config/schema.ts | 72 +++ x-pack/plugins/screenshotting/server/index.ts | 20 + .../server/layouts/base_layout.ts} | 18 +- .../server}/layouts/canvas_layout.ts | 20 +- .../server}/layouts/create_layout.test.ts | 28 +- .../server/layouts/create_layout.ts | 26 + .../screenshotting/server/layouts/index.ts | 38 ++ .../server/layouts/mock.ts} | 22 +- .../server}/layouts/preserve_layout.css | 0 .../server}/layouts/preserve_layout.test.ts | 0 .../server}/layouts/preserve_layout.ts | 21 +- .../server}/layouts/print_layout.ts | 28 +- x-pack/plugins/screenshotting/server/mock.ts | 22 + .../plugins/screenshotting/server/plugin.ts | 89 ++++ .../server}/screenshots/constants.ts | 2 +- .../get_element_position_data.test.ts | 50 +- .../screenshots/get_element_position_data.ts | 41 +- .../screenshots/get_number_of_items.test.ts | 58 +++ .../screenshots/get_number_of_items.ts | 27 +- .../screenshots/get_render_errors.test.ts | 53 ++ .../server}/screenshots/get_render_errors.ts | 19 +- .../screenshots/get_screenshots.test.ts | 42 +- .../server}/screenshots/get_screenshots.ts | 36 +- .../server/screenshots/get_time_range.test.ts | 49 ++ .../server}/screenshots/get_time_range.ts | 15 +- .../server/screenshots/index.test.ts | 411 +++++++++++++++ .../server/screenshots/index.ts | 102 ++++ .../server}/screenshots/inject_css.ts | 17 +- .../screenshotting/server/screenshots/mock.ts | 31 ++ .../server/screenshots/observable.test.ts | 102 ++++ .../server/screenshots/observable.ts | 258 +++++++++ .../server}/screenshots/open_url.ts | 39 +- .../server}/screenshots/wait_for_render.ts | 21 +- .../screenshots/wait_for_visualizations.ts | 21 +- x-pack/plugins/screenshotting/server/utils.ts | 13 + x-pack/plugins/screenshotting/tsconfig.json | 19 + .../schema/xpack_plugins.json | 3 - .../translations/translations/ja-JP.json | 46 +- .../translations/translations/zh-CN.json | 46 +- x-pack/tasks/download_chromium.ts | 10 +- .../reporting_and_security.config.ts | 2 +- 178 files changed, 3794 insertions(+), 4000 deletions(-) delete mode 100644 x-pack/plugins/reporting/common/types/layout.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.test.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/index.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/download/download.test.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.test.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/download/index.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/index.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/install.ts delete mode 100644 x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/layouts/create_layout.ts delete mode 100644 x-pack/plugins/reporting/server/lib/layouts/index.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/get_time_range.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/index.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts delete mode 100644 x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts create mode 100644 x-pack/plugins/screenshotting/README.md create mode 100644 x-pack/plugins/screenshotting/common/context.ts rename x-pack/plugins/{reporting/server/browsers/chromium/driver => screenshotting/common}/index.ts (66%) create mode 100644 x-pack/plugins/screenshotting/common/layout.ts create mode 100644 x-pack/plugins/screenshotting/jest.config.js create mode 100644 x-pack/plugins/screenshotting/kibana.json create mode 100644 x-pack/plugins/screenshotting/public/context_storage.ts create mode 100644 x-pack/plugins/screenshotting/public/index.ts create mode 100755 x-pack/plugins/screenshotting/public/plugin.tsx rename x-pack/plugins/{reporting/server/browsers/chromium/driver/chromium_driver.ts => screenshotting/server/browsers/chromium/driver.ts} (75%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/chromium/driver_factory/args.ts (81%) create mode 100644 x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts create mode 100644 x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts rename x-pack/plugins/{reporting => screenshotting}/server/browsers/chromium/driver_factory/metrics.test.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/chromium/driver_factory/metrics.ts (81%) create mode 100644 x-pack/plugins/screenshotting/server/browsers/chromium/index.ts rename x-pack/plugins/{reporting => screenshotting}/server/browsers/chromium/paths.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/download/checksum.test.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/download/checksum.ts (62%) create mode 100644 x-pack/plugins/screenshotting/server/browsers/download/fetch.test.ts rename x-pack/plugins/{reporting/server/browsers/download/download.ts => screenshotting/server/browsers/download/fetch.ts} (55%) create mode 100644 x-pack/plugins/screenshotting/server/browsers/download/index.test.ts create mode 100644 x-pack/plugins/screenshotting/server/browsers/download/index.ts rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/__fixtures__/file.md (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/__fixtures__/file.md.zip (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/extract.test.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/extract.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/extract_error.ts (99%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/index.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/unzip.test.ts (100%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/extract/unzip.ts (100%) create mode 100644 x-pack/plugins/screenshotting/server/browsers/index.ts create mode 100644 x-pack/plugins/screenshotting/server/browsers/install.ts create mode 100644 x-pack/plugins/screenshotting/server/browsers/mock.ts rename x-pack/plugins/{reporting => screenshotting}/server/browsers/network_policy.test.ts (99%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/network_policy.ts (94%) rename x-pack/plugins/{reporting => screenshotting}/server/browsers/safe_child_process.ts (70%) create mode 100644 x-pack/plugins/screenshotting/server/config/create_config.test.ts create mode 100644 x-pack/plugins/screenshotting/server/config/create_config.ts create mode 100644 x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.test.ts rename x-pack/plugins/{reporting => screenshotting}/server/config/default_chromium_sandbox_disabled.ts (80%) create mode 100644 x-pack/plugins/screenshotting/server/config/index.ts create mode 100644 x-pack/plugins/screenshotting/server/config/schema.test.ts create mode 100644 x-pack/plugins/screenshotting/server/config/schema.ts create mode 100755 x-pack/plugins/screenshotting/server/index.ts rename x-pack/plugins/{reporting/server/lib/layouts/layout.ts => screenshotting/server/layouts/base_layout.ts} (79%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/canvas_layout.ts (80%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/create_layout.test.ts (80%) create mode 100644 x-pack/plugins/screenshotting/server/layouts/create_layout.ts create mode 100644 x-pack/plugins/screenshotting/server/layouts/index.ts rename x-pack/plugins/{reporting/server/test_helpers/create_mock_layoutinstance.ts => screenshotting/server/layouts/mock.ts} (59%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/preserve_layout.css (100%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/preserve_layout.test.ts (100%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/preserve_layout.ts (79%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/layouts/print_layout.ts (64%) create mode 100644 x-pack/plugins/screenshotting/server/mock.ts create mode 100755 x-pack/plugins/screenshotting/server/plugin.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/constants.ts (92%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_element_position_data.test.ts (69%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_element_position_data.ts (75%) create mode 100644 x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_number_of_items.ts (79%) create mode 100644 x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_render_errors.ts (78%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_screenshots.test.ts (71%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_screenshots.ts (59%) create mode 100644 x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/get_time_range.ts (79%) create mode 100644 x-pack/plugins/screenshotting/server/screenshots/index.test.ts create mode 100644 x-pack/plugins/screenshotting/server/screenshots/index.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/inject_css.ts (78%) create mode 100644 x-pack/plugins/screenshotting/server/screenshots/mock.ts create mode 100644 x-pack/plugins/screenshotting/server/screenshots/observable.test.ts create mode 100644 x-pack/plugins/screenshotting/server/screenshots/observable.ts rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/open_url.ts (54%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/wait_for_render.ts (84%) rename x-pack/plugins/{reporting/server/lib => screenshotting/server}/screenshots/wait_for_visualizations.ts (80%) create mode 100644 x-pack/plugins/screenshotting/server/utils.ts create mode 100644 x-pack/plugins/screenshotting/tsconfig.json diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index e997c0bc68cde..3d9de2d35b500 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -540,6 +540,11 @@ Elastic. |Add tagging capability to saved objects +|{kib-repo}blob/{branch}/x-pack/plugins/screenshotting/README.md[screenshotting] +|This plugin provides functionality to take screenshots of the Kibana pages. +It uses Chromium and Puppeteer underneath to run the browser in headless mode. + + |{kib-repo}blob/{branch}/x-pack/plugins/searchprofiler/README.md[searchprofiler] |The search profiler consumes the Profile API by sending a search API with profile: true enabled in the request body. The response contains diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts index 9fa13b013f195..06ded8d8bf526 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.test.ts @@ -65,7 +65,7 @@ it('produces the right watch and ignore list', () => { /x-pack/test/plugin_functional/plugins/resolver_test/target/**, /x-pack/test/plugin_functional/plugins/resolver_test/scripts/**, /x-pack/test/plugin_functional/plugins/resolver_test/docs/**, - /x-pack/plugins/reporting/chromium, + /x-pack/plugins/screenshotting/chromium, /x-pack/plugins/security_solution/cypress, /x-pack/plugins/apm/scripts, /x-pack/plugins/apm/ftr_e2e, diff --git a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts index e1bd431d280a4..f075dc806b6ec 100644 --- a/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts +++ b/packages/kbn-cli-dev-mode/src/get_server_watch_paths.ts @@ -56,7 +56,7 @@ export function getServerWatchPaths({ pluginPaths, pluginScanDirs }: Options) { /\.(md|sh|txt)$/, /debug\.log$/, ...pluginInternalDirsIgnore, - fromRoot('x-pack/plugins/reporting/chromium'), + fromRoot('x-pack/plugins/screenshotting/chromium'), fromRoot('x-pack/plugins/security_solution/cypress'), fromRoot('x-pack/plugins/apm/scripts'), fromRoot('x-pack/plugins/apm/ftr_e2e'), // prevents restarts for APM cypress tests diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 41c4d3bdd1b35..1de3a8a1b3976 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -117,3 +117,4 @@ pageLoadAssetSize: dataViewManagement: 5000 reporting: 57003 visTypeHeatmap: 25340 + screenshotting: 17017 diff --git a/src/dev/build/tasks/install_chromium.js b/src/dev/build/tasks/install_chromium.js index ad60019ea81a4..2bcceb33fad00 100644 --- a/src/dev/build/tasks/install_chromium.js +++ b/src/dev/build/tasks/install_chromium.js @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import { first } from 'rxjs/operators'; - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { installBrowser } from '../../../../x-pack/plugins/reporting/server/browsers/install'; +import { install } from '../../../../x-pack/plugins/screenshotting/server/utils'; export const InstallChromium = { description: 'Installing Chromium', @@ -22,13 +20,23 @@ export const InstallChromium = { // revert after https://github.com/elastic/kibana/issues/109949 if (target === 'darwin-arm64') continue; - const { binaryPath$ } = installBrowser( - log, - build.resolvePathForPlatform(platform, 'x-pack/plugins/reporting/chromium'), + const logger = { + get: log.withType.bind(log), + debug: log.debug.bind(log), + info: log.info.bind(log), + warn: log.warning.bind(log), + trace: log.verbose.bind(log), + error: log.error.bind(log), + fatal: log.error.bind(log), + log: log.write.bind(log), + }; + + await install( + logger, + build.resolvePathForPlatform(platform, 'x-pack/plugins/screenshotting/chromium'), platform.getName(), platform.getArchitecture() ); - await binaryPath$.pipe(first()).toPromise(); } }, }; diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js b/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js index 05af7c2a154a4..57467d84f1f61 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js +++ b/src/dev/code_coverage/ingest_coverage/__tests__/enumerate_patterns.test.js @@ -15,16 +15,17 @@ const log = new ToolingLog({ }); describe(`enumeratePatterns`, () => { - it(`should resolve x-pack/plugins/reporting/server/browsers/extract/unzip.ts to kibana-reporting`, () => { + it(`should resolve x-pack/plugins/screenshotting/server/browsers/extract/unzip.ts to kibana-screenshotting`, () => { const actual = enumeratePatterns(REPO_ROOT)(log)( - new Map([['x-pack/plugins/reporting', ['kibana-reporting']]]) + new Map([['x-pack/plugins/screenshotting', ['kibana-screenshotting']]]) ); - expect( - actual[0].includes( - 'x-pack/plugins/reporting/server/browsers/extract/unzip.ts kibana-reporting' - ) - ).toBe(true); + expect(actual).toHaveProperty( + '0', + expect.arrayContaining([ + 'x-pack/plugins/screenshotting/server/browsers/extract/unzip.ts kibana-screenshotting', + ]) + ); }); it(`should resolve src/plugins/charts/common/static/color_maps/color_maps.ts to kibana-app`, () => { const actual = enumeratePatterns(REPO_ROOT)(log)( diff --git a/x-pack/.gitignore b/x-pack/.gitignore index 9a02a9e552b40..0e0e9aba84467 100644 --- a/x-pack/.gitignore +++ b/x-pack/.gitignore @@ -6,7 +6,7 @@ /test/functional/apps/**/reports/session /test/reporting/configs/failure_debug/ /plugins/reporting/.chromium/ -/plugins/reporting/chromium/ +/plugins/screenshotting/chromium/ /plugins/reporting/.phantom/ /.aws-config.json /.env diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index b51363f1b7006..aac29086fe53d 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -46,6 +46,7 @@ "xpack.reporting": ["plugins/reporting"], "xpack.rollupJobs": ["plugins/rollup"], "xpack.runtimeFields": "plugins/runtime_fields", + "xpack.screenshotting": "plugins/screenshotting", "xpack.searchProfiler": "plugins/searchprofiler", "xpack.security": "plugins/security", "xpack.server": "legacy/server", diff --git a/x-pack/examples/reporting_example/kibana.json b/x-pack/examples/reporting_example/kibana.json index 716c6ea29c2a0..94780f1df0b36 100644 --- a/x-pack/examples/reporting_example/kibana.json +++ b/x-pack/examples/reporting_example/kibana.json @@ -10,5 +10,6 @@ }, "description": "Example integration code for applications to feature reports.", "optionalPlugins": [], - "requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode", "share"] + "requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode", "share"], + "requiredBundles": ["screenshotting"] } diff --git a/x-pack/examples/reporting_example/public/containers/main.tsx b/x-pack/examples/reporting_example/public/containers/main.tsx index c6723c9839197..5f6cd816e9db3 100644 --- a/x-pack/examples/reporting_example/public/containers/main.tsx +++ b/x-pack/examples/reporting_example/public/containers/main.tsx @@ -39,7 +39,8 @@ import type { JobParamsPDFV2, JobParamsPNGV2, } from '../../../../plugins/reporting/public'; -import { constants, ReportingStart } from '../../../../plugins/reporting/public'; +import { LayoutTypes } from '../../../../plugins/screenshotting/public'; +import { ReportingStart } from '../../../../plugins/reporting/public'; import { REPORTING_EXAMPLE_LOCATOR_ID } from '../../common'; @@ -87,7 +88,7 @@ export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAp const getPDFJobParamsDefault = (): JobAppParamsPDF => { return { layout: { - id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + id: LayoutTypes.PRESERVE_LAYOUT, }, relativeUrls: ['/app/reportingExample#/intended-visualization'], objectType: 'develeloperExample', @@ -99,7 +100,7 @@ export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAp return { version: '8.0.0', layout: { - id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + id: LayoutTypes.PRESERVE_LAYOUT, }, locatorParams: [ { id: REPORTING_EXAMPLE_LOCATOR_ID, version: '0.5.0', params: { myTestState: {} } }, @@ -114,7 +115,7 @@ export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAp return { version: '8.0.0', layout: { - id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + id: LayoutTypes.PRESERVE_LAYOUT, }, locatorParams: { id: REPORTING_EXAMPLE_LOCATOR_ID, @@ -131,7 +132,7 @@ export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAp return { version: '8.0.0', layout: { - id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + id: LayoutTypes.PRESERVE_LAYOUT, }, locatorParams: { id: REPORTING_EXAMPLE_LOCATOR_ID, @@ -148,7 +149,7 @@ export const Main = ({ basename, reporting, screenshotMode }: ReportingExampleAp return { version: '8.0.0', layout: { - id: print ? constants.LAYOUT_TYPES.PRINT : constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + id: print ? LayoutTypes.PRINT : LayoutTypes.PRESERVE_LAYOUT, dimensions: { // Magic numbers based on height of components not rendered on this screen :( height: 2400, diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 65d196b6e068a..1fe37f86b037f 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -55,17 +55,6 @@ export const UI_SETTINGS_CSV_SEPARATOR = 'csv:separator'; export const UI_SETTINGS_CSV_QUOTE_VALUES = 'csv:quoteValues'; export const UI_SETTINGS_DATEFORMAT_TZ = 'dateFormat:tz'; -export const LAYOUT_TYPES = { - CANVAS: 'canvas', - PRESERVE_LAYOUT: 'preserve_layout', - PRINT: 'print', -}; - -export const DEFAULT_VIEWPORT = { - width: 1950, - height: 1200, -}; - // Export Type Definitions export const CSV_REPORT_TYPE = 'CSV'; export const CSV_JOB_TYPE = 'csv_searchsource'; diff --git a/x-pack/plugins/reporting/common/test/fixtures.ts b/x-pack/plugins/reporting/common/test/fixtures.ts index c7489d54e9504..5cc6cf274c340 100644 --- a/x-pack/plugins/reporting/common/test/fixtures.ts +++ b/x-pack/plugins/reporting/common/test/fixtures.ts @@ -11,7 +11,6 @@ import type { ReportMock } from './types'; const buildMockReport = (baseObj: ReportMock) => ({ index: '.reporting-2020.04.12', migration_version: '7.15.0', - browser_type: 'chromium', max_attempts: 1, timeout: 300000, created_by: 'elastic', diff --git a/x-pack/plugins/reporting/common/types/base.ts b/x-pack/plugins/reporting/common/types/base.ts index a44378979ac3c..234467a16921e 100644 --- a/x-pack/plugins/reporting/common/types/base.ts +++ b/x-pack/plugins/reporting/common/types/base.ts @@ -6,7 +6,7 @@ */ import type { Ensure, SerializableRecord } from '@kbn/utility-types'; -import type { LayoutParams } from './layout'; +import type { LayoutParams } from '../../../screenshotting/common'; import { LocatorParams } from './url'; export type JobId = string; diff --git a/x-pack/plugins/reporting/common/types/export_types/png.ts b/x-pack/plugins/reporting/common/types/export_types/png.ts index 3b850b5bd8b33..5afde424127a1 100644 --- a/x-pack/plugins/reporting/common/types/export_types/png.ts +++ b/x-pack/plugins/reporting/common/types/export_types/png.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { LayoutParams } from '../layout'; +import type { LayoutParams } from '../../../../screenshotting/common'; import type { BaseParams, BasePayload } from '../base'; interface BaseParamsPNG { diff --git a/x-pack/plugins/reporting/common/types/export_types/png_v2.ts b/x-pack/plugins/reporting/common/types/export_types/png_v2.ts index c937d01ce0be1..1469437fe6199 100644 --- a/x-pack/plugins/reporting/common/types/export_types/png_v2.ts +++ b/x-pack/plugins/reporting/common/types/export_types/png_v2.ts @@ -6,7 +6,7 @@ */ import type { LocatorParams } from '../url'; -import type { LayoutParams } from '../layout'; +import type { LayoutParams } from '../../../../screenshotting/common'; import type { BaseParams, BasePayload } from '../base'; // Job params: structure of incoming user request data diff --git a/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts b/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts index a424706430f2c..57e5a90595d5c 100644 --- a/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts +++ b/x-pack/plugins/reporting/common/types/export_types/printable_pdf.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { LayoutParams } from '../layout'; +import type { LayoutParams } from '../../../../screenshotting/common'; import type { BaseParams, BasePayload } from '../base'; interface BaseParamsPDF { diff --git a/x-pack/plugins/reporting/common/types/export_types/printable_pdf_v2.ts b/x-pack/plugins/reporting/common/types/export_types/printable_pdf_v2.ts index c9a7a2ce2331a..b3fbbc1653dfb 100644 --- a/x-pack/plugins/reporting/common/types/export_types/printable_pdf_v2.ts +++ b/x-pack/plugins/reporting/common/types/export_types/printable_pdf_v2.ts @@ -6,7 +6,7 @@ */ import type { LocatorParams } from '../url'; -import type { LayoutParams } from '../layout'; +import type { LayoutParams } from '../../../../screenshotting/common'; import type { BaseParams, BasePayload } from '../base'; interface BaseParamsPDFV2 { diff --git a/x-pack/plugins/reporting/common/types/index.ts b/x-pack/plugins/reporting/common/types/index.ts index 8612400e8b390..056ef81e70a0a 100644 --- a/x-pack/plugins/reporting/common/types/index.ts +++ b/x-pack/plugins/reporting/common/types/index.ts @@ -5,11 +5,9 @@ * 2.0. */ -import type { Size, LayoutParams } from './layout'; import type { JobId, BaseParams, BaseParamsV2, BasePayload, BasePayloadV2 } from './base'; export type { JobId, BaseParams, BaseParamsV2, BasePayload, BasePayloadV2 }; -export type { Size, LayoutParams }; export type { DownloadReportFn, IlmPolicyMigrationStatus, @@ -20,20 +18,6 @@ export type { } from './url'; export * from './export_types'; -export interface PageSizeParams { - pageMarginTop: number; - pageMarginBottom: number; - pageMarginWidth: number; - tableBorderWidth: number; - headingHeight: number; - subheadingHeight: number; -} - -export interface PdfImageSize { - width: number; - height?: number; -} - export interface ReportDocumentHead { _id: string; _index: string; @@ -83,7 +67,6 @@ export interface ReportSource { */ kibana_name?: string; // for troubleshooting kibana_id?: string; // for troubleshooting - browser_type?: string; // no longer used since chromium is the only option (used to allow phantomjs) timeout?: number; // for troubleshooting: the actual comparison uses the config setting xpack.reporting.queue.timeout max_attempts?: number; // for troubleshooting: the actual comparison uses the config setting xpack.reporting.capture.maxAttempts started_at?: string; // timestamp in UTC diff --git a/x-pack/plugins/reporting/common/types/layout.ts b/x-pack/plugins/reporting/common/types/layout.ts deleted file mode 100644 index b22d6b59d0873..0000000000000 --- a/x-pack/plugins/reporting/common/types/layout.ts +++ /dev/null @@ -1,24 +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 type { Ensure, SerializableRecord } from '@kbn/utility-types'; - -export type Size = Ensure< - { - width: number; - height: number; - }, - SerializableRecord ->; - -export type LayoutParams = Ensure< - { - id: string; - dimensions?: Size; - }, - SerializableRecord ->; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index 4bddfae96756d..123c23e5e1c29 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -18,6 +18,7 @@ "uiActions", "taskManager", "embeddable", + "screenshotting", "screenshotMode", "share", "features" diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index 8e2db6a9d998e..d24695b1041c7 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -51,7 +51,6 @@ export class Job { public timeout: ReportSource['timeout']; public kibana_name: ReportSource['kibana_name']; public kibana_id: ReportSource['kibana_id']; - public browser_type: ReportSource['browser_type']; public size?: ReportOutput['size']; public content_type?: TaskRunResult['content_type']; @@ -80,7 +79,6 @@ export class Job { this.timeout = report.timeout; this.kibana_name = report.kibana_name; this.kibana_id = report.kibana_id; - this.browser_type = report.browser_type; this.browserTimezone = report.payload.browserTimezone; this.size = report.output?.size; this.content_type = report.output?.content_type; diff --git a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx index 25199c4abaa68..00ce9069d81ce 100644 --- a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx +++ b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx @@ -141,12 +141,6 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { }), description: info.layout?.id || UNKNOWN, }, - { - title: i18n.translate('xpack.reporting.listing.infoPanel.browserTypeInfo', { - defaultMessage: 'Browser type', - }), - description: info.browser_type || NA, - }, ]; const warnings = info.getWarnings(); diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index fe80ed679c8ed..ea48bb253ad9f 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -18,6 +18,7 @@ import { Plugin, PluginInitializerContext, } from 'src/core/public'; +import type { ScreenshottingSetup } from '../../screenshotting/public'; import { CONTEXT_MENU_TRIGGER } from '../../../../src/plugins/embeddable/public'; import { FeatureCatalogueCategory, @@ -73,6 +74,7 @@ export interface ReportingPublicPluginSetupDendencies { management: ManagementSetup; licensing: LicensingPluginSetup; uiActions: UiActionsSetup; + screenshotting: ScreenshottingSetup; share: SharePluginSetup; } @@ -145,6 +147,7 @@ export class ReportingPublicPlugin home, management, licensing: { license$ }, // FIXME: 'license$' is deprecated + screenshotting, share, uiActions, } = setupDeps; @@ -203,7 +206,7 @@ export class ReportingPublicPlugin id: 'reportingRedirect', mount: async (params) => { const { mountRedirectApp } = await import('./redirect'); - return mountRedirectApp({ ...params, share, apiClient }); + return mountRedirectApp({ ...params, apiClient, screenshotting, share }); }, title: 'Reporting redirect app', searchable: false, diff --git a/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx index eb34fc71cbf4e..fa658126efebc 100644 --- a/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx +++ b/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { EuiErrorBoundary } from '@elastic/eui'; import type { AppMountParameters } from 'kibana/public'; +import type { ScreenshottingSetup } from '../../../screenshotting/public'; import type { SharePluginSetup } from '../shared_imports'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -17,13 +18,25 @@ import { RedirectApp } from './redirect_app'; interface MountParams extends AppMountParameters { apiClient: ReportingAPIClient; + screenshotting: ScreenshottingSetup; share: SharePluginSetup; } -export const mountRedirectApp = ({ element, apiClient, history, share }: MountParams) => { +export const mountRedirectApp = ({ + element, + apiClient, + history, + screenshotting, + share, +}: MountParams) => { render( - + , element ); diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx index 4b271b17c5e85..9f0b3f51f2731 100644 --- a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx @@ -12,6 +12,7 @@ import { i18n } from '@kbn/i18n'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; import type { ScopedHistory } from 'src/core/public'; +import type { ScreenshottingSetup } from '../../../screenshotting/public'; import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants'; import { LocatorParams } from '../../common/types'; @@ -24,6 +25,7 @@ import './redirect_app.scss'; interface Props { apiClient: ReportingAPIClient; history: ScopedHistory; + screenshotting: ScreenshottingSetup; share: SharePluginSetup; } @@ -39,7 +41,9 @@ const i18nTexts = { ), }; -export const RedirectApp: FunctionComponent = ({ share, apiClient }) => { +type ReportingContext = Record; + +export const RedirectApp: FunctionComponent = ({ apiClient, screenshotting, share }) => { const [error, setError] = useState(); useEffect(() => { @@ -53,9 +57,8 @@ export const RedirectApp: FunctionComponent = ({ share, apiClient }) => { const result = await apiClient.getInfo(jobId as string); locatorParams = result?.locatorParams?.[0]; } else { - locatorParams = (window as unknown as Record)[ - REPORTING_REDIRECT_LOCATOR_STORE_KEY - ]; + locatorParams = + screenshotting.getContext()?.[REPORTING_REDIRECT_LOCATOR_STORE_KEY]; } if (!locatorParams) { @@ -70,7 +73,7 @@ export const RedirectApp: FunctionComponent = ({ share, apiClient }) => { throw e; } })(); - }, [share, apiClient]); + }, [apiClient, screenshotting, share]); return (
diff --git a/x-pack/plugins/reporting/public/share_context_menu/index.ts b/x-pack/plugins/reporting/public/share_context_menu/index.ts index b0d6f2e6a2b52..321a5a29281af 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/index.ts +++ b/x-pack/plugins/reporting/public/share_context_menu/index.ts @@ -8,8 +8,8 @@ import * as Rx from 'rxjs'; import type { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { CoreStart } from 'src/core/public'; +import type { LayoutParams } from '../../../screenshotting/common'; import type { LicensingPluginSetup } from '../../../licensing/public'; -import type { LayoutParams } from '../../common/types'; import type { ReportingAPIClient } from '../lib/reporting_api_client'; export interface ExportPanelShareOpts { diff --git a/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx index de3cc89b31fd0..f9e2908c0f733 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/screen_capture_panel_content.tsx @@ -8,7 +8,7 @@ import { EuiFormRow, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { Component } from 'react'; -import { LayoutParams } from '../../common/types'; +import type { LayoutParams } from '../../../screenshotting/common'; import { ReportingPanelContent, ReportingPanelProps } from './reporting_panel_content'; export interface Props extends ReportingPanelProps { @@ -103,7 +103,7 @@ export class ScreenCapturePanelContent extends Component { this.setState({ useCanvasLayout: evt.target.checked, usePrintLayout: false }); }; - private getLayout = (): Required => { + private getLayout = (): LayoutParams => { const { layout: outerLayout } = this.props.getJobParams(); let dimensions = outerLayout?.dimensions; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.test.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.test.ts deleted file mode 100644 index dae692fae8825..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.test.ts +++ /dev/null @@ -1,76 +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 puppeteer from 'puppeteer'; -import * as Rx from 'rxjs'; -import { take } from 'rxjs/operators'; -import { HeadlessChromiumDriverFactory } from '.'; -import type { ReportingCore } from '../../..'; -import { - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../../test_helpers'; - -jest.mock('puppeteer'); - -const mock = (browserDriverFactory: HeadlessChromiumDriverFactory) => { - browserDriverFactory.getBrowserLogger = jest.fn(() => new Rx.Observable()); - browserDriverFactory.getProcessLogger = jest.fn(() => new Rx.Observable()); - browserDriverFactory.getPageExit = jest.fn(() => new Rx.Observable()); - return browserDriverFactory; -}; - -describe('class HeadlessChromiumDriverFactory', () => { - let reporting: ReportingCore; - const logger = createMockLevelLogger(); - const path = 'path/to/headless_shell'; - - beforeEach(async () => { - (puppeteer as jest.Mocked).launch.mockResolvedValue({ - newPage: jest.fn().mockResolvedValue({ - target: jest.fn(() => ({ - createCDPSession: jest.fn().mockResolvedValue({ - send: jest.fn(), - }), - })), - emulateTimezone: jest.fn(), - setDefaultTimeout: jest.fn(), - }), - close: jest.fn(), - process: jest.fn(), - } as unknown as puppeteer.Browser); - - reporting = await createMockReportingCore( - createMockConfigSchema({ - capture: { - browser: { chromium: { proxy: {} } }, - timeouts: { openUrl: 50000 }, - }, - }) - ); - }); - - it('createPage returns browser driver and process exit observable', async () => { - const factory = mock(new HeadlessChromiumDriverFactory(reporting, path, logger)); - const utils = await factory.createPage({}).pipe(take(1)).toPromise(); - expect(utils).toHaveProperty('driver'); - expect(utils).toHaveProperty('exit$'); - }); - - it('createPage rejects if Puppeteer launch fails', async () => { - (puppeteer as jest.Mocked).launch.mockRejectedValue( - `Puppeteer Launch mock fail.` - ); - const factory = mock(new HeadlessChromiumDriverFactory(reporting, path, logger)); - expect(() => - factory.createPage({}).pipe(take(1)).toPromise() - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error spawning Chromium browser! Puppeteer Launch mock fail."` - ); - }); -}); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts deleted file mode 100644 index 2aef62f59985b..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ /dev/null @@ -1,268 +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 { i18n } from '@kbn/i18n'; -import { getDataPath } from '@kbn/utils'; -import del from 'del'; -import apm from 'elastic-apm-node'; -import fs from 'fs'; -import path from 'path'; -import puppeteer from 'puppeteer'; -import * as Rx from 'rxjs'; -import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; -import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { getChromiumDisconnectedError } from '../'; -import { ReportingCore } from '../../..'; -import { durationToNumber } from '../../../../common/schema_utils'; -import { CaptureConfig } from '../../../../server/types'; -import { LevelLogger } from '../../../lib'; -import { safeChildProcess } from '../../safe_child_process'; -import { HeadlessChromiumDriver } from '../driver'; -import { args } from './args'; -import { getMetrics } from './metrics'; - -type BrowserConfig = CaptureConfig['browser']['chromium']; - -export class HeadlessChromiumDriverFactory { - private binaryPath: string; - private captureConfig: CaptureConfig; - private browserConfig: BrowserConfig; - private userDataDir: string; - private getChromiumArgs: () => string[]; - private core: ReportingCore; - - constructor(core: ReportingCore, binaryPath: string, private logger: LevelLogger) { - this.core = core; - this.binaryPath = binaryPath; - const config = core.getConfig(); - this.captureConfig = config.get('capture'); - this.browserConfig = this.captureConfig.browser.chromium; - - if (this.browserConfig.disableSandbox) { - logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); - } - - this.userDataDir = fs.mkdtempSync(path.join(getDataPath(), 'chromium-')); - this.getChromiumArgs = () => - args({ - userDataDir: this.userDataDir, - disableSandbox: this.browserConfig.disableSandbox, - proxy: this.browserConfig.proxy, - }); - } - - type = 'chromium'; - - /* - * Return an observable to objects which will drive screenshot capture for a page - */ - createPage( - { browserTimezone }: { browserTimezone?: string }, - pLogger = this.logger - ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable }> { - // FIXME: 'create' is deprecated - return Rx.Observable.create(async (observer: InnerSubscriber) => { - const logger = pLogger.clone(['browser-driver']); - logger.info(`Creating browser page driver`); - - const chromiumArgs = this.getChromiumArgs(); - logger.debug(`Chromium launch args set to: ${chromiumArgs}`); - - let browser: puppeteer.Browser | null = null; - - try { - browser = await puppeteer.launch({ - pipe: !this.browserConfig.inspect, - userDataDir: this.userDataDir, - executablePath: this.binaryPath, - ignoreHTTPSErrors: true, - handleSIGHUP: false, - args: chromiumArgs, - env: { - TZ: browserTimezone, - }, - }); - } catch (err) { - observer.error(new Error(`Error spawning Chromium browser! ${err}`)); - return; - } - - const page = await browser.newPage(); - const devTools = await page.target().createCDPSession(); - - await devTools.send('Performance.enable', { timeDomain: 'timeTicks' }); - const startMetrics = await devTools.send('Performance.getMetrics'); - - // Log version info for debugging / maintenance - const versionInfo = await devTools.send('Browser.getVersion'); - logger.debug(`Browser version: ${JSON.stringify(versionInfo)}`); - - await page.emulateTimezone(browserTimezone); - - // Set the default timeout for all navigation methods to the openUrl timeout - // All waitFor methods have their own timeout config passed in to them - page.setDefaultTimeout(durationToNumber(this.captureConfig.timeouts.openUrl)); - - logger.debug(`Browser page driver created`); - - const childProcess = { - async kill() { - try { - if (devTools && startMetrics) { - const endMetrics = await devTools.send('Performance.getMetrics'); - const { cpu, cpuInPercentage, memory, memoryInMegabytes } = getMetrics( - startMetrics, - endMetrics - ); - - apm.currentTransaction?.setLabel('cpu', cpu, false); - apm.currentTransaction?.setLabel('memory', memory, false); - logger.debug( - `Chromium consumed CPU ${cpuInPercentage}% Memory ${memoryInMegabytes}MB` - ); - } - } catch (error) { - logger.error(error); - } - - try { - await browser?.close(); - } catch (err) { - // do not throw - logger.error(err); - } - }, - }; - const { terminate$ } = safeChildProcess(logger, childProcess); - - // this is adding unsubscribe logic to our observer - // so that if our observer unsubscribes, we terminate our child-process - observer.add(() => { - logger.debug(`The browser process observer has unsubscribed. Closing the browser...`); - childProcess.kill(); // ignore async - }); - - // make the observer subscribe to terminate$ - observer.add( - terminate$ - .pipe( - tap((signal) => { - logger.debug(`Termination signal received: ${signal}`); - }), - ignoreElements() - ) - .subscribe(observer) - ); - - // taps the browser log streams and combine them to Kibana logs - this.getBrowserLogger(page, logger).subscribe(); - this.getProcessLogger(browser, logger).subscribe(); - - // HeadlessChromiumDriver: object to "drive" a browser page - const driver = new HeadlessChromiumDriver(this.core, page, { - inspect: !!this.browserConfig.inspect, - networkPolicy: this.captureConfig.networkPolicy, - }); - - // Rx.Observable: stream to interrupt page capture - const exit$ = this.getPageExit(browser, page); - - observer.next({ driver, exit$ }); - - // unsubscribe logic makes a best-effort attempt to delete the user data directory used by chromium - observer.add(() => { - const userDataDir = this.userDataDir; - logger.debug(`deleting chromium user data directory at [${userDataDir}]`); - // the unsubscribe function isn't `async` so we're going to make our best effort at - // deleting the userDataDir and if it fails log an error. - del(userDataDir, { force: true }).catch((error) => { - logger.error(`error deleting user data directory at [${userDataDir}]!`); - logger.error(error); - }); - }); - }); - } - - getBrowserLogger(page: puppeteer.Page, logger: LevelLogger): Rx.Observable { - const consoleMessages$ = Rx.fromEvent(page, 'console').pipe( - map((line) => { - const formatLine = () => `{ text: "${line.text()?.trim()}", url: ${line.location()?.url} }`; - - if (line.type() === 'error') { - logger.error(`Error in browser console: ${formatLine()}`, ['headless-browser-console']); - } else { - logger.debug(`Message in browser console: ${formatLine()}`, [ - `headless-browser-console:${line.type()}`, - ]); - } - }) - ); - - const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( - map((err) => { - logger.warning( - i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { - defaultMessage: `Reporting encountered an uncaught error on the page that will be ignored: {err}`, - values: { err: err.toString() }, - }) - ); - }) - ); - - const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( - map((req) => { - const failure = req.failure && req.failure(); - if (failure) { - logger.warning( - `Request to [${req.url()}] failed! [${failure.errorText}]. This error will be ignored.` - ); - } - }) - ); - - return Rx.merge(consoleMessages$, uncaughtExceptionPageError$, pageRequestFailed$); - } - - getProcessLogger(browser: puppeteer.Browser, logger: LevelLogger): Rx.Observable { - const childProcess = browser.process(); - // NOTE: The browser driver can not observe stdout and stderr of the child process - // Puppeteer doesn't give a handle to the original ChildProcess object - // See https://github.com/GoogleChrome/puppeteer/issues/1292#issuecomment-521470627 - - if (childProcess == null) { - throw new TypeError('childProcess is null or undefined!'); - } - - // just log closing of the process - const processClose$ = Rx.fromEvent(childProcess, 'close').pipe( - tap(() => { - logger.debug('child process closed', ['headless-browser-process']); - }) - ); - - return processClose$; // ideally, this would also merge with observers for stdout and stderr - } - - getPageExit(browser: puppeteer.Browser, page: puppeteer.Page) { - const pageError$ = Rx.fromEvent(page, 'error').pipe( - mergeMap((err) => { - return Rx.throwError( - i18n.translate('xpack.reporting.browsers.chromium.errorDetected', { - defaultMessage: 'Reporting encountered an error: {err}', - values: { err: err.toString() }, - }) - ); - }) - ); - - const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe( - mergeMap(() => Rx.throwError(getChromiumDisconnectedError())) - ); - - return Rx.merge(pageError$, browserDisconnect$); - } -} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts deleted file mode 100644 index 1a739488bf6ed..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/start_logs.ts +++ /dev/null @@ -1,144 +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 { i18n } from '@kbn/i18n'; -import { spawn } from 'child_process'; -import del from 'del'; -import { mkdtempSync } from 'fs'; -import { uniq } from 'lodash'; -import os from 'os'; -import { join } from 'path'; -import { createInterface } from 'readline'; -import { getDataPath } from '@kbn/utils'; -import { fromEvent, merge, of, timer } from 'rxjs'; -import { catchError, map, reduce, takeUntil, tap } from 'rxjs/operators'; -import { ReportingCore } from '../../../'; -import { LevelLogger } from '../../../lib'; -import { ChromiumArchivePaths } from '../paths'; -import { args } from './args'; - -const paths = new ChromiumArchivePaths(); -const browserLaunchTimeToWait = 5 * 1000; - -// Default args used by pptr -// https://github.com/puppeteer/puppeteer/blob/13ea347/src/node/Launcher.ts#L168 -const defaultArgs = [ - '--disable-background-networking', - '--enable-features=NetworkService,NetworkServiceInProcess', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-breakpad', - '--disable-client-side-phishing-detection', - '--disable-component-extensions-with-background-pages', - '--disable-default-apps', - '--disable-dev-shm-usage', - '--disable-extensions', - '--disable-features=TranslateUI', - '--disable-hang-monitor', - '--disable-ipc-flooding-protection', - '--disable-popup-blocking', - '--disable-prompt-on-repost', - '--disable-renderer-backgrounding', - '--disable-sync', - '--force-color-profile=srgb', - '--metrics-recording-only', - '--no-first-run', - '--enable-automation', - '--password-store=basic', - '--use-mock-keychain', - '--remote-debugging-port=0', - '--headless', -]; - -export const browserStartLogs = ( - core: ReportingCore, - logger: LevelLogger, - overrideFlags: string[] = [] -) => { - const config = core.getConfig(); - const proxy = config.get('capture', 'browser', 'chromium', 'proxy'); - const disableSandbox = config.get('capture', 'browser', 'chromium', 'disableSandbox'); - const userDataDir = mkdtempSync(join(getDataPath(), 'chromium-')); - - const platform = process.platform; - const architecture = os.arch(); - const pkg = paths.find(platform, architecture); - if (!pkg) { - throw new Error(`Unsupported platform: ${platform}-${architecture}`); - } - const binaryPath = paths.getBinaryPath(pkg); - - const kbnArgs = args({ - userDataDir, - disableSandbox, - proxy, - }); - const finalArgs = uniq([...defaultArgs, ...kbnArgs, ...overrideFlags]); - - // On non-windows platforms, `detached: true` makes child process a - // leader of a new process group, making it possible to kill child - // process tree with `.kill(-pid)` command. @see - // https://nodejs.org/api/child_process.html#child_process_options_detached - const browserProcess = spawn(binaryPath, finalArgs, { - detached: process.platform !== 'win32', - }); - - const rl = createInterface({ input: browserProcess.stderr }); - - const exit$ = fromEvent(browserProcess, 'exit').pipe( - map((code) => { - logger.error(`Browser exited abnormally, received code: ${code}`); - return i18n.translate('xpack.reporting.diagnostic.browserCrashed', { - defaultMessage: `Browser exited abnormally during startup`, - }); - }) - ); - - const error$ = fromEvent(browserProcess, 'error').pipe( - map((err) => { - logger.error(`Browser process threw an error on startup`); - logger.error(err as string | Error); - return i18n.translate('xpack.reporting.diagnostic.browserErrored', { - defaultMessage: `Browser process threw an error on startup`, - }); - }) - ); - - const browserProcessLogger = logger.clone(['chromium-stderr']); - const log$ = fromEvent(rl, 'line').pipe( - tap((message: unknown) => { - if (typeof message === 'string') { - browserProcessLogger.info(message); - } - }) - ); - - // Collect all events (exit, error and on log-lines), but let chromium keep spitting out - // logs as sometimes it's "bind" successfully for remote connections, but later emit - // a log indicative of an issue (for example, no default font found). - return merge(exit$, error$, log$).pipe( - takeUntil(timer(browserLaunchTimeToWait)), - reduce((acc, curr) => `${acc}${curr}\n`, ''), - tap(() => { - if (browserProcess && browserProcess.pid && !browserProcess.killed) { - browserProcess.kill('SIGKILL'); - logger.info(`Successfully sent 'SIGKILL' to browser process (PID: ${browserProcess.pid})`); - } - browserProcess.removeAllListeners(); - rl.removeAllListeners(); - rl.close(); - del(userDataDir, { force: true }).catch((error) => { - logger.error(`Error deleting user data directory at [${userDataDir}]!`); - logger.error(error); - }); - }), - catchError((error) => { - logger.error(error); - return of(error); - }) - ); -}; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/index.ts deleted file mode 100644 index e0d043f821ab4..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/index.ts +++ /dev/null @@ -1,36 +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 { i18n } from '@kbn/i18n'; -import { BrowserDownload } from '../'; -import { ReportingCore } from '../../../server'; -import { LevelLogger } from '../../lib'; -import { HeadlessChromiumDriverFactory } from './driver_factory'; -import { ChromiumArchivePaths } from './paths'; - -export const chromium: BrowserDownload = { - paths: new ChromiumArchivePaths(), - createDriverFactory: (core: ReportingCore, binaryPath: string, logger: LevelLogger) => - new HeadlessChromiumDriverFactory(core, binaryPath, logger), -}; - -export const getChromiumDisconnectedError = () => - new Error( - i18n.translate('xpack.reporting.screencapture.browserWasClosed', { - defaultMessage: 'Browser was closed unexpectedly! Check the server logs for more info.', - }) - ); - -export const getDisallowedOutgoingUrlError = (interceptedUrl: string) => - new Error( - i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { - defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}". Failing the request and closing the browser.`, - values: { interceptedUrl }, - }) - ); - -export { ChromiumArchivePaths }; diff --git a/x-pack/plugins/reporting/server/browsers/download/download.test.ts b/x-pack/plugins/reporting/server/browsers/download/download.test.ts deleted file mode 100644 index 688a746826e54..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/download/download.test.ts +++ /dev/null @@ -1,72 +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 { createHash } from 'crypto'; -import del from 'del'; -import { readFileSync } from 'fs'; -import { resolve as resolvePath } from 'path'; -import { Readable } from 'stream'; -import { LevelLogger } from '../../lib'; -import { download } from './download'; - -const TEMP_DIR = resolvePath(__dirname, '__tmp__'); -const TEMP_FILE = resolvePath(TEMP_DIR, 'foo/bar/download'); - -class ReadableOf extends Readable { - constructor(private readonly responseBody: string) { - super(); - } - - _read() { - this.push(this.responseBody); - this.push(null); - } -} - -jest.mock('axios'); -const request: jest.Mock = jest.requireMock('axios').request; - -const mockLogger = { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), -} as unknown as LevelLogger; - -test('downloads the url to the path', async () => { - const BODY = 'abdcefg'; - request.mockImplementationOnce(async () => { - return { - data: new ReadableOf(BODY), - }; - }); - - await download('url', TEMP_FILE, mockLogger); - expect(readFileSync(TEMP_FILE, 'utf8')).toEqual(BODY); -}); - -test('returns the md5 hex hash of the http body', async () => { - const BODY = 'foobar'; - const HASH = createHash('md5').update(BODY).digest('hex'); - request.mockImplementationOnce(async () => { - return { - data: new ReadableOf(BODY), - }; - }); - - const returned = await download('url', TEMP_FILE, mockLogger); - expect(returned).toEqual(HASH); -}); - -test('throws if request emits an error', async () => { - request.mockImplementationOnce(async () => { - throw new Error('foo'); - }); - - return expect(download('url', TEMP_FILE, mockLogger)).rejects.toThrow('foo'); -}); - -afterEach(async () => await del(TEMP_DIR)); diff --git a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.test.ts b/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.test.ts deleted file mode 100644 index 9db128c019ac0..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.test.ts +++ /dev/null @@ -1,120 +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 path from 'path'; -import mockFs from 'mock-fs'; -import { existsSync, readdirSync } from 'fs'; -import { chromium } from '../chromium'; -import { download } from './download'; -import { md5 } from './checksum'; -import { ensureBrowserDownloaded } from './ensure_downloaded'; -import { LevelLogger } from '../../lib'; - -jest.mock('./checksum'); -jest.mock('./download'); - -// https://github.com/elastic/kibana/issues/115881 -describe.skip('ensureBrowserDownloaded', () => { - let logger: jest.Mocked; - - beforeEach(() => { - logger = { - debug: jest.fn(), - error: jest.fn(), - warning: jest.fn(), - } as unknown as typeof logger; - - (md5 as jest.MockedFunction).mockImplementation( - async (packagePath) => - chromium.paths.packages.find( - (packageInfo) => chromium.paths.resolvePath(packageInfo) === packagePath - )?.archiveChecksum ?? 'some-md5' - ); - - (download as jest.MockedFunction).mockImplementation( - async (_url, packagePath) => - chromium.paths.packages.find( - (packageInfo) => chromium.paths.resolvePath(packageInfo) === packagePath - )?.archiveChecksum ?? 'some-md5' - ); - - mockFs(); - }); - - afterEach(() => { - mockFs.restore(); - jest.resetAllMocks(); - }); - - it('should remove unexpected files', async () => { - const unexpectedPath1 = `${chromium.paths.archivesPath}/unexpected1`; - const unexpectedPath2 = `${chromium.paths.archivesPath}/unexpected2`; - - mockFs({ - [unexpectedPath1]: 'test', - [unexpectedPath2]: 'test', - }); - - await ensureBrowserDownloaded(logger); - - expect(existsSync(unexpectedPath1)).toBe(false); - expect(existsSync(unexpectedPath2)).toBe(false); - }); - - it('should reject when download fails', async () => { - (download as jest.MockedFunction).mockRejectedValueOnce( - new Error('some error') - ); - - await expect(ensureBrowserDownloaded(logger)).rejects.toBeInstanceOf(Error); - }); - - it('should reject when downloaded md5 hash is different', async () => { - (download as jest.MockedFunction).mockResolvedValue('random-md5'); - - await expect(ensureBrowserDownloaded(logger)).rejects.toBeInstanceOf(Error); - }); - - describe('when archives are already present', () => { - beforeEach(() => { - mockFs( - Object.fromEntries( - chromium.paths.packages.map((packageInfo) => [ - chromium.paths.resolvePath(packageInfo), - '', - ]) - ) - ); - }); - - it('should not download again', async () => { - await ensureBrowserDownloaded(logger); - - expect(download).not.toHaveBeenCalled(); - const paths = [ - readdirSync(path.resolve(chromium.paths.archivesPath + '/x64')), - readdirSync(path.resolve(chromium.paths.archivesPath + '/arm64')), - ]; - - expect(paths).toEqual([ - expect.arrayContaining([ - 'chrome-win.zip', - 'chromium-70f5d88-linux_x64.zip', - 'chromium-d163fd7-darwin_x64.zip', - ]), - expect.arrayContaining(['chromium-70f5d88-linux_arm64.zip']), - ]); - }); - - it('should download again if md5 hash different', async () => { - (md5 as jest.MockedFunction).mockResolvedValueOnce('random-md5'); - await ensureBrowserDownloaded(logger); - - expect(download).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts deleted file mode 100644 index 2766b404f1dd1..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ /dev/null @@ -1,101 +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 { existsSync } from 'fs'; -import del from 'del'; -import { BrowserDownload, chromium } from '../'; -import { GenericLevelLogger } from '../../lib/level_logger'; -import { md5 } from './checksum'; -import { download } from './download'; - -/** - * Check for the downloaded archive of each requested browser type and - * download them if they are missing or their checksum is invalid - */ -export async function ensureBrowserDownloaded(logger: GenericLevelLogger) { - await ensureDownloaded([chromium], logger); -} - -/** - * Clears the unexpected files in the browsers archivesPath - * and ensures that all packages/archives are downloaded and - * that their checksums match the declared value - */ -async function ensureDownloaded(browsers: BrowserDownload[], logger: GenericLevelLogger) { - await Promise.all( - browsers.map(async ({ paths: pSet }) => { - const removedFiles = await del(`${pSet.archivesPath}/**/*`, { - force: true, - onlyFiles: true, - ignore: pSet.getAllArchiveFilenames(), - }); - - removedFiles.forEach((path) => { - logger.warning(`Deleting unexpected file ${path}`); - }); - - const invalidChecksums: string[] = []; - await Promise.all( - pSet.packages.map(async (p) => { - const { archiveFilename, archiveChecksum } = p; - if (archiveFilename && archiveChecksum) { - const path = pSet.resolvePath(p); - const pathExists = existsSync(path); - - let foundChecksum: string; - try { - foundChecksum = await md5(path).catch(); - } catch { - foundChecksum = 'MISSING'; - } - - if (pathExists && foundChecksum === archiveChecksum) { - logger.debug(`Browser archive for ${p.platform}/${p.architecture} found in ${path} `); - return; - } - - if (!pathExists) { - logger.warning( - `Browser archive for ${p.platform}/${p.architecture} not found in ${path}.` - ); - } - if (foundChecksum !== archiveChecksum) { - logger.warning( - `Browser archive checksum for ${p.platform}/${p.architecture} ` + - `is ${foundChecksum} but ${archiveChecksum} was expected.` - ); - } - - const url = pSet.getDownloadUrl(p); - try { - const downloadedChecksum = await download(url, path, logger); - if (downloadedChecksum !== archiveChecksum) { - logger.warning( - `Invalid checksum for ${p.platform}/${p.architecture}: ` + - `expected ${archiveChecksum} got ${downloadedChecksum}` - ); - invalidChecksums.push(`${url} => ${path}`); - } - } catch (err) { - throw new Error(`Failed to download ${url}: ${err}`); - } - } - }) - ); - - if (invalidChecksums.length) { - const err = new Error( - `Error downloading browsers, checksums incorrect for:\n - ${invalidChecksums.join( - '\n - ' - )}` - ); - logger.error(err); - throw err; - } - }) - ); -} diff --git a/x-pack/plugins/reporting/server/browsers/download/index.ts b/x-pack/plugins/reporting/server/browsers/download/index.ts deleted file mode 100644 index d54a7a1f30cc7..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/download/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { ensureBrowserDownloaded } from './ensure_downloaded'; diff --git a/x-pack/plugins/reporting/server/browsers/index.ts b/x-pack/plugins/reporting/server/browsers/index.ts deleted file mode 100644 index be5c85a6e9581..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/index.ts +++ /dev/null @@ -1,35 +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 { first } from 'rxjs/operators'; -import { ReportingCore } from '../'; -import { LevelLogger } from '../lib'; -import { chromium, ChromiumArchivePaths } from './chromium'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; -import { installBrowser } from './install'; - -export { chromium } from './chromium'; -export { HeadlessChromiumDriver } from './chromium/driver'; -export { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; - -type CreateDriverFactory = ( - core: ReportingCore, - binaryPath: string, - logger: LevelLogger -) => HeadlessChromiumDriverFactory; - -export interface BrowserDownload { - createDriverFactory: CreateDriverFactory; - paths: ChromiumArchivePaths; -} - -export const initializeBrowserDriverFactory = async (core: ReportingCore, logger: LevelLogger) => { - const chromiumLogger = logger.clone(['chromium']); - const { binaryPath$ } = installBrowser(chromiumLogger); - const binaryPath = await binaryPath$.pipe(first()).toPromise(); - return chromium.createDriverFactory(core, binaryPath, chromiumLogger); -}; diff --git a/x-pack/plugins/reporting/server/browsers/install.ts b/x-pack/plugins/reporting/server/browsers/install.ts deleted file mode 100644 index 0441bbcfb5306..0000000000000 --- a/x-pack/plugins/reporting/server/browsers/install.ts +++ /dev/null @@ -1,72 +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 del from 'del'; -import os from 'os'; -import path from 'path'; -import * as Rx from 'rxjs'; -import { GenericLevelLogger } from '../lib/level_logger'; -import { ChromiumArchivePaths } from './chromium'; -import { ensureBrowserDownloaded } from './download'; -import { md5 } from './download/checksum'; -import { extract } from './extract'; - -/** - * "install" a browser by type into installs path by extracting the downloaded - * archive. If there is an error extracting the archive an `ExtractError` is thrown - */ -export function installBrowser( - logger: GenericLevelLogger, - chromiumPath: string = path.resolve(__dirname, '../../chromium'), - platform: string = process.platform, - architecture: string = os.arch() -): { binaryPath$: Rx.Subject } { - const binaryPath$ = new Rx.Subject(); - - const paths = new ChromiumArchivePaths(); - const pkg = paths.find(platform, architecture); - - if (!pkg) { - throw new Error(`Unsupported platform: ${platform}-${architecture}`); - } - - const backgroundInstall = async () => { - const binaryPath = paths.getBinaryPath(pkg); - const binaryChecksum = await md5(binaryPath).catch(() => ''); - - if (binaryChecksum !== pkg.binaryChecksum) { - logger.warning( - `Found browser binary checksum for ${pkg.platform}/${pkg.architecture} ` + - `is ${binaryChecksum} but ${pkg.binaryChecksum} was expected. Re-installing...` - ); - try { - await del(chromiumPath); - } catch (err) { - logger.error(err); - } - - try { - await ensureBrowserDownloaded(logger); - const archive = path.join(paths.archivesPath, pkg.architecture, pkg.archiveFilename); - logger.info(`Extracting [${archive}] to [${chromiumPath}]`); - await extract(archive, chromiumPath); - } catch (err) { - logger.error(err); - } - } - - logger.info(`Browser executable: ${binaryPath}`); - - binaryPath$.next(binaryPath); // subscribers wait for download and extract to complete - }; - - backgroundInstall(); - - return { - binaryPath$, - }; -} diff --git a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap index a384550f18462..65f3c45fb2255 100644 --- a/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap +++ b/x-pack/plugins/reporting/server/config/__snapshots__/schema.test.ts.snap @@ -3,52 +3,8 @@ exports[`Reporting Config Schema context {"dev":false,"dist":false} produces correct config 1`] = ` Object { "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, "loadDelay": "PT3S", "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "host": undefined, - "protocol": "http:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "https:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "ws:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "wss:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "data:", - }, - Object { - "allow": false, - "host": undefined, - "protocol": undefined, - }, - ], - }, "timeouts": Object { "openUrl": "PT1M", "renderComplete": "PT30S", @@ -101,53 +57,8 @@ Object { exports[`Reporting Config Schema context {"dev":false,"dist":true} produces correct config 1`] = ` Object { "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "inspect": false, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, "loadDelay": "PT3S", "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "host": undefined, - "protocol": "http:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "https:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "ws:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "wss:", - }, - Object { - "allow": true, - "host": undefined, - "protocol": "data:", - }, - Object { - "allow": false, - "host": undefined, - "protocol": undefined, - }, - ], - }, "timeouts": Object { "openUrl": "PT1M", "renderComplete": "PT30S", diff --git a/x-pack/plugins/reporting/server/config/create_config.test.ts b/x-pack/plugins/reporting/server/config/create_config.test.ts index 3c5ecdc1dab0b..fd8180bd46a05 100644 --- a/x-pack/plugins/reporting/server/config/create_config.test.ts +++ b/x-pack/plugins/reporting/server/config/create_config.test.ts @@ -77,13 +77,6 @@ describe('Reporting server createConfig$', () => { expect(result).toMatchInlineSnapshot(` Object { - "capture": Object { - "browser": Object { - "chromium": Object { - "disableSandbox": true, - }, - }, - }, "csv": Object {}, "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", "index": ".reporting", @@ -106,47 +99,6 @@ describe('Reporting server createConfig$', () => { expect(mockLogger.warn).not.toHaveBeenCalled(); }); - it('uses user-provided disableSandbox: false', async () => { - mockInitContext = coreMock.createPluginInitializerContext( - createMockConfigSchema({ - encryptionKey: '888888888888888888888888888888888', - capture: { browser: { chromium: { disableSandbox: false } } }, - }) - ); - const mockConfig$ = createMockConfig(mockInitContext); - const result = await createConfig$(mockCoreSetup, mockConfig$, mockLogger).toPromise(); - - expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: false }); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - it('uses user-provided disableSandbox: true', async () => { - mockInitContext = coreMock.createPluginInitializerContext( - createMockConfigSchema({ - encryptionKey: '888888888888888888888888888888888', - capture: { browser: { chromium: { disableSandbox: true } } }, - }) - ); - const mockConfig$ = createMockConfig(mockInitContext); - const result = await createConfig$(mockCoreSetup, mockConfig$, mockLogger).toPromise(); - - expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: true }); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - - it('provides a default for disableSandbox', async () => { - mockInitContext = coreMock.createPluginInitializerContext( - createMockConfigSchema({ - encryptionKey: '888888888888888888888888888888888', - }) - ); - const mockConfig$ = createMockConfig(mockInitContext); - const result = await createConfig$(mockCoreSetup, mockConfig$, mockLogger).toPromise(); - - expect(result.capture.browser.chromium).toMatchObject({ disableSandbox: expect.any(Boolean) }); - expect(mockLogger.warn).not.toHaveBeenCalled(); - }); - it.each(['0', '0.0', '0.0.0', '0.0.0.0', '0000:0000:0000:0000:0000:0000:0000:0000', '::'])( `apply failover logic when hostname is given as "%s"`, async (hostname) => { diff --git a/x-pack/plugins/reporting/server/config/create_config.ts b/x-pack/plugins/reporting/server/config/create_config.ts index 5de54a43582ab..2ac225ec4576a 100644 --- a/x-pack/plugins/reporting/server/config/create_config.ts +++ b/x-pack/plugins/reporting/server/config/create_config.ts @@ -7,17 +7,15 @@ import crypto from 'crypto'; import ipaddr from 'ipaddr.js'; -import { sum, upperFirst } from 'lodash'; +import { sum } from 'lodash'; import { Observable } from 'rxjs'; -import { map, mergeMap } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { CoreSetup } from 'src/core/server'; import { LevelLogger } from '../lib'; -import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; import { ReportingConfigType } from './schema'; /* * Set up dynamic config defaults - * - xpack.capture.browser.chromium.disableSandbox * - xpack.kibanaServer * - xpack.reporting.encryptionKey */ @@ -71,41 +69,6 @@ export function createConfig$( protocol: kibanaServerProtocol, }, }; - }), - mergeMap(async (config) => { - if (config.capture.browser.chromium.disableSandbox != null) { - // disableSandbox was set by user - return { ...config }; - } - - // disableSandbox was not set by user, apply default for OS - const { os, disableSandbox } = await getDefaultChromiumSandboxDisabled(); - const osName = [os.os, os.dist, os.release].filter(Boolean).map(upperFirst).join(' '); - - logger.debug(`Running on OS: '{osName}'`); - - if (disableSandbox === true) { - logger.warn( - `Chromium sandbox provides an additional layer of protection, but is not supported for ${osName} OS.` + - ` Automatically setting 'xpack.reporting.capture.browser.chromium.disableSandbox: true'.` - ); - } else { - logger.info( - `Chromium sandbox provides an additional layer of protection, and is supported for ${osName} OS.` + - ` Automatically enabling Chromium sandbox.` - ); - } - - return { - ...config, - capture: { - ...config.capture, - browser: { - ...config.capture.browser, - chromium: { ...config.capture.browser.chromium, disableSandbox }, - }, - }, - }; }) ); } diff --git a/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts b/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts deleted file mode 100644 index 6ca75b7a1701b..0000000000000 --- a/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.test.ts +++ /dev/null @@ -1,39 +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. - */ - -jest.mock('getos', () => { - return jest.fn(); -}); - -import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; -import getos from 'getos'; - -interface TestObject { - os: string; - dist?: string; - release?: string; -} - -function defaultTest(os: TestObject, expectedDefault: boolean) { - test(`${expectedDefault ? 'disabled' : 'enabled'} on ${JSON.stringify(os)}`, async () => { - (getos as jest.Mock).mockImplementation((cb) => cb(null, os)); - const actualDefault = await getDefaultChromiumSandboxDisabled(); - expect(actualDefault.disableSandbox).toBe(expectedDefault); - }); -} - -defaultTest({ os: 'win32' }, false); -defaultTest({ os: 'darwin' }, false); -defaultTest({ os: 'linux', dist: 'Centos', release: '7.0' }, true); -defaultTest({ os: 'linux', dist: 'Red Hat Linux', release: '7.0' }, true); -defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '14.04' }, false); -defaultTest({ os: 'linux', dist: 'Ubuntu Linux', release: '16.04' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '11' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '12' }, false); -defaultTest({ os: 'linux', dist: 'SUSE Linux', release: '42.0' }, false); -defaultTest({ os: 'linux', dist: 'Debian', release: '8' }, true); -defaultTest({ os: 'linux', dist: 'Debian', release: '9' }, true); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index 711e930484e01..963895d1fe583 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -19,6 +19,7 @@ export const config: PluginConfigDescriptor = { schema: ConfigSchema, deprecations: ({ unused }) => [ unused('capture.browser.chromium.maxScreenshotDimension', { level: 'warning' }), // unused since 7.8 + unused('capture.browser.type'), unused('poll.jobCompletionNotifier.intervalErrorMultiplier', { level: 'warning' }), // unused since 7.10 unused('poll.jobsRefresh.intervalErrorMultiplier', { level: 'warning' }), // unused since 7.10 unused('capture.viewport', { level: 'warning' }), // deprecated as unused since 7.16 @@ -72,7 +73,6 @@ export const config: PluginConfigDescriptor = { capture: { maxAttempts: true, timeouts: { openUrl: true, renderComplete: true, waitForElements: true }, - networkPolicy: false, // show as [redacted] zoom: true, }, csv: { maxSizeBytes: true, scroll: { size: true, duration: true } }, diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index c49490be87a15..3af7a4e5cfe4c 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -55,47 +55,12 @@ describe('Reporting Config Schema', () => { ).toBe('qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'); expect(ConfigSchema.validate({ encryptionKey: 'weaksauce' }).encryptionKey).toBe('weaksauce'); - - // disableSandbox - expect( - ConfigSchema.validate({ capture: { browser: { chromium: { disableSandbox: true } } } }) - .capture.browser.chromium - ).toMatchObject({ disableSandbox: true, proxy: { enabled: false } }); - // kibanaServer expect( ConfigSchema.validate({ kibanaServer: { hostname: 'Frodo' } }).kibanaServer ).toMatchObject({ hostname: 'Frodo' }); }); - it('allows setting a wildcard for chrome proxy bypass', () => { - expect( - ConfigSchema.validate({ - capture: { - browser: { - chromium: { - proxy: { - enabled: true, - server: 'http://example.com:8080', - bypass: ['*.example.com', '*bar.example.com', 'bats.example.com'], - }, - }, - }, - }, - }).capture.browser.chromium.proxy - ).toMatchInlineSnapshot(` - Object { - "bypass": Array [ - "*.example.com", - "*bar.example.com", - "bats.example.com", - ], - "enabled": true, - "server": "http://example.com:8080", - } - `); - }); - it.each(['0', '0.0', '0.0.0'])( `fails to validate "kibanaServer.hostname" with an invalid hostname: "%s"`, (address) => { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index 4c56fc4c6db60..c031ed4f94f9d 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -46,20 +46,6 @@ const QueueSchema = schema.object({ }), }); -const RulesSchema = schema.object({ - allow: schema.boolean(), - host: schema.maybe(schema.string()), - protocol: schema.maybe( - schema.string({ - validate(value) { - if (!/:$/.test(value)) { - return 'must end in colon'; - } - }, - }) - ), -}); - const CaptureSchema = schema.object({ timeouts: schema.object({ openUrl: schema.oneOf([schema.number(), schema.duration()], { @@ -72,56 +58,10 @@ const CaptureSchema = schema.object({ defaultValue: moment.duration({ seconds: 30 }), }), }), - networkPolicy: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - rules: schema.arrayOf(RulesSchema, { - defaultValue: [ - { host: undefined, allow: true, protocol: 'http:' }, - { host: undefined, allow: true, protocol: 'https:' }, - { host: undefined, allow: true, protocol: 'ws:' }, - { host: undefined, allow: true, protocol: 'wss:' }, - { host: undefined, allow: true, protocol: 'data:' }, - { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! - ], - }), - }), zoom: schema.number({ defaultValue: 2 }), loadDelay: schema.oneOf([schema.number(), schema.duration()], { defaultValue: moment.duration({ seconds: 3 }), }), - browser: schema.object({ - autoDownload: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.boolean({ defaultValue: true }) - ), - chromium: schema.object({ - inspect: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.maybe(schema.never()) - ), - disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig$ - proxy: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - server: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.uri({ scheme: ['http', 'https'] }), - schema.maybe(schema.never()) - ), - bypass: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.arrayOf(schema.string()), - schema.maybe(schema.never()) - ), - }), - }), - type: schema.string({ defaultValue: 'chromium' }), - }), maxAttempts: schema.conditional( schema.contextRef('dist'), true, diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 43aefb73aebb9..63900db4016b5 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -7,8 +7,8 @@ import Hapi from '@hapi/hapi'; import * as Rx from 'rxjs'; -import { filter, first, map, take } from 'rxjs/operators'; -import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; +import { filter, first, map, switchMap, take } from 'rxjs/operators'; +import type { ScreenshottingStart, ScreenshotResult } from '../../screenshotting/server'; import { BasePath, IClusterClient, @@ -28,13 +28,14 @@ import { SecurityPluginSetup } from '../../security/server'; import { DEFAULT_SPACE_ID } from '../../spaces/common/constants'; import { SpacesPluginSetup } from '../../spaces/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; +import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../common/constants'; +import { durationToNumber } from '../common/schema_utils'; import { ReportingConfig, ReportingSetup } from './'; -import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factory'; import { ReportingConfigType } from './config'; import { checkLicense, getExportTypesRegistry, LevelLogger } from './lib'; import { ReportingStore } from './lib/store'; import { ExecuteReportTask, MonitorReportsTask, ReportTaskParams } from './lib/tasks'; -import { ReportingPluginRouter } from './types'; +import { ReportingPluginRouter, ScreenshotOptions } from './types'; export interface ReportingInternalSetup { basePath: Pick; @@ -44,13 +45,11 @@ export interface ReportingInternalSetup { security?: SecurityPluginSetup; spaces?: SpacesPluginSetup; taskManager: TaskManagerSetupContract; - screenshotMode: ScreenshotModePluginSetup; logger: LevelLogger; status: StatusServiceSetup; } export interface ReportingInternalStart { - browserDriverFactory: HeadlessChromiumDriverFactory; store: ReportingStore; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; @@ -58,6 +57,7 @@ export interface ReportingInternalStart { data: DataPluginStart; taskManager: TaskManagerStartContract; logger: LevelLogger; + screenshotting: ScreenshottingStart; } export class ReportingCore { @@ -253,18 +253,6 @@ export class ReportingCore { .toPromise(); } - private getScreenshotModeDep() { - return this.getPluginSetupDeps().screenshotMode; - } - - public getEnableScreenshotMode() { - return this.getScreenshotModeDep().setScreenshotModeEnabled; - } - - public getSetScreenshotLayout() { - return this.getScreenshotModeDep().setScreenshotLayout; - } - /* * Gives synchronous access to the setupDeps */ @@ -350,6 +338,35 @@ export class ReportingCore { return startDeps.esClient; } + public getScreenshots(options: ScreenshotOptions): Rx.Observable { + return Rx.defer(() => this.getPluginStartDeps()).pipe( + switchMap(({ screenshotting }) => { + const config = this.getConfig(); + return screenshotting.getScreenshots({ + ...options, + + timeouts: { + loadDelay: durationToNumber(config.get('capture', 'loadDelay')), + openUrl: durationToNumber(config.get('capture', 'timeouts', 'openUrl')), + waitForElements: durationToNumber(config.get('capture', 'timeouts', 'waitForElements')), + renderComplete: durationToNumber(config.get('capture', 'timeouts', 'renderComplete')), + }, + + layout: { + zoom: config.get('capture', 'zoom'), + ...options.layout, + }, + + urls: options.urls.map((url) => + typeof url === 'string' + ? url + : [url[0], { [REPORTING_REDIRECT_LOCATOR_STORE_KEY]: url[1] }] + ), + }); + }) + ); + } + public trackReport(reportId: string) { this.executing.add(reportId); } diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index c5e70a6c93eff..8c83e0ae73527 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -8,70 +8,60 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { finalize, map, tap } from 'rxjs/operators'; +import { LayoutTypes } from '../../../../screenshotting/common'; import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../'; -import { UrlOrUrlLocatorTuple } from '../../../common/types'; +import { ScreenshotOptions } from '../../types'; import { LevelLogger } from '../../lib'; -import { LayoutParams, LayoutSelectorDictionary, PreserveLayout } from '../../lib/layouts'; -import { getScreenshots$, ScreenshotResults } from '../../lib/screenshots'; -import { ConditionalHeaders } from '../common'; -export async function generatePngObservableFactory(reporting: ReportingCore) { - const config = reporting.getConfig(); - const captureConfig = config.get('capture'); - const { browserDriverFactory } = await reporting.getPluginStartDeps(); - - return function generatePngObservable( - logger: LevelLogger, - urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, - browserTimezone: string | undefined, - conditionalHeaders: ConditionalHeaders, - layoutParams: LayoutParams & { selectors?: Partial } - ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const apmTrans = apm.startTransaction('generate-png', REPORTING_TRANSACTION_TYPE); - const apmLayout = apmTrans?.startSpan('create-layout', 'setup'); - if (!layoutParams || !layoutParams.dimensions) { - throw new Error(`LayoutParams.Dimensions is undefined.`); - } - const layout = new PreserveLayout(layoutParams.dimensions, layoutParams.selectors); +export function generatePngObservable( + reporting: ReportingCore, + logger: LevelLogger, + options: ScreenshotOptions +): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { + const apmTrans = apm.startTransaction('generate-png', REPORTING_TRANSACTION_TYPE); + const apmLayout = apmTrans?.startSpan('create-layout', 'setup'); + if (!options.layout.dimensions) { + throw new Error(`LayoutParams.Dimensions is undefined.`); + } + const layout = { + id: LayoutTypes.PRESERVE_LAYOUT, + ...options.layout, + }; - if (apmLayout) apmLayout.end(); + apmLayout?.end(); - const apmScreenshots = apmTrans?.startSpan('screenshots-pipeline', 'setup'); - let apmBuffer: typeof apm.currentSpan; - const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: [urlOrUrlLocatorTuple], - conditionalHeaders, - layout, - browserTimezone, - }).pipe( - tap(() => { - apmScreenshots?.end(); - apmBuffer = apmTrans?.startSpan('get-buffer', 'output') ?? null; - }), - map((results: ScreenshotResults[]) => ({ - buffer: results[0].screenshots[0].data, - warnings: results.reduce((found, current) => { - if (current.error) { - found.push(current.error.message); - } - if (current.renderErrors) { - found.push(...current.renderErrors); - } - return found; - }, [] as string[]), - })), - tap(({ buffer }) => { - logger.debug(`PNG buffer byte length: ${buffer.byteLength}`); - apmTrans?.setLabel('byte-length', buffer.byteLength, false); - }), - finalize(() => { - apmBuffer?.end(); - apmTrans?.end(); - }) - ); + const apmScreenshots = apmTrans?.startSpan('screenshots-pipeline', 'setup'); + let apmBuffer: typeof apm.currentSpan; - return screenshots$; - }; + return reporting.getScreenshots({ ...options, layout }).pipe( + tap(({ metrics$ }) => { + metrics$.subscribe(({ cpu, memory }) => { + apmTrans?.setLabel('cpu', cpu, false); + apmTrans?.setLabel('memory', memory, false); + }); + apmScreenshots?.end(); + apmBuffer = apmTrans?.startSpan('get-buffer', 'output') ?? null; + }), + map(({ results }) => ({ + buffer: results[0].screenshots[0].data, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + if (current.renderErrors) { + found.push(...current.renderErrors); + } + return found; + }, [] as string[]), + })), + tap(({ buffer }) => { + logger.debug(`PNG buffer byte length: ${buffer.byteLength}`); + apmTrans?.setLabel('byte-length', buffer.byteLength, false); + }), + finalize(() => { + apmBuffer?.end(); + apmTrans?.end(); + }) + ); } diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index c35dcb5344e21..501de48e0450a 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -10,7 +10,7 @@ export { getConditionalHeaders } from './get_conditional_headers'; export { getFullUrls } from './get_full_urls'; export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; -export { generatePngObservableFactory } from './generate_png'; +export { generatePngObservable } from './generate_png'; export { getCustomLogo } from './get_custom_logo'; export interface TimeRangeParams { diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts index 58ddeb51e7a4f..0c7fedc8f7b7e 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts @@ -13,12 +13,12 @@ import { StyleDictionary, TDocumentDefinitions, } from 'pdfmake/interfaces'; -import { LayoutInstance } from '../../../lib/layouts'; +import type { Layout } from '../../../../../screenshotting/server'; import { REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; export function getTemplate( - layout: LayoutInstance, + layout: Layout, logo: string | undefined, title: string, tableBorderWidth: number, diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts index 74a247d4568ab..2df98c6c79357 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts @@ -5,8 +5,7 @@ * 2.0. */ -import { PreserveLayout, PrintLayout } from '../../../lib/layouts'; -import { createMockConfig, createMockConfigSchema } from '../../../test_helpers'; +import { createMockLayout } from '../../../../../screenshotting/server/layouts/mock'; import { PdfMaker } from './'; const imageBase64 = Buffer.from( @@ -16,66 +15,22 @@ const imageBase64 = Buffer.from( // FLAKY: https://github.com/elastic/kibana/issues/118484 describe.skip('PdfMaker', () => { - it('makes PDF using PrintLayout mode', async () => { - const config = createMockConfig(createMockConfigSchema()); - const layout = new PrintLayout(config.get('capture')); - const pdf = new PdfMaker(layout, undefined); + let layout: ReturnType; + let pdf: PdfMaker; - expect(pdf.setTitle('the best PDF in the world')).toBe(undefined); - expect([ - pdf.addImage(imageBase64, { title: 'first viz', description: '☃️' }), - pdf.addImage(imageBase64, { title: 'second viz', description: '❄️' }), - ]).toEqual([undefined, undefined]); - - const { _layout: testLayout, _title: testTitle } = pdf as unknown as { - _layout: object; - _title: string; - }; - expect(testLayout).toMatchObject({ - captureConfig: { browser: { chromium: { disableSandbox: true } } }, // NOTE: irrelevant data? - groupCount: 2, - id: 'print', - selectors: { - itemsCountAttribute: 'data-shared-items-count', - renderComplete: '[data-shared-item]', - screenshot: '[data-shared-item]', - timefilterDurationAttribute: 'data-shared-timefilter-duration', - }, - }); - expect(testTitle).toBe('the best PDF in the world'); - - // generate buffer - pdf.generate(); - const result = await pdf.getBuffer(); - expect(Buffer.isBuffer(result)).toBe(true); + beforeEach(() => { + layout = createMockLayout(); + pdf = new PdfMaker(layout, undefined); }); - it('makes PDF using PreserveLayout mode', async () => { - const layout = new PreserveLayout({ width: 400, height: 300 }); - const pdf = new PdfMaker(layout, undefined); + describe('getBuffer', () => { + it('should generate PDF buffer', async () => { + pdf.setTitle('the best PDF in the world'); + pdf.addImage(imageBase64, { title: 'first viz', description: '☃️' }); + pdf.addImage(imageBase64, { title: 'second viz', description: '❄️' }); + pdf.generate(); - expect(pdf.setTitle('the finest PDF in the world')).toBe(undefined); - expect(pdf.addImage(imageBase64, { title: 'cool times', description: '☃️' })).toBe(undefined); - - const { _layout: testLayout, _title: testTitle } = pdf as unknown as { - _layout: object; - _title: string; - }; - expect(testLayout).toMatchObject({ - groupCount: 1, - id: 'preserve_layout', - selectors: { - itemsCountAttribute: 'data-shared-items-count', - renderComplete: '[data-shared-item]', - screenshot: '[data-shared-items-container]', - timefilterDurationAttribute: 'data-shared-timefilter-duration', - }, + await expect(pdf.getBuffer()).resolves.toBeInstanceOf(Buffer); }); - expect(testTitle).toBe('the finest PDF in the world'); - - // generate buffer - pdf.generate(); - const result = await pdf.getBuffer(); - expect(Buffer.isBuffer(result)).toBe(true); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts index 0cd054d3e3709..d6c0ec9dd844c 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts @@ -12,7 +12,7 @@ import _ from 'lodash'; import path from 'path'; import Printer from 'pdfmake'; import { Content, ContentImage, ContentText } from 'pdfmake/interfaces'; -import { LayoutInstance } from '../../../lib/layouts'; +import type { Layout } from '../../../../../screenshotting/server'; import { getDocOptions, REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; import { getTemplate } from './get_template'; @@ -21,14 +21,14 @@ const assetPath = path.resolve(__dirname, '..', '..', 'common', 'assets'); const tableBorderWidth = 1; export class PdfMaker { - private _layout: LayoutInstance; + private _layout: Layout; private _logo: string | undefined; private _title: string; private _content: Content[]; private _printer: Printer; private _pdfDoc: PDFKit.PDFDocument | undefined; - constructor(layout: LayoutInstance, logo: string | undefined) { + constructor(layout: Layout, logo: string | undefined) { const fontPath = (filename: string) => path.resolve(assetPath, 'fonts', filename); const fonts = { Roboto: { diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts index ed4709d501b43..7356da4da3a11 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts @@ -15,11 +15,11 @@ import { createMockConfigSchema, createMockReportingCore, } from '../../../test_helpers'; -import { generatePngObservableFactory } from '../../common'; +import { generatePngObservable } from '../../common'; import { TaskPayloadPNG } from '../types'; import { runTaskFnFactory } from './'; -jest.mock('../../common/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +jest.mock('../../common/generate_png'); let content: string; let mockReporting: ReportingCore; @@ -61,16 +61,13 @@ beforeEach(async () => { mockReporting = await createMockReportingCore(mockReportingConfig); mockReporting.setConfig(createMockConfig(mockReportingConfig)); - - (generatePngObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); -afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); +afterEach(() => (generatePngObservable as jest.Mock).mockReset()); test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); - const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -85,42 +82,24 @@ test(`passes browserTimezone to generatePng`, async () => { stream ); - expect(generatePngObservable.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - LevelLogger { - "_logger": Object { - "get": [MockFunction], - }, - "_tags": Array [ - "PNG", - "execute", - "pngJobId", - ], - "warning": [Function], - }, - "localhost:80undefined/app/kibana#/something", - "UTC", - Object { - "conditions": Object { - "basePath": undefined, - "hostname": "localhost", - "port": 80, - "protocol": undefined, - }, - "headers": Object {}, - }, - undefined, - ], - ] - `); + expect(generatePngObservable).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + urls: ['localhost:80undefined/app/kibana#/something'], + browserTimezone: 'UTC', + conditionalHeaders: expect.objectContaining({ + conditions: expect.any(Object), + headers: {}, + }), + }) + ); }); test(`returns content_type of application/png`, async () => { const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); - const generatePngObservable = await generatePngObservableFactory(mockReporting); (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('foo') })); const { content_type: contentType } = await runTask( @@ -134,7 +113,6 @@ test(`returns content_type of application/png`, async () => { test(`returns content of generatePng`, async () => { const testContent = 'raw string from get_screenhots'; - const generatePngObservable = await generatePngObservableFactory(mockReporting); (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index 2446e7a7d1c51..e6cbfb45eb095 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -7,7 +7,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; -import { catchError, finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; +import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { PNG_JOB_TYPE, REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; import { TaskRunResult } from '../../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../../types'; @@ -16,7 +16,7 @@ import { getConditionalHeaders, getFullUrls, omitBlockedHeaders, - generatePngObservableFactory, + generatePngObservable, } from '../../common'; import { TaskPayloadPNG } from '../types'; @@ -25,40 +25,35 @@ export const runTaskFnFactory: RunTaskFnFactory> = const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); - return async function runTask(jobId, job, cancellationToken, stream) { + return function runTask(jobId, job, cancellationToken, stream) { const apmTrans = apm.startTransaction('execute-job-png', REPORTING_TRANSACTION_TYPE); const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePng: { end: () => void } | null | undefined; - const generatePngObservable = await generatePngObservableFactory(reporting); const jobLogger = parentLogger.clone([PNG_JOB_TYPE, 'execute', jobId]); const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const urls = getFullUrls(config, job); - const hashUrl = urls[0]; - if (apmGetAssets) apmGetAssets.end(); + const [url] = getFullUrls(config, job); + apmGetAssets?.end(); apmGeneratePng = apmTrans?.startSpan('generate-png-pipeline', 'execute'); - return generatePngObservable( - jobLogger, - hashUrl, - job.browserTimezone, + + return generatePngObservable(reporting, jobLogger, { conditionalHeaders, - job.layout - ); + urls: [url], + browserTimezone: job.browserTimezone, + layout: job.layout, + }); }), tap(({ buffer }) => stream.write(buffer)), map(({ warnings }) => ({ content_type: 'image/png', warnings, })), - catchError((err) => { - jobLogger.error(err); - return Rx.throwError(err); - }), + tap({ error: (error) => jobLogger.error(error) }), finalize(() => apmGeneratePng?.end()) ); diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts index ba076f98996b1..783c8f8e8f880 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts @@ -16,11 +16,11 @@ import { createMockConfigSchema, createMockReportingCore, } from '../../test_helpers'; -import { generatePngObservableFactory } from '../common'; +import { generatePngObservable } from '../common'; import { runTaskFnFactory } from './execute_job'; import { TaskPayloadPNGV2 } from './types'; -jest.mock('../common/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +jest.mock('../common/generate_png'); let content: string; let mockReporting: ReportingCore; @@ -62,16 +62,13 @@ beforeEach(async () => { mockReporting = await createMockReportingCore(mockReportingConfig); mockReporting.setConfig(createMockConfig(mockReportingConfig)); - - (generatePngObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); -afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); +afterEach(() => (generatePngObservable as jest.Mock).mockReset()); test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); - const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; - generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -87,49 +84,29 @@ test(`passes browserTimezone to generatePng`, async () => { stream ); - expect(generatePngObservable.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - LevelLogger { - "_logger": Object { - "get": [MockFunction], - }, - "_tags": Array [ - "PNGV2", - "execute", - "pngJobId", - ], - "warning": [Function], - }, - Array [ - "localhost:80undefined/app/reportingRedirect?forceNow=test", - Object { - "id": "test", - "params": Object {}, - "version": "test", - }, + expect(generatePngObservable).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + urls: [ + [ + 'localhost:80undefined/app/reportingRedirect?forceNow=test', + { id: 'test', params: {}, version: 'test' }, ], - "UTC", - Object { - "conditions": Object { - "basePath": undefined, - "hostname": "localhost", - "port": 80, - "protocol": undefined, - }, - "headers": Object {}, - }, - undefined, ], - ] - `); + browserTimezone: 'UTC', + conditionalHeaders: expect.objectContaining({ + conditions: expect.any(Object), + headers: {}, + }), + }) + ); }); test(`returns content_type of application/png`, async () => { const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); - const generatePngObservable = await generatePngObservableFactory(mockReporting); (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('foo') })); const { content_type: contentType } = await runTask( @@ -146,7 +123,6 @@ test(`returns content_type of application/png`, async () => { test(`returns content of generatePng getBuffer base64 encoded`, async () => { const testContent = 'raw string from get_screenhots'; - const generatePngObservable = await generatePngObservableFactory(mockReporting); (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index 00652309b88c1..a8ab6c4355000 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -7,7 +7,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; -import { catchError, finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; +import { finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { PNG_JOB_TYPE_V2, REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; import { TaskRunResult } from '../../lib/tasks'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; @@ -15,7 +15,7 @@ import { decryptJobHeaders, getConditionalHeaders, omitBlockedHeaders, - generatePngObservableFactory, + generatePngObservable, } from '../common'; import { getFullRedirectAppUrl } from '../common/v2/get_full_redirect_app_url'; import { TaskPayloadPNGV2 } from './types'; @@ -25,12 +25,11 @@ export const runTaskFnFactory: RunTaskFnFactory> = const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); - return async function runTask(jobId, job, cancellationToken, stream) { + return function runTask(jobId, job, cancellationToken, stream) { const apmTrans = apm.startTransaction('execute-job-png-v2', REPORTING_TRANSACTION_TYPE); const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePng: { end: () => void } | null | undefined; - const generatePngObservable = await generatePngObservableFactory(reporting); const jobLogger = parentLogger.clone([PNG_JOB_TYPE_V2, 'execute', jobId]); const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), @@ -41,25 +40,21 @@ export const runTaskFnFactory: RunTaskFnFactory> = const [locatorParams] = job.locatorParams; apmGetAssets?.end(); - apmGeneratePng = apmTrans?.startSpan('generate-png-pipeline', 'execute'); - return generatePngObservable( - jobLogger, - [url, locatorParams], - job.browserTimezone, + + return generatePngObservable(reporting, jobLogger, { conditionalHeaders, - job.layout - ); + browserTimezone: job.browserTimezone, + layout: job.layout, + urls: [[url, locatorParams]], + }); }), tap(({ buffer }) => stream.write(buffer)), map(({ warnings }) => ({ content_type: 'image/png', warnings, })), - catchError((err) => { - jobLogger.error(err); - return Rx.throwError(err); - }), + tap({ error: (error) => jobLogger.error(error) }), finalize(() => apmGeneratePng?.end()) ); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts index 02f9c93929ea1..eb02097ec7924 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.test.ts @@ -5,18 +5,18 @@ * 2.0. */ -jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); - import * as Rx from 'rxjs'; import { Writable } from 'stream'; import { ReportingCore } from '../../../'; import { CancellationToken } from '../../../../common'; import { cryptoFactory, LevelLogger } from '../../../lib'; import { createMockConfigSchema, createMockReportingCore } from '../../../test_helpers'; -import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { generatePdfObservable } from '../lib/generate_pdf'; import { TaskPayloadPDF } from '../types'; import { runTaskFnFactory } from './'; +jest.mock('../lib/generate_pdf'); + let content: string; let mockReporting: ReportingCore; let stream: jest.Mocked; @@ -56,16 +56,13 @@ beforeEach(async () => { }; const mockSchema = createMockConfigSchema(reportingConfig); mockReporting = await createMockReportingCore(mockSchema); - - (generatePdfObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); -afterEach(() => (generatePdfObservableFactory as jest.Mock).mockReset()); +afterEach(() => (generatePdfObservable as jest.Mock).mockReset()); test(`passes browserTimezone to generatePdf`, async () => { const encryptedHeaders = await encryptHeaders({}); - const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; - generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from('') })); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -81,8 +78,13 @@ test(`passes browserTimezone to generatePdf`, async () => { stream ); - const tzParam = generatePdfObservable.mock.calls[0][3]; - expect(tzParam).toBe('UTC'); + expect(generatePdfObservable).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ browserTimezone: 'UTC' }), + undefined + ); }); test(`returns content_type of application/pdf`, async () => { @@ -90,7 +92,6 @@ test(`returns content_type of application/pdf`, async () => { const runTask = runTaskFnFactory(mockReporting, logger); const encryptedHeaders = await encryptHeaders({}); - const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const { content_type: contentType } = await runTask( @@ -104,7 +105,6 @@ test(`returns content_type of application/pdf`, async () => { test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const testContent = 'test content'; - const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = runTaskFnFactory(mockReporting, getMockLogger()); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index 2358333bbe7ef..f301b3e1e6ef2 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -18,7 +18,7 @@ import { omitBlockedHeaders, getCustomLogo, } from '../../common'; -import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { generatePdfObservable } from '../lib/generate_pdf'; import { TaskPayloadPDF } from '../types'; export const runTaskFnFactory: RunTaskFnFactory> = @@ -32,8 +32,6 @@ export const runTaskFnFactory: RunTaskFnFactory> = const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePdf: { end: () => void } | null | undefined; - const generatePdfObservable = await generatePdfObservableFactory(reporting); - const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), @@ -49,12 +47,15 @@ export const runTaskFnFactory: RunTaskFnFactory> = apmGeneratePdf = apmTrans?.startSpan('generate-pdf-pipeline', 'execute'); return generatePdfObservable( + reporting, jobLogger, title, - urls, - browserTimezone, - conditionalHeaders, - layout, + { + urls, + browserTimezone, + conditionalHeaders, + layout, + }, logo ); }), diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index dce6ea678bded..5bf087fecd10a 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -8,16 +8,15 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; +import { ScreenshotResult } from '../../../../../screenshotting/server'; import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; -import { createLayout, LayoutParams } from '../../../lib/layouts'; -import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../common'; +import { ScreenshotOptions } from '../../../types'; import { PdfMaker } from '../../common/pdf'; import { getTracker } from './tracker'; -const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { - const grouped = groupBy(urlScreenshots.map((u) => u.timeRange)); +const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { + const grouped = groupBy(urlScreenshots.map(({ timeRange }) => timeRange)); const values = Object.values(grouped); if (values.length === 1) { return values[0][0]; @@ -26,97 +25,80 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { return null; }; -export async function generatePdfObservableFactory(reporting: ReportingCore) { - const config = reporting.getConfig(); - const captureConfig = config.get('capture'); - const { browserDriverFactory } = await reporting.getPluginStartDeps(); - - return function generatePdfObservable( - logger: LevelLogger, - title: string, - urls: string[], - browserTimezone: string | undefined, - conditionalHeaders: ConditionalHeaders, - layoutParams: LayoutParams, - logo?: string - ): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { - const tracker = getTracker(); - tracker.startLayout(); - - const layout = createLayout(captureConfig, layoutParams); - logger.debug(`Layout: width=${layout.width} height=${layout.height}`); - tracker.endLayout(); - - tracker.startScreenshots(); - const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: urls, - conditionalHeaders, - layout, - browserTimezone, - }).pipe( - mergeMap(async (results: ScreenshotResults[]) => { - tracker.endScreenshots(); - - tracker.startSetup(); - const pdfOutput = new PdfMaker(layout, logo); - if (title) { - const timeRange = getTimeRange(results); - title += timeRange ? ` - ${timeRange}` : ''; - pdfOutput.setTitle(title); - } - tracker.endSetup(); - - results.forEach((r) => { - r.screenshots.forEach((screenshot) => { - logger.debug(`Adding image to PDF. Image size: ${screenshot.data.byteLength}`); // prettier-ignore - tracker.startAddImage(); - tracker.endAddImage(); - pdfOutput.addImage(screenshot.data, { - title: screenshot.title ?? undefined, - description: screenshot.description ?? undefined, - }); +export function generatePdfObservable( + reporting: ReportingCore, + logger: LevelLogger, + title: string, + options: ScreenshotOptions, + logo?: string +): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { + const tracker = getTracker(); + tracker.startScreenshots(); + + return reporting.getScreenshots(options).pipe( + mergeMap(async ({ layout, metrics$, results }) => { + metrics$.subscribe(({ cpu, memory }) => { + tracker.setCpuUsage(cpu); + tracker.setMemoryUsage(memory); + }); + tracker.endScreenshots(); + tracker.startSetup(); + + const pdfOutput = new PdfMaker(layout, logo); + if (title) { + const timeRange = getTimeRange(results); + title += timeRange ? ` - ${timeRange}` : ''; + pdfOutput.setTitle(title); + } + tracker.endSetup(); + + results.forEach((r) => { + r.screenshots.forEach((screenshot) => { + logger.debug(`Adding image to PDF. Image size: ${screenshot.data.byteLength}`); // prettier-ignore + tracker.startAddImage(); + tracker.endAddImage(); + pdfOutput.addImage(screenshot.data, { + title: screenshot.title ?? undefined, + description: screenshot.description ?? undefined, }); }); - - let buffer: Buffer | null = null; - try { - tracker.startCompile(); - logger.debug(`Compiling PDF using "${layout.id}" layout...`); - pdfOutput.generate(); - tracker.endCompile(); - - tracker.startGetBuffer(); - logger.debug(`Generating PDF Buffer...`); - buffer = await pdfOutput.getBuffer(); - - const byteLength = buffer?.byteLength ?? 0; - logger.debug(`PDF buffer byte length: ${byteLength}`); - tracker.setByteLength(byteLength); - - tracker.endGetBuffer(); - } catch (err) { - logger.error(`Could not generate the PDF buffer!`); - logger.error(err); - } - - tracker.end(); - - return { - buffer, - warnings: results.reduce((found, current) => { - if (current.error) { - found.push(current.error.message); - } - if (current.renderErrors) { - found.push(...current.renderErrors); - } - return found; - }, [] as string[]), - }; - }) - ); - - return screenshots$; - }; + }); + + let buffer: Buffer | null = null; + try { + tracker.startCompile(); + logger.debug(`Compiling PDF using "${layout.id}" layout...`); + pdfOutput.generate(); + tracker.endCompile(); + + tracker.startGetBuffer(); + logger.debug(`Generating PDF Buffer...`); + buffer = await pdfOutput.getBuffer(); + + const byteLength = buffer?.byteLength ?? 0; + logger.debug(`PDF buffer byte length: ${byteLength}`); + tracker.setByteLength(byteLength); + + tracker.endGetBuffer(); + } catch (err) { + logger.error(`Could not generate the PDF buffer!`); + logger.error(err); + } + + tracker.end(); + + return { + buffer, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + if (current.renderErrors) { + found.push(...current.renderErrors); + } + return found; + }, [] as string[]), + }; + }) + ); } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/tracker.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/tracker.ts index 3d720ccade546..d1cf2b96817d2 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/tracker.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/tracker.ts @@ -10,8 +10,8 @@ import { REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; interface PdfTracker { setByteLength: (byteLength: number) => void; - startLayout: () => void; - endLayout: () => void; + setCpuUsage: (cpu: number) => void; + setMemoryUsage: (memory: number) => void; startScreenshots: () => void; endScreenshots: () => void; startSetup: () => void; @@ -35,7 +35,6 @@ interface ApmSpan { export function getTracker(): PdfTracker { const apmTrans = apm.startTransaction('generate-pdf', REPORTING_TRANSACTION_TYPE); - let apmLayout: ApmSpan | null = null; let apmScreenshots: ApmSpan | null = null; let apmSetup: ApmSpan | null = null; let apmAddImage: ApmSpan | null = null; @@ -43,12 +42,6 @@ export function getTracker(): PdfTracker { let apmGetBuffer: ApmSpan | null = null; return { - startLayout() { - apmLayout = apmTrans?.startSpan('create-layout', SPANTYPE_SETUP) || null; - }, - endLayout() { - if (apmLayout) apmLayout.end(); - }, startScreenshots() { apmScreenshots = apmTrans?.startSpan('screenshots-pipeline', SPANTYPE_SETUP) || null; }, @@ -82,6 +75,12 @@ export function getTracker(): PdfTracker { setByteLength(byteLength: number) { apmTrans?.setLabel('byte-length', byteLength, false); }, + setCpuUsage(cpu: number) { + apmTrans?.setLabel('cpu', cpu, false); + }, + setMemoryUsage(memory: number) { + apmTrans?.setLabel('memory', memory, false); + }, end() { if (apmTrans) apmTrans.end(); }, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts index 197bd3866b8f6..9a73595ff32da 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -jest.mock('./lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); +jest.mock('./lib/generate_pdf'); import * as Rx from 'rxjs'; import { Writable } from 'stream'; @@ -15,7 +15,7 @@ import { LocatorParams } from '../../../common/types'; import { cryptoFactory, LevelLogger } from '../../lib'; import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; -import { generatePdfObservableFactory } from './lib/generate_pdf'; +import { generatePdfObservable } from './lib/generate_pdf'; import { TaskPayloadPDFV2 } from './types'; let content: string; @@ -61,16 +61,13 @@ beforeEach(async () => { }; const mockSchema = createMockConfigSchema(reportingConfig); mockReporting = await createMockReportingCore(mockSchema); - - (generatePdfObservableFactory as jest.Mock).mockReturnValue(jest.fn()); }); -afterEach(() => (generatePdfObservableFactory as jest.Mock).mockReset()); +afterEach(() => (generatePdfObservable as jest.Mock).mockReset()); test(`passes browserTimezone to generatePdf`, async () => { const encryptedHeaders = await encryptHeaders({}); - const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; - generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; @@ -87,8 +84,15 @@ test(`passes browserTimezone to generatePdf`, async () => { stream ); - const tzParam = generatePdfObservable.mock.calls[0][4]; - expect(tzParam).toBe('UTC'); + expect(generatePdfObservable).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ browserTimezone: 'UTC' }), + undefined + ); }); test(`returns content_type of application/pdf`, async () => { @@ -96,7 +100,6 @@ test(`returns content_type of application/pdf`, async () => { const runTask = runTaskFnFactory(mockReporting, logger); const encryptedHeaders = await encryptHeaders({}); - const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from('') })); const { content_type: contentType } = await runTask( @@ -110,7 +113,6 @@ test(`returns content_type of application/pdf`, async () => { test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const testContent = 'test content'; - const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); const runTask = runTaskFnFactory(mockReporting, getMockLogger()); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index b1b6f3f79aee3..890c0c9cde731 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -17,7 +17,7 @@ import { omitBlockedHeaders, getCustomLogo, } from '../common'; -import { generatePdfObservableFactory } from './lib/generate_pdf'; +import { generatePdfObservable } from './lib/generate_pdf'; import { TaskPayloadPDFV2 } from './types'; export const runTaskFnFactory: RunTaskFnFactory> = @@ -31,8 +31,6 @@ export const runTaskFnFactory: RunTaskFnFactory> = const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup'); let apmGeneratePdf: { end: () => void } | null | undefined; - const generatePdfObservable = await generatePdfObservableFactory(reporting); - const process$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), @@ -46,13 +44,16 @@ export const runTaskFnFactory: RunTaskFnFactory> = apmGeneratePdf = apmTrans?.startSpan('generate-pdf-pipeline', 'execute'); return generatePdfObservable( + reporting, jobLogger, job, title, locatorParams, - browserTimezone, - conditionalHeaders, - layout, + { + browserTimezone, + conditionalHeaders, + layout, + }, logo ); }), diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index b44e2ca4441eb..3d790beb41b39 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -5,21 +5,20 @@ * 2.0. */ -import { groupBy, zip } from 'lodash'; +import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; -import { createLayout, LayoutParams } from '../../../lib/layouts'; -import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../common'; +import { ScreenshotResult } from '../../../../../screenshotting/server'; +import { ScreenshotOptions } from '../../../types'; import { PdfMaker } from '../../common/pdf'; import { getFullRedirectAppUrl } from '../../common/v2/get_full_redirect_app_url'; import type { TaskPayloadPDFV2 } from '../types'; import { getTracker } from './tracker'; -const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { +const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { const grouped = groupBy(urlScreenshots.map((u) => u.timeRange)); const values = Object.values(grouped); if (values.length === 1) { @@ -29,106 +28,92 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { return null; }; -export async function generatePdfObservableFactory(reporting: ReportingCore) { - const config = reporting.getConfig(); - const captureConfig = config.get('capture'); - const { browserDriverFactory } = await reporting.getPluginStartDeps(); - - return function generatePdfObservable( - logger: LevelLogger, - job: TaskPayloadPDFV2, - title: string, - locatorParams: LocatorParams[], - browserTimezone: string | undefined, - conditionalHeaders: ConditionalHeaders, - layoutParams: LayoutParams, - logo?: string - ): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { - const tracker = getTracker(); - tracker.startLayout(); - - const layout = createLayout(captureConfig, layoutParams); - logger.debug(`Layout: width=${layout.width} height=${layout.height}`); - tracker.endLayout(); - - tracker.startScreenshots(); - - /** - * For each locator we get the relative URL to the redirect app - */ - const urls = locatorParams.map(() => - getFullRedirectAppUrl(reporting.getConfig(), job.spaceId, job.forceNow) - ); - - const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: zip(urls, locatorParams) as UrlOrUrlLocatorTuple[], - conditionalHeaders, - layout, - browserTimezone, - }).pipe( - mergeMap(async (results: ScreenshotResults[]) => { - tracker.endScreenshots(); - - tracker.startSetup(); - const pdfOutput = new PdfMaker(layout, logo); - if (title) { - const timeRange = getTimeRange(results); - title += timeRange ? ` - ${timeRange}` : ''; - pdfOutput.setTitle(title); - } - tracker.endSetup(); - - results.forEach((r) => { - r.screenshots.forEach((screenshot) => { - logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.data.byteLength}`); // prettier-ignore - tracker.startAddImage(); - tracker.endAddImage(); - pdfOutput.addImage(screenshot.data, { - title: screenshot.title ?? undefined, - description: screenshot.description ?? undefined, - }); +export function generatePdfObservable( + reporting: ReportingCore, + logger: LevelLogger, + job: TaskPayloadPDFV2, + title: string, + locatorParams: LocatorParams[], + options: Omit, + logo?: string +): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { + const tracker = getTracker(); + tracker.startScreenshots(); + + /** + * For each locator we get the relative URL to the redirect app + */ + const urls = locatorParams.map((locator) => [ + getFullRedirectAppUrl(reporting.getConfig(), job.spaceId, job.forceNow), + locator, + ]) as UrlOrUrlLocatorTuple[]; + + const screenshots$ = reporting.getScreenshots({ ...options, urls }).pipe( + mergeMap(async ({ layout, metrics$, results }) => { + metrics$.subscribe(({ cpu, memory }) => { + tracker.setCpuUsage(cpu); + tracker.setMemoryUsage(memory); + }); + tracker.endScreenshots(); + tracker.startSetup(); + + const pdfOutput = new PdfMaker(layout, logo); + if (title) { + const timeRange = getTimeRange(results); + title += timeRange ? ` - ${timeRange}` : ''; + pdfOutput.setTitle(title); + } + tracker.endSetup(); + + results.forEach((r) => { + r.screenshots.forEach((screenshot) => { + logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.data.byteLength}`); // prettier-ignore + tracker.startAddImage(); + tracker.endAddImage(); + pdfOutput.addImage(screenshot.data, { + title: screenshot.title ?? undefined, + description: screenshot.description ?? undefined, }); }); - - let buffer: Buffer | null = null; - try { - tracker.startCompile(); - logger.debug(`Compiling PDF using "${layout.id}" layout...`); - pdfOutput.generate(); - tracker.endCompile(); - - tracker.startGetBuffer(); - logger.debug(`Generating PDF Buffer...`); - buffer = await pdfOutput.getBuffer(); - - const byteLength = buffer?.byteLength ?? 0; - logger.debug(`PDF buffer byte length: ${byteLength}`); - tracker.setByteLength(byteLength); - - tracker.endGetBuffer(); - } catch (err) { - logger.error(`Could not generate the PDF buffer!`); - logger.error(err); - } - - tracker.end(); - - return { - buffer, - warnings: results.reduce((found, current) => { - if (current.error) { - found.push(current.error.message); - } - if (current.renderErrors) { - found.push(...current.renderErrors); - } - return found; - }, [] as string[]), - }; - }) - ); - - return screenshots$; - }; + }); + + let buffer: Buffer | null = null; + try { + tracker.startCompile(); + logger.debug(`Compiling PDF using "${layout.id}" layout...`); + pdfOutput.generate(); + tracker.endCompile(); + + tracker.startGetBuffer(); + logger.debug(`Generating PDF Buffer...`); + buffer = await pdfOutput.getBuffer(); + + const byteLength = buffer?.byteLength ?? 0; + logger.debug(`PDF buffer byte length: ${byteLength}`); + tracker.setByteLength(byteLength); + + tracker.endGetBuffer(); + } catch (err) { + logger.error(`Could not generate the PDF buffer!`); + logger.error(err); + } + + tracker.end(); + + return { + buffer, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + if (current.renderErrors) { + found.push(...current.renderErrors); + } + return found; + }, [] as string[]), + }; + }) + ); + + return screenshots$; } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts index 3d720ccade546..d1cf2b96817d2 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts @@ -10,8 +10,8 @@ import { REPORTING_TRANSACTION_TYPE } from '../../../../common/constants'; interface PdfTracker { setByteLength: (byteLength: number) => void; - startLayout: () => void; - endLayout: () => void; + setCpuUsage: (cpu: number) => void; + setMemoryUsage: (memory: number) => void; startScreenshots: () => void; endScreenshots: () => void; startSetup: () => void; @@ -35,7 +35,6 @@ interface ApmSpan { export function getTracker(): PdfTracker { const apmTrans = apm.startTransaction('generate-pdf', REPORTING_TRANSACTION_TYPE); - let apmLayout: ApmSpan | null = null; let apmScreenshots: ApmSpan | null = null; let apmSetup: ApmSpan | null = null; let apmAddImage: ApmSpan | null = null; @@ -43,12 +42,6 @@ export function getTracker(): PdfTracker { let apmGetBuffer: ApmSpan | null = null; return { - startLayout() { - apmLayout = apmTrans?.startSpan('create-layout', SPANTYPE_SETUP) || null; - }, - endLayout() { - if (apmLayout) apmLayout.end(); - }, startScreenshots() { apmScreenshots = apmTrans?.startSpan('screenshots-pipeline', SPANTYPE_SETUP) || null; }, @@ -82,6 +75,12 @@ export function getTracker(): PdfTracker { setByteLength(byteLength: number) { apmTrans?.setLabel('byte-length', byteLength, false); }, + setCpuUsage(cpu: number) { + apmTrans?.setLabel('cpu', cpu, false); + }, + setMemoryUsage(memory: number) { + apmTrans?.setLabel('memory', memory, false); + }, end() { if (apmTrans) apmTrans.end(); }, diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts deleted file mode 100644 index f62ee6ab720c3..0000000000000 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts +++ /dev/null @@ -1,29 +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 { LAYOUT_TYPES } from '../../../common/constants'; -import { CaptureConfig } from '../../types'; -import { LayoutInstance, LayoutParams, LayoutTypes } from './'; -import { CanvasLayout } from './canvas_layout'; -import { PreserveLayout } from './preserve_layout'; -import { PrintLayout } from './print_layout'; - -export function createLayout( - captureConfig: CaptureConfig, - layoutParams?: LayoutParams -): LayoutInstance { - if (layoutParams && layoutParams.dimensions && layoutParams.id === LAYOUT_TYPES.PRESERVE_LAYOUT) { - return new PreserveLayout(layoutParams.dimensions); - } - - if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.CANVAS) { - return new CanvasLayout(layoutParams.dimensions); - } - - // layoutParams is optional as PrintLayout doesn't use it - return new PrintLayout(captureConfig); -} diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts deleted file mode 100644 index daff568ab0067..0000000000000 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ /dev/null @@ -1,51 +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 { LevelLogger } from '../'; -import { Size } from '../../../common/types'; -import { HeadlessChromiumDriver } from '../../browsers'; -import type { Layout } from './layout'; - -export interface LayoutSelectorDictionary { - screenshot: string; - renderComplete: string; - renderError: string; - renderErrorAttribute: string; - itemsCountAttribute: string; - timefilterDurationAttribute: string; -} - -export type { LayoutParams, PageSizeParams, PdfImageSize, Size } from '../../../common/types'; -export { CanvasLayout } from './canvas_layout'; -export { createLayout } from './create_layout'; -export type { Layout } from './layout'; -export { PreserveLayout } from './preserve_layout'; -export { PrintLayout } from './print_layout'; - -export const LayoutTypes = { - PRESERVE_LAYOUT: 'preserve_layout', - PRINT: 'print', - CANVAS: 'canvas', // no margins or branding in the layout -}; - -export const getDefaultLayoutSelectors = (): LayoutSelectorDictionary => ({ - screenshot: '[data-shared-items-container]', - renderComplete: '[data-shared-item]', - renderError: '[data-render-error]', - renderErrorAttribute: 'data-render-error', - itemsCountAttribute: 'data-shared-items-count', - timefilterDurationAttribute: 'data-shared-timefilter-duration', -}); - -interface LayoutSelectors { - // Fields that are not part of Layout: the instances - // independently implement these fields on their own - selectors: LayoutSelectorDictionary; - positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise; -} - -export type LayoutInstance = Layout & LayoutSelectors & Partial; 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 deleted file mode 100644 index f160fcb8b27ad..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts +++ /dev/null @@ -1,90 +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 { set } from 'lodash'; -import { durationToNumber } from '../../../common/schema_utils'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { - createMockBrowserDriverFactory, - createMockConfig, - createMockConfigSchema, - createMockLayoutInstance, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; -import { CaptureConfig } from '../../types'; -import { LayoutInstance } from '../layouts'; -import { LevelLogger } from '../level_logger'; -import { getNumberOfItems } from './get_number_of_items'; - -describe('getNumberOfItems', () => { - let captureConfig: CaptureConfig; - let layout: LayoutInstance; - let logger: jest.Mocked; - let browser: HeadlessChromiumDriver; - let timeout: number; - - beforeEach(async () => { - const schema = createMockConfigSchema(set({}, 'capture.timeouts.waitForElements', 0)); - const config = createMockConfig(schema); - const core = await createMockReportingCore(schema); - - captureConfig = config.get('capture'); - layout = createMockLayoutInstance(captureConfig); - logger = createMockLevelLogger(); - timeout = durationToNumber(captureConfig.timeouts.waitForElements); - - await createMockBrowserDriverFactory(core, logger, { - evaluate: jest.fn( - async unknown>({ - fn, - args, - }: { - fn: T; - args: Parameters; - }) => fn(...args) - ), - getCreatePage: (driver) => { - browser = driver; - - return jest.fn(); - }, - }); - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('should determine the number of items by attribute', async () => { - document.body.innerHTML = ` -
- `; - - await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(10); - }); - - it('should determine the number of items by selector ', async () => { - document.body.innerHTML = ` - - - - `; - - await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(3); - }); - - it('should fall back to the selector when the attribute is empty', async () => { - document.body.innerHTML = ` -
- - - `; - - await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(2); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.test.ts deleted file mode 100644 index d29c0936bfceb..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.test.ts +++ /dev/null @@ -1,82 +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 { - createMockBrowserDriverFactory, - createMockConfig, - createMockConfigSchema, - createMockLayoutInstance, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; -import { CaptureConfig } from '../../types'; -import { LayoutInstance } from '../layouts'; -import { LevelLogger } from '../level_logger'; -import { getRenderErrors } from './get_render_errors'; - -describe('getRenderErrors', () => { - let captureConfig: CaptureConfig; - let layout: LayoutInstance; - let logger: jest.Mocked; - let browser: HeadlessChromiumDriver; - - beforeEach(async () => { - const schema = createMockConfigSchema(); - const config = createMockConfig(schema); - const core = await createMockReportingCore(schema); - - captureConfig = config.get('capture'); - layout = createMockLayoutInstance(captureConfig); - logger = createMockLevelLogger(); - - await createMockBrowserDriverFactory(core, logger, { - evaluate: jest.fn( - async unknown>({ - fn, - args, - }: { - fn: T; - args: Parameters; - }) => fn(...args) - ), - getCreatePage: (driver) => { - browser = driver; - - return jest.fn(); - }, - }); - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('should extract the error messages', async () => { - document.body.innerHTML = ` -
-
-
-
- `; - - await expect(getRenderErrors(browser, layout, logger)).resolves.toEqual([ - 'a test error', - 'a test error', - 'a test error', - 'a test error', - ]); - }); - - it('should extract the error messages, even when there are none', async () => { - document.body.innerHTML = ` - - `; - - await expect(getRenderErrors(browser, layout, logger)).resolves.toEqual(undefined); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.test.ts deleted file mode 100644 index 003d1dc254a2a..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.test.ts +++ /dev/null @@ -1,76 +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 { - createMockBrowserDriverFactory, - createMockConfig, - createMockConfigSchema, - createMockLayoutInstance, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; -import { LayoutInstance } from '../layouts'; -import { LevelLogger } from '../level_logger'; -import { getTimeRange } from './get_time_range'; - -describe('getTimeRange', () => { - let layout: LayoutInstance; - let logger: jest.Mocked; - let browser: HeadlessChromiumDriver; - - beforeEach(async () => { - const schema = createMockConfigSchema(); - const config = createMockConfig(schema); - const captureConfig = config.get('capture'); - const core = await createMockReportingCore(schema); - - layout = createMockLayoutInstance(captureConfig); - logger = createMockLevelLogger(); - - await createMockBrowserDriverFactory(core, logger, { - evaluate: jest.fn( - async unknown>({ - fn, - args, - }: { - fn: T; - args: Parameters; - }) => fn(...args) - ), - getCreatePage: (driver) => { - browser = driver; - - return jest.fn(); - }, - }); - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('should return null when there is no duration element', async () => { - await expect(getTimeRange(browser, layout, logger)).resolves.toBeNull(); - }); - - it('should return null when duration attrbute is empty', async () => { - document.body.innerHTML = ` -
- `; - - await expect(getTimeRange(browser, layout, logger)).resolves.toBeNull(); - }); - - it('should return duration', async () => { - document.body.innerHTML = ` -
- `; - - await expect(getTimeRange(browser, layout, logger)).resolves.toBe('10'); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts deleted file mode 100644 index 2b8a0d6207a9b..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ /dev/null @@ -1,83 +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 { LevelLogger } from '../'; -import { UrlOrUrlLocatorTuple } from '../../../common/types'; -import { ConditionalHeaders } from '../../export_types/common'; -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[]; - conditionalHeaders: ConditionalHeaders; - layout: LayoutInstance; - browserTimezone?: string; -} - -export interface AttributesMap { - [key: string]: string | null; -} - -export interface ElementPosition { - boundingClientRect: { - // modern browsers support x/y, but older ones don't - top: number; - left: number; - width: number; - height: number; - }; - scroll: { - x: number; - y: number; - }; -} - -export interface ElementsPositionAndAttribute { - position: ElementPosition; - attributes: AttributesMap; -} - -export interface Screenshot { - data: Buffer; - title: string | null; - description: string | null; -} - -export interface PageSetupResults { - elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; - timeRange: string | null; - error?: Error; -} - -export interface ScreenshotResults { - timeRange: string | null; - screenshots: Screenshot[]; - error?: Error; - - /** - * Individual visualizations might encounter errors at runtime. If there are any they are added to this - * field. Any text captured here is intended to be shown to the user for debugging purposes, reporting - * does no further sanitization on these strings. - */ - renderErrors?: string[]; - elementsPositionAndAttributes?: ElementsPositionAndAttribute[]; // NOTE: for testing -} diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts deleted file mode 100644 index 3071ecb54dc26..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ /dev/null @@ -1,490 +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. - */ - -jest.mock('puppeteer', () => ({ - launch: () => ({ - // Fixme needs event emitters - newPage: () => ({ - emulateTimezone: jest.fn(), - setDefaultTimeout: jest.fn(), - }), - process: jest.fn(), - close: jest.fn(), - }), -})); - -import moment from 'moment'; -import * as Rx from 'rxjs'; -import { ReportingCore } from '../..'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { ConditionalHeaders } from '../../export_types/common'; -import { - createMockBrowserDriverFactory, - createMockConfig, - createMockConfigSchema, - createMockLayoutInstance, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; -import * as contexts from './constants'; -import { getScreenshots$ } from './'; - -/* - * Mocks - */ -const logger = createMockLevelLogger(); - -const mockSchema = createMockConfigSchema({ - capture: { - loadDelay: moment.duration(2, 's'), - timeouts: { - openUrl: moment.duration(2, 'm'), - waitForElements: moment.duration(20, 's'), - renderComplete: moment.duration(10, 's'), - }, - }, -}); -const mockConfig = createMockConfig(mockSchema); -const captureConfig = mockConfig.get('capture'); -const mockLayout = createMockLayoutInstance(captureConfig); - -let core: ReportingCore; - -/* - * Tests - */ -describe('Screenshot Observable Pipeline', () => { - let mockBrowserDriverFactory: any; - - beforeEach(async () => { - core = await createMockReportingCore(mockSchema); - mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {}); - }); - - it('pipelines a single url into screenshot and timeRange', async () => { - const result = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: ['/welcome/home/start/index.htm'], - conditionalHeaders: {} as ConditionalHeaders, - layout: mockLayout, - browserTimezone: 'UTC', - }).toPromise(); - - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object { - "description": "Default ", - "title": "Default Mock Title", - }, - "position": Object { - "boundingClientRect": Object { - "height": 600, - "left": 0, - "top": 0, - "width": 800, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "error": undefined, - "screenshots": Array [ - Object { - "data": Object { - "data": Array [ - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - ], - "type": "Buffer", - }, - "description": "Default ", - "title": "Default Mock Title", - }, - ], - "timeRange": "Default GetTimeRange Result", - }, - ] - `); - }); - - it('pipelines multiple urls into', async () => { - // mock implementations - const mockScreenshot = jest.fn(async () => Buffer.from('some screenshots')); - const mockOpen = jest.fn(); - - // mocks - mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, { - screenshot: mockScreenshot, - open: mockOpen, - }); - - // test - const result = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: [ - '/welcome/home/start/index2.htm', - '/welcome/home/start/index.php3?page=./home.php', - ], - conditionalHeaders: {} as ConditionalHeaders, - layout: mockLayout, - browserTimezone: 'UTC', - }).toPromise(); - - expect(result).toMatchInlineSnapshot(` - Array [ - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object { - "description": "Default ", - "title": "Default Mock Title", - }, - "position": Object { - "boundingClientRect": Object { - "height": 600, - "left": 0, - "top": 0, - "width": 800, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "error": undefined, - "screenshots": Array [ - Object { - "data": Object { - "data": Array [ - 115, - 111, - 109, - 101, - 32, - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - 115, - ], - "type": "Buffer", - }, - "description": "Default ", - "title": "Default Mock Title", - }, - ], - "timeRange": "Default GetTimeRange Result", - }, - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object { - "description": "Default ", - "title": "Default Mock Title", - }, - "position": Object { - "boundingClientRect": Object { - "height": 600, - "left": 0, - "top": 0, - "width": 800, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "error": undefined, - "screenshots": Array [ - Object { - "data": Object { - "data": Array [ - 115, - 111, - 109, - 101, - 32, - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - 115, - ], - "type": "Buffer", - }, - "description": "Default ", - "title": "Default Mock Title", - }, - ], - "timeRange": "Default GetTimeRange Result", - }, - ] - `); - - // ensures the correct selectors are waited on for multi URL jobs - expect(mockOpen.mock.calls.length).toBe(2); - - const firstSelector = mockOpen.mock.calls[0][1].waitForSelector; - expect(firstSelector).toBe('.kbnAppWrapper'); - - const secondSelector = mockOpen.mock.calls[1][1].waitForSelector; - expect(secondSelector).toBe('[data-shared-page="2"]'); - }); - - describe('error handling', () => { - it('recovers if waitForSelector fails', async () => { - // mock implementations - const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => { - throw new Error('Mock error!'); - }); - - // mocks - mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, { - waitForSelector: mockWaitForSelector, - }); - - // test - const getScreenshot = async () => { - return await getScreenshots$(captureConfig, mockBrowserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: [ - '/welcome/home/start/index2.htm', - '/welcome/home/start/index.php3?page=./home.php3', - ], - conditionalHeaders: {} as ConditionalHeaders, - layout: mockLayout, - browserTimezone: 'UTC', - }).toPromise(); - }; - - await expect(getScreenshot()).resolves.toMatchInlineSnapshot(` - Array [ - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object {}, - "position": Object { - "boundingClientRect": Object { - "height": 100, - "left": 0, - "top": 0, - "width": 100, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "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 { - "data": Array [ - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - ], - "type": "Buffer", - }, - "description": undefined, - "title": undefined, - }, - ], - "timeRange": null, - }, - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object {}, - "position": Object { - "boundingClientRect": Object { - "height": 100, - "left": 0, - "top": 0, - "width": 100, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "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 { - "data": Array [ - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - ], - "type": "Buffer", - }, - "description": undefined, - "title": undefined, - }, - ], - "timeRange": null, - }, - ] - `); - }); - - it('observes page exit', async () => { - // mocks - const mockGetCreatePage = (driver: HeadlessChromiumDriver) => - jest - .fn() - .mockImplementation(() => - Rx.of({ driver, exit$: Rx.throwError('Instant timeout has fired!') }) - ); - - const mockWaitForSelector = jest.fn().mockImplementation((selectorArg: string) => { - return Rx.never().toPromise(); - }); - - mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, { - getCreatePage: mockGetCreatePage, - waitForSelector: mockWaitForSelector, - }); - - // test - const getScreenshot = async () => { - return await getScreenshots$(captureConfig, mockBrowserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: ['/welcome/home/start/index.php3?page=./home.php3'], - conditionalHeaders: {} as ConditionalHeaders, - layout: mockLayout, - browserTimezone: 'UTC', - }).toPromise(); - }; - - await expect(getScreenshot()).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`); - }); - - it(`uses defaults for element positions and size when Kibana page is not ready`, async () => { - // mocks - const mockBrowserEvaluate = jest.fn(); - mockBrowserEvaluate.mockImplementation(() => { - const lastCallIndex = mockBrowserEvaluate.mock.calls.length - 1; - const { context: mockCall } = mockBrowserEvaluate.mock.calls[lastCallIndex][1]; - - if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) { - return Promise.resolve(null); - } else { - return Promise.resolve(); - } - }); - mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, { - evaluate: mockBrowserEvaluate, - }); - mockLayout.getViewport = () => null; - - const screenshots = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { - logger, - urlsOrUrlLocatorTuples: ['/welcome/home/start/index.php3?page=./home.php3'], - conditionalHeaders: {} as ConditionalHeaders, - layout: mockLayout, - browserTimezone: 'UTC', - }).toPromise(); - - expect(screenshots).toMatchInlineSnapshot(` - Array [ - Object { - "elementsPositionAndAttributes": Array [ - Object { - "attributes": Object {}, - "position": Object { - "boundingClientRect": Object { - "height": 1200, - "left": 0, - "top": 0, - "width": 1800, - }, - "scroll": Object { - "x": 0, - "y": 0, - }, - }, - }, - ], - "error": undefined, - "screenshots": Array [ - Object { - "data": Object { - "data": Array [ - 115, - 99, - 114, - 101, - 101, - 110, - 115, - 104, - 111, - 116, - ], - "type": "Buffer", - }, - "description": undefined, - "title": undefined, - }, - ], - "timeRange": undefined, - }, - ] - `); - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts deleted file mode 100644 index 8ba2a125a5504..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts +++ /dev/null @@ -1,85 +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 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 { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; -import { HeadlessChromiumDriverFactory } from '../../browsers'; -import { CaptureConfig } from '../../types'; -import { - ElementPosition, - ElementsPositionAndAttribute, - PageSetupResults, - ScreenshotObservableOpts, - ScreenshotResults, -} from './'; -import { ScreenshotObservableHandler } from './observable_handler'; - -export type { ElementPosition, ElementsPositionAndAttribute, ScreenshotResults }; - -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, - opts: ScreenshotObservableOpts -): Rx.Observable { - const apmTrans = apm.startTransaction('screenshot-pipeline', REPORTING_TRANSACTION_TYPE); - const apmCreatePage = apmTrans?.startSpan('create-page', 'wait'); - const { browserTimezone, logger } = opts; - - return browserDriverFactory.createPage({ browserTimezone }, logger).pipe( - mergeMap(({ driver, exit$ }) => { - apmCreatePage?.end(); - exit$.subscribe({ error: () => apmTrans?.end() }); - - const screen = new ScreenshotObservableHandler(driver, opts, getTimeouts(captureConfig)); - - return Rx.from(opts.urlsOrUrlLocatorTuples).pipe( - concatMap((urlOrUrlLocatorTuple, index) => - screen.setupPage(index, urlOrUrlLocatorTuple, apmTrans).pipe( - catchError((err) => { - screen.checkPageIsOpen(); // this fails the job if the browser has closed - - logger.error(err); - return Rx.of({ ...defaultSetupResult, error: err }); // allow failover screenshot capture - }), - takeUntil(exit$), - screen.getScreenshots() - ) - ), - take(opts.urlsOrUrlLocatorTuples.length), - toArray() - ); - }), - first() - ); -} - -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 deleted file mode 100644 index cb0a513992722..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts +++ /dev/null @@ -1,160 +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 * 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"]` - ); - }); - - 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 deleted file mode 100644 index c241a529818fa..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts +++ /dev/null @@ -1,197 +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 apm from 'elastic-apm-node'; -import * as Rx from 'rxjs'; -import { catchError, mergeMap, switchMapTo, timeoutWith } 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']; - - 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) => - source.pipe( - catchError((error) => { - throw new Error(`The "${label}" phase encountered an error: ${error}`); - }), - timeoutWith( - timeoutValue, - Rx.throwError( - new Error( - `The "${label}" phase took longer than ${numberToDuration( - timeoutValue - ).asSeconds()} seconds. You may need to increase "${configValue}"` - ) - ) - ) - ); - } - - private openUrl(index: number, urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple) { - return Rx.defer(() => - openUrl( - this.timeouts.openUrl.timeoutValue, - this.driver, - index, - urlOrUrlLocatorTuple, - this.conditionalHeaders, - this.layout, - this.logger - ) - ).pipe(this.waitUntil(this.timeouts.openUrl)); - } - - private waitForElements() { - const driver = this.driver; - const waitTimeout = this.timeouts.waitForElements.timeoutValue; - - return Rx.defer(() => getNumberOfItems(waitTimeout, driver, this.layout, this.logger)).pipe( - mergeMap((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(); - - return Rx.forkJoin([ - driver.setViewport(viewport, this.logger), - waitForVisualizations(waitTimeout, driver, itemsCount, this.layout, this.logger), - ]); - }), - this.waitUntil(this.timeouts.waitForElements) - ); - } - - private completeRender(apmTrans: apm.Transaction | null) { - const driver = this.driver; - const layout = this.layout; - const logger = this.logger; - - return Rx.defer(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); - }).pipe( - mergeMap(() => - Rx.forkJoin({ - timeRange: getTimeRange(driver, layout, logger), - elementsPositionAndAttributes: getElementPositionAndAttributes(driver, layout, logger), - renderErrors: getRenderErrors(driver, layout, logger), - }) - ), - this.waitUntil(this.timeouts.renderComplete) - ); - } - - public setupPage( - index: number, - urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, - apmTrans: apm.Transaction | null - ) { - return this.openUrl(index, urlOrUrlLocatorTuple).pipe( - switchMapTo(this.waitForElements()), - switchMapTo(this.completeRender(apmTrans)) - ); - } - - 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/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index a43b4494fe913..667648d3372c5 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -34,7 +34,6 @@ export const mapping = { }, }, }, - browser_type: { type: 'keyword' }, migration_version: { type: 'keyword' }, // new field (7.14) to distinguish reports that were scheduled with Task Manager jobtype: { type: 'keyword' }, payload: { type: 'object', enabled: false }, diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index f9cd413b3e5a7..f6cbbade4df7b 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -13,7 +13,6 @@ describe('Class Report', () => { _index: '.reporting-test-index-12345', jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { headers: 'payload_test_field', @@ -28,7 +27,6 @@ describe('Class Report', () => { expect(report.toReportSource()).toMatchObject({ attempts: 0, - browser_type: 'browser_type_test_string', completed_at: undefined, created_by: 'created_by_test_string', jobtype: 'test-report', @@ -49,7 +47,6 @@ describe('Class Report', () => { }); expect(report.toApiJSON()).toMatchObject({ attempts: 0, - browser_type: 'browser_type_test_string', created_by: 'created_by_test_string', index: '.reporting-test-index-12345', jobtype: 'test-report', @@ -68,7 +65,6 @@ describe('Class Report', () => { _index: '.reporting-test-index-12345', jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { headers: 'payload_test_field', @@ -91,7 +87,6 @@ describe('Class Report', () => { expect(report.toReportSource()).toMatchObject({ attempts: 0, - browser_type: 'browser_type_test_string', completed_at: undefined, created_by: 'created_by_test_string', jobtype: 'test-report', @@ -113,7 +108,6 @@ describe('Class Report', () => { }); expect(report.toApiJSON()).toMatchObject({ attempts: 0, - browser_type: 'browser_type_test_string', completed_at: undefined, created_by: 'created_by_test_string', id: '12342p9o387549o2345', diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 2f802334eb6ff..67f1ccdea5db8 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -38,7 +38,6 @@ export class Report implements Partial { public readonly payload: ReportSource['payload']; public readonly meta: ReportSource['meta']; - public readonly browser_type: ReportSource['browser_type']; public readonly status: ReportSource['status']; public readonly attempts: ReportSource['attempts']; @@ -82,7 +81,6 @@ export class Report implements Partial { this.max_attempts = opts.max_attempts; this.attempts = opts.attempts || 0; this.timeout = opts.timeout; - this.browser_type = opts.browser_type; this.process_expiration = opts.process_expiration; this.started_at = opts.started_at; @@ -125,7 +123,6 @@ export class Report implements Partial { meta: this.meta, timeout: this.timeout, max_attempts: this.max_attempts, - browser_type: this.browser_type, status: this.status, attempts: this.attempts, started_at: this.started_at, @@ -170,7 +167,6 @@ export class Report implements Partial { meta: this.meta, timeout: this.timeout, max_attempts: this.max_attempts, - browser_type: this.browser_type, status: this.status, attempts: this.attempts, started_at: this.started_at, diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index a28197d261ba2..c67dc3fa2d992 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -193,7 +193,6 @@ describe('ReportingStore', () => { status: 'pending', meta: { testMeta: 'meta' } as any, payload: { testPayload: 'payload' } as any, - browser_type: 'browser type string', attempts: 0, max_attempts: 1, timeout: 30000, @@ -214,7 +213,6 @@ describe('ReportingStore', () => { "_primary_term": 1234, "_seq_no": 5678, "attempts": 0, - "browser_type": "browser type string", "completed_at": undefined, "created_at": "some time", "created_by": "some security person", @@ -247,7 +245,6 @@ describe('ReportingStore', () => { _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { title: 'test report', @@ -279,7 +276,6 @@ describe('ReportingStore', () => { _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { title: 'test report', @@ -310,7 +306,6 @@ describe('ReportingStore', () => { _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { title: 'test report', @@ -341,7 +336,6 @@ describe('ReportingStore', () => { _primary_term: 10002, jobtype: 'test-report', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', max_attempts: 50, payload: { title: 'test report', @@ -385,7 +379,6 @@ describe('ReportingStore', () => { _primary_term: 10002, jobtype: 'test-report-2', created_by: 'created_by_test_string', - browser_type: 'browser_type_test_string', status: 'processing', process_expiration: '2002', max_attempts: 3, diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 43f57da8c21f7..7ddef6d66e275 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -24,7 +24,6 @@ import { MIGRATION_VERSION } from './report'; export type ReportProcessingFields = Required<{ kibana_id: Report['kibana_id']; kibana_name: Report['kibana_name']; - browser_type: Report['browser_type']; attempts: Report['attempts']; started_at: Report['started_at']; max_attempts: Report['max_attempts']; @@ -252,7 +251,6 @@ export class ReportingStore { _primary_term: document._primary_term, jobtype: document._source?.jobtype, attempts: document._source?.attempts, - browser_type: document._source?.browser_type, created_at: document._source?.created_at, created_by: document._source?.created_by, max_attempts: document._source?.max_attempts, diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 5f885ad127b43..b725c31da398d 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -159,7 +159,6 @@ export class ExecuteReportTask implements ReportingTask { const doc: ReportProcessingFields = { kibana_id: this.kibanaId, kibana_name: this.kibanaName, - browser_type: this.config.capture.browser.type, attempts: report.attempts + 1, max_attempts: maxAttempts, started_at: startTime, diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index 9a2acc4a51202..4c04eb0c004e5 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -5,16 +5,6 @@ * 2.0. */ -jest.mock('./browsers/install', () => ({ - installBrowser: jest.fn().mockImplementation(() => ({ - binaryPath$: { - pipe: jest.fn().mockImplementation(() => ({ - toPromise: () => Promise.resolve(), - })), - }, - })), -})); - import { coreMock } from 'src/core/server/mocks'; import { featuresPluginMock } from '../../features/server/mocks'; import { TaskManagerSetupContract } from '../../task_manager/server'; diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 8969a698a8ce4..0a2318daded02 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -8,7 +8,6 @@ import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; import { PLUGIN_ID } from '../common/constants'; import { ReportingCore } from './'; -import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, registerUiSettings, ReportingConfigType } from './config'; import { registerDeprecations } from './deprecations'; import { LevelLogger, ReportingStore } from './lib'; @@ -35,7 +34,7 @@ export class ReportingPlugin public setup(core: CoreSetup, plugins: ReportingSetupDeps) { const { http } = core; - const { screenshotMode, features, licensing, security, spaces, taskManager } = plugins; + const { features, licensing, security, spaces, taskManager } = plugins; const reportingCore = new ReportingCore(this.logger, this.initContext); @@ -53,7 +52,6 @@ export class ReportingPlugin const router = http.createRouter(); const basePath = http.basePath; reportingCore.pluginSetup({ - screenshotMode, features, licensing, basePath, @@ -98,11 +96,9 @@ export class ReportingPlugin (async () => { await reportingCore.pluginSetsUp(); - const browserDriverFactory = await initializeBrowserDriverFactory(reportingCore, this.logger); const store = new ReportingStore(reportingCore, this.logger); await reportingCore.pluginStart({ - browserDriverFactory, savedObjects: core.savedObjects, uiSettings: core.uiSettings, store, @@ -110,6 +106,7 @@ export class ReportingPlugin data: plugins.data, taskManager: plugins.taskManager, logger: this.logger, + screenshotting: plugins.screenshotting, }); // Note: this must be called after ReportingCore.pluginStart diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts index a27ce6a49b1a2..47dae7f96daa4 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts @@ -6,11 +6,10 @@ */ import { UnwrapPromise } from '@kbn/utility-types'; -import { spawn } from 'child_process'; -import { createInterface } from 'readline'; import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import * as Rx from 'rxjs'; +import type { ScreenshottingStart } from '../../../../screenshotting/server'; import { ReportingCore } from '../..'; import { createMockConfigSchema, @@ -21,17 +20,11 @@ import { import type { ReportingRequestHandlerContext } from '../../types'; import { registerDiagnoseBrowser } from './browser'; -jest.mock('child_process'); -jest.mock('readline'); - type SetupServerReturn = UnwrapPromise>; const devtoolMessage = 'DevTools listening on (ws://localhost:4000)'; const fontNotFoundMessage = 'Could not find the default font'; -const wait = (ms: number): Rx.Observable<0> => - Rx.from(new Promise<0>((resolve) => setTimeout(() => resolve(0), ms))); - describe('POST /diagnose/browser', () => { jest.setTimeout(6000); const reportingSymbol = Symbol('reporting'); @@ -40,12 +33,11 @@ describe('POST /diagnose/browser', () => { let server: SetupServerReturn['server']; let httpSetup: SetupServerReturn['httpSetup']; let core: ReportingCore; - const mockedSpawn: any = spawn; - const mockedCreateInterface: any = createInterface; + let screenshotting: jest.Mocked; const config = createMockConfigSchema({ queue: { timeout: 120000 }, - capture: { browser: { chromium: { proxy: { enabled: false } } } }, + capture: {}, }); beforeEach(async () => { @@ -56,9 +48,6 @@ describe('POST /diagnose/browser', () => { () => ({ usesUiCapabilities: () => false }) ); - // Make all uses of 'Rx.timer' return an observable that completes in 50ms - jest.spyOn(Rx, 'timer').mockImplementation(() => wait(50)); - core = await createMockReportingCore( config, createMockPluginSetup({ @@ -67,21 +56,7 @@ describe('POST /diagnose/browser', () => { }) ); - mockedSpawn.mockImplementation(() => ({ - removeAllListeners: jest.fn(), - kill: jest.fn(), - pid: 123, - stderr: 'stderr', - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); - - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - })); + screenshotting = (await core.getPluginStartDeps()).screenshotting as typeof screenshotting; }); afterEach(async () => { @@ -94,12 +69,7 @@ describe('POST /diagnose/browser', () => { await server.start(); - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: (_e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0), - removeEventListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - })); + screenshotting.diagnose.mockReturnValue(Rx.of(devtoolMessage)); return supertest(httpSetup.server.listener) .post('/api/reporting/diagnose/browser') @@ -115,20 +85,7 @@ describe('POST /diagnose/browser', () => { registerDiagnoseBrowser(core, mockLogger); await server.start(); - - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: (_e: string, cb: any) => setTimeout(() => cb(logs), 0), - removeEventListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - })); - - mockedSpawn.mockImplementation(() => ({ - removeAllListeners: jest.fn(), - kill: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); + screenshotting.diagnose.mockReturnValue(Rx.of(logs)); return supertest(httpSetup.server.listener) .post('/api/reporting/diagnose/browser') @@ -139,8 +96,7 @@ describe('POST /diagnose/browser', () => { "help": Array [ "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", ], - "logs": "Could not find the default font - ", + "logs": "Could not find the default font", "success": false, } `); @@ -151,23 +107,7 @@ describe('POST /diagnose/browser', () => { registerDiagnoseBrowser(core, mockLogger); await server.start(); - - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: (_e: string, cb: any) => { - setTimeout(() => cb(devtoolMessage), 0); - setTimeout(() => cb(fontNotFoundMessage), 0); - }, - removeEventListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - })); - - mockedSpawn.mockImplementation(() => ({ - removeAllListeners: jest.fn(), - kill: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); + screenshotting.diagnose.mockReturnValue(Rx.of(`${devtoolMessage}\n${fontNotFoundMessage}`)); return supertest(httpSetup.server.listener) .post('/api/reporting/diagnose/browser') @@ -179,89 +119,10 @@ describe('POST /diagnose/browser', () => { "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", ], "logs": "DevTools listening on (ws://localhost:4000) - Could not find the default font - ", + Could not find the default font", "success": false, } `); }); }); - - it('logs a message when the browser starts, but then crashes', async () => { - registerDiagnoseBrowser(core, mockLogger); - - await server.start(); - - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: (_e: string, cb: any) => { - setTimeout(() => cb(fontNotFoundMessage), 0); - }, - removeEventListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - })); - - mockedSpawn.mockImplementation(() => ({ - removeAllListeners: jest.fn(), - kill: jest.fn(), - addEventListener: (e: string, cb: any) => { - if (e === 'exit') { - setTimeout(() => cb(), 5); - } - }, - removeEventListener: jest.fn(), - })); - - return supertest(httpSetup.server.listener) - .post('/api/reporting/diagnose/browser') - .expect(200) - .then(({ body }) => { - const helpArray = [...body.help]; - helpArray.sort(); - expect(helpArray).toMatchInlineSnapshot(` - Array [ - "The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.", - ] - `); - expect(body.logs).toMatch(/Could not find the default font/); - expect(body.logs).toMatch(/Browser exited abnormally during startup/); - expect(body.success).toBe(false); - }); - }); - - it('cleans up process and subscribers', async () => { - registerDiagnoseBrowser(core, mockLogger); - - await server.start(); - const killMock = jest.fn(); - const spawnListenersMock = jest.fn(); - const createInterfaceListenersMock = jest.fn(); - const createInterfaceCloseMock = jest.fn(); - - mockedSpawn.mockImplementation(() => ({ - removeAllListeners: spawnListenersMock, - kill: killMock, - pid: 123, - stderr: 'stderr', - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); - - mockedCreateInterface.mockImplementation(() => ({ - addEventListener: (_e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0), - removeEventListener: jest.fn(), - removeAllListeners: createInterfaceListenersMock, - close: createInterfaceCloseMock, - })); - - return supertest(httpSetup.server.listener) - .post('/api/reporting/diagnose/browser') - .expect(200) - .then(() => { - expect(killMock.mock.calls.length).toBe(1); - expect(spawnListenersMock.mock.calls.length).toBe(1); - expect(createInterfaceListenersMock.mock.calls.length).toBe(1); - expect(createInterfaceCloseMock.mock.calls.length).toBe(1); - }); - }); }); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts index 7793fc658c535..f68df294b4118 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; -import { browserStartLogs } from '../../browsers/chromium/driver_factory/start_logs'; import { LevelLogger as Logger } from '../../lib'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { DiagnosticResponse } from './'; @@ -52,7 +51,8 @@ export const registerDiagnoseBrowser = (reporting: ReportingCore, logger: Logger }, authorizedUserPreRouting(reporting, async (_user, _context, _req, res) => { try { - const logs = await browserStartLogs(reporting, logger).toPromise(); + const { screenshotting } = await reporting.getPluginStartDeps(); + const logs = await screenshotting.diagnose().toPromise(); const knownIssues = Object.keys(logsToHelpMap) as Array; const boundSuccessfully = logs.includes(`DevTools listening on`); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts index dd543707fe66a..4bc33d20d6fcf 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -20,7 +20,7 @@ import type { ReportingRequestHandlerContext } from '../../types'; jest.mock('../../export_types/common/generate_png'); -import { generatePngObservableFactory } from '../../export_types/common'; +import { generatePngObservable } from '../../export_types/common'; type SetupServerReturn = UnwrapPromise>; @@ -31,12 +31,12 @@ describe('POST /diagnose/screenshot', () => { let core: ReportingCore; const setScreenshotResponse = (resp: object | Error) => { - const generateMock = Promise.resolve(() => ({ + const generateMock = { pipe: () => ({ toPromise: () => (resp instanceof Error ? Promise.reject(resp) : Promise.resolve(resp)), }), - })); - (generatePngObservableFactory as jest.Mock).mockResolvedValue(generateMock); + }; + (generatePngObservable as jest.Mock).mockReturnValue(generateMock); }; const config = createMockConfigSchema({ queue: { timeout: 120000 } }); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index f2002dd945882..2d5a254045104 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { ReportingCore } from '../..'; import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; import { API_DIAGNOSE_URL } from '../../../common/constants'; -import { omitBlockedHeaders, generatePngObservableFactory } from '../../export_types/common'; +import { omitBlockedHeaders, generatePngObservable } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; import { LevelLogger as Logger } from '../../lib'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; @@ -25,7 +25,6 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log validate: {}, }, authorizedUserPreRouting(reporting, async (_user, _context, req, res) => { - const generatePngObservable = await generatePngObservableFactory(reporting); const config = reporting.getConfig(); const decryptedHeaders = req.headers as Record; const [basePath, protocol, hostname, port] = [ @@ -40,7 +39,6 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log // Hack the layout to make the base/login page work const layout = { - id: 'png', dimensions: { width: 1440, height: 2024, @@ -53,7 +51,7 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log }, }; - const headers = { + const conditionalHeaders = { headers: omitBlockedHeaders(decryptedHeaders), conditions: { hostname, @@ -63,7 +61,12 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log }, }; - return generatePngObservable(logger, hashUrl, 'America/Los_Angeles', headers, layout) + return generatePngObservable(reporting, logger, { + conditionalHeaders, + layout, + browserTimezone: 'America/Los_Angeles', + urls: [hashUrl], + }) .pipe() .toPromise() .then((screenshot) => { diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts index 5900a151f92da..6d73a3ec7ee74 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts @@ -103,7 +103,6 @@ describe('Handle request to generate', () => { "_primary_term": undefined, "_seq_no": undefined, "attempts": 0, - "browser_type": undefined, "completed_at": undefined, "created_by": "testymcgee", "jobtype": "printable_pdf", @@ -180,7 +179,6 @@ describe('Handle request to generate', () => { expect(snapObj).toMatchInlineSnapshot(` Object { "attempts": 0, - "browser_type": undefined, "completed_at": undefined, "created_by": "testymcgee", "index": ".reporting-foo-index-234", diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts deleted file mode 100644 index d42fb73b447a5..0000000000000 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts +++ /dev/null @@ -1,146 +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 moment from 'moment'; -import { Page } from 'puppeteer'; -import * as Rx from 'rxjs'; -import { ReportingCore } from '..'; -import { chromium, HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../browsers'; -import { LevelLogger } from '../lib'; -import { ElementsPositionAndAttribute } from '../lib/screenshots'; -import * as contexts from '../lib/screenshots/constants'; -import { CaptureConfig } from '../types'; - -interface CreateMockBrowserDriverFactoryOpts { - evaluate: jest.Mock, any[]>; - waitForSelector: jest.Mock, any[]>; - waitFor: jest.Mock, any[]>; - screenshot: jest.Mock, any[]>; - open: jest.Mock, any[]>; - getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock; -} - -const mockSelectors = { - renderComplete: 'renderedSelector', - itemsCountAttribute: 'itemsSelector', - screenshot: 'screenshotSelector', - timefilterDurationAttribute: 'timefilterDurationSelector', - toastHeader: 'toastHeaderSelector', -}; - -const getMockElementsPositionAndAttributes = ( - title: string, - description: string -): ElementsPositionAndAttribute[] => [ - { - position: { - boundingClientRect: { top: 0, left: 0, width: 800, height: 600 }, - scroll: { x: 0, y: 0 }, - }, - attributes: { title, description }, - }, -]; - -const mockWaitForSelector = jest.fn(); -mockWaitForSelector.mockImplementation((selectorArg: string) => { - const { renderComplete, itemsCountAttribute, toastHeader } = mockSelectors; - if (selectorArg === `${renderComplete},[${itemsCountAttribute}]`) { - return Promise.resolve(true); - } else if (selectorArg === toastHeader) { - return Rx.never().toPromise(); - } - throw new Error(selectorArg); -}); -const mockBrowserEvaluate = jest.fn(); -mockBrowserEvaluate.mockImplementation(() => { - const lastCallIndex = mockBrowserEvaluate.mock.calls.length - 1; - const { context: mockCall } = mockBrowserEvaluate.mock.calls[lastCallIndex][1]; - - if (mockCall === contexts.CONTEXT_SKIPTELEMETRY) { - return Promise.resolve(); - } - if (mockCall === contexts.CONTEXT_GETNUMBEROFITEMS) { - return Promise.resolve(1); - } - if (mockCall === contexts.CONTEXT_INJECTCSS) { - return Promise.resolve(); - } - if (mockCall === contexts.CONTEXT_WAITFORRENDER) { - return Promise.resolve(); - } - if (mockCall === contexts.CONTEXT_GETTIMERANGE) { - return Promise.resolve('Default GetTimeRange Result'); - } - if (mockCall === contexts.CONTEXT_ELEMENTATTRIBUTES) { - return Promise.resolve(getMockElementsPositionAndAttributes('Default Mock Title', 'Default ')); - } - if (mockCall === contexts.CONTEXT_GETRENDERERRORS) { - return Promise.resolve(); - } - throw new Error(mockCall); -}); -const mockScreenshot = jest.fn(async () => Buffer.from('screenshot')); -const getCreatePage = (driver: HeadlessChromiumDriver) => - jest.fn().mockImplementation(() => Rx.of({ driver, exit$: Rx.never() })); - -const defaultOpts: CreateMockBrowserDriverFactoryOpts = { - evaluate: mockBrowserEvaluate, - waitForSelector: mockWaitForSelector, - waitFor: jest.fn(), - screenshot: mockScreenshot, - open: jest.fn(), - getCreatePage, -}; - -export const createMockBrowserDriverFactory = async ( - core: ReportingCore, - logger: LevelLogger, - opts: Partial = {} -): Promise => { - const captureConfig: CaptureConfig = { - timeouts: { - openUrl: moment.duration(60, 's'), - waitForElements: moment.duration(30, 's'), - renderComplete: moment.duration(30, 's'), - }, - browser: { - type: 'chromium', - chromium: { - inspect: false, - disableSandbox: false, - proxy: { enabled: false, server: undefined, bypass: undefined }, - }, - autoDownload: false, - }, - networkPolicy: { enabled: true, rules: [] }, - loadDelay: moment.duration(2, 's'), - zoom: 2, - maxAttempts: 1, - }; - - const binaryPath = '/usr/local/share/common/secure/super_awesome_binary'; - const mockBrowserDriverFactory = chromium.createDriverFactory(core, binaryPath, logger); - const mockPage = { setViewport: () => {} } as unknown as Page; - const mockBrowserDriver = new HeadlessChromiumDriver(core, mockPage, { - inspect: true, - networkPolicy: captureConfig.networkPolicy, - }); - - // mock the driver methods as either default mocks or passed-in - mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore - mockBrowserDriver.waitFor = opts.waitFor ? opts.waitFor : defaultOpts.waitFor; - mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate; - mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot; - mockBrowserDriver.open = opts.open ? opts.open : defaultOpts.open; - mockBrowserDriver.isPageOpen = () => true; - - mockBrowserDriverFactory.createPage = opts.getCreatePage - ? opts.getCreatePage(mockBrowserDriver) - : getCreatePage(mockBrowserDriver); - - return mockBrowserDriverFactory; -}; diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index c05b2c54aeabf..0569ea1400555 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -7,7 +7,6 @@ jest.mock('../routes'); jest.mock('../usage'); -jest.mock('../browsers'); import _ from 'lodash'; import * as Rx from 'rxjs'; @@ -18,24 +17,15 @@ import { FieldFormatsRegistry } from 'src/plugins/field_formats/common'; import { ReportingConfig, ReportingCore } from '../'; import { featuresPluginMock } from '../../../features/server/mocks'; import { securityMock } from '../../../security/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { createMockScreenshottingStart } from '../../../screenshotting/server/mock'; import { taskManagerMock } from '../../../task_manager/server/mocks'; -import { - chromium, - HeadlessChromiumDriverFactory, - initializeBrowserDriverFactory, -} from '../browsers'; import { ReportingConfigType } from '../config'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStore } from '../lib'; import { setFieldFormats } from '../services'; import { createMockLevelLogger } from './create_mock_levellogger'; -( - initializeBrowserDriverFactory as jest.Mock> -).mockImplementation(() => Promise.resolve({} as HeadlessChromiumDriverFactory)); - -(chromium as any).createDriverFactory.mockImplementation(() => ({})); - export const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup => { return { features: featuresPluginMock.createSetup(), @@ -63,7 +53,6 @@ export const createMockPluginStart = ( : createMockReportingStore(); return { - browserDriverFactory: startMock.browserDriverFactory, esClient: elasticsearchServiceMock.createClusterClient(), savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, @@ -74,6 +63,7 @@ export const createMockPluginStart = ( ensureScheduled: jest.fn(), } as any, logger: createMockLevelLogger(), + screenshotting: startMock.screenshotting || createMockScreenshottingStart(), ...startMock, }; }; @@ -102,14 +92,6 @@ export const createMockConfigSchema = ( port: 80, ...overrides.kibanaServer, }, - capture: { - browser: { - chromium: { - disableSandbox: true, - }, - }, - ...overrides.capture, - }, queue: { indexInterval: 'week', pollEnabled: true, diff --git a/x-pack/plugins/reporting/server/test_helpers/index.ts b/x-pack/plugins/reporting/server/test_helpers/index.ts index fe8c92d928af5..667c85c24a35d 100644 --- a/x-pack/plugins/reporting/server/test_helpers/index.ts +++ b/x-pack/plugins/reporting/server/test_helpers/index.ts @@ -5,8 +5,6 @@ * 2.0. */ -export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; -export { createMockLayoutInstance } from './create_mock_layoutinstance'; export { createMockLevelLogger } from './create_mock_levellogger'; export { createMockConfig, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index af9a973b0bb45..3b1e819f0863c 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -11,13 +11,17 @@ import { DataPluginStart } from 'src/plugins/data/server/plugin'; import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { Writable } from 'stream'; +import type { + ScreenshottingStart, + ScreenshotOptions as BaseScreenshotOptions, +} from '../../screenshotting/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../security/server'; import { SpacesPluginSetup } from '../../spaces/server'; import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { CancellationToken } from '../common'; -import { BaseParams, BasePayload, TaskRunResult } from '../common/types'; +import { BaseParams, BasePayload, TaskRunResult, UrlOrUrlLocatorTuple } from '../common/types'; import { ReportingConfigType } from './config'; import { ReportingCore } from './core'; import { LevelLogger } from './lib'; @@ -39,6 +43,7 @@ export interface ReportingSetupDeps { export interface ReportingStartDeps { data: DataPluginStart; + screenshotting: ScreenshottingStart; taskManager: TaskManagerStartContract; } @@ -109,3 +114,10 @@ export interface ReportingRequestHandlerContext { * @internal */ export type ReportingPluginRouter = IRouter; + +/** + * @internal + */ +export interface ScreenshotOptions extends Omit { + urls: UrlOrUrlLocatorTuple[]; +} diff --git a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap index 2017ae0be59c7..78bb9ab6df51f 100644 --- a/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap +++ b/x-pack/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap @@ -129,9 +129,6 @@ Object { "available": Object { "type": "boolean", }, - "browser_type": Object { - "type": "keyword", - }, "csv": Object { "app": Object { "canvas workpad": Object { @@ -1973,7 +1970,6 @@ Object { }, "_all": 9, "available": true, - "browser_type": undefined, "csv": Object { "app": Object { "canvas workpad": 0, @@ -2243,7 +2239,6 @@ Object { }, "_all": 0, "available": true, - "browser_type": undefined, "csv": Object { "app": Object { "canvas workpad": 0, @@ -2492,7 +2487,6 @@ Object { }, "_all": 4, "available": true, - "browser_type": undefined, "csv": Object { "app": Object { "canvas workpad": 0, @@ -2768,7 +2762,6 @@ Object { }, "_all": 11, "available": true, - "browser_type": undefined, "csv": Object { "app": Object { "canvas workpad": 0, diff --git a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts index 73a4920b350e3..59387923e3755 100644 --- a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts @@ -206,10 +206,6 @@ export async function getReportingUsage( .search(params) .then(({ body: response }) => handleResponse(response)) .then((usage: Partial): ReportingUsageType => { - // Allow this to explicitly throw an exception if/when this config is deprecated, - // because we shouldn't collect browserType in that case! - const browserType = config.get('capture', 'browser', 'type'); - const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); const availability = exportTypesHandler.getAvailability( featureAvailability @@ -219,7 +215,6 @@ export async function getReportingUsage( return { available: true, - browser_type: browserType, enabled: true, last7Days: getExportStats(last7Days, availability, exportTypesHandler), ...getExportStats(all, availability, exportTypesHandler), diff --git a/x-pack/plugins/reporting/server/usage/schema.ts b/x-pack/plugins/reporting/server/usage/schema.ts index 9580ddb935dfb..fc464903edaee 100644 --- a/x-pack/plugins/reporting/server/usage/schema.ts +++ b/x-pack/plugins/reporting/server/usage/schema.ts @@ -92,7 +92,6 @@ const rangeStatsSchema: MakeSchemaFrom = { export const reportingSchema: MakeSchemaFrom = { ...rangeStatsSchema, available: { type: 'boolean' }, - browser_type: { type: 'keyword' }, enabled: { type: 'boolean' }, last7Days: rangeStatsSchema, }; diff --git a/x-pack/plugins/reporting/server/usage/types.ts b/x-pack/plugins/reporting/server/usage/types.ts index 856d3ad10cb26..e6695abc8da74 100644 --- a/x-pack/plugins/reporting/server/usage/types.ts +++ b/x-pack/plugins/reporting/server/usage/types.ts @@ -129,7 +129,6 @@ export type RangeStats = JobTypes & { export type ReportingUsageType = RangeStats & { available: boolean; - browser_type: string; enabled: boolean; last7Days: RangeStats; }; diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json index 3e58450565720..4e09708915f95 100644 --- a/x-pack/plugins/reporting/tsconfig.json +++ b/x-pack/plugins/reporting/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../../../src/plugins/field_formats/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, + { "path": "../screenshotting/tsconfig.json" }, { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, ] diff --git a/x-pack/plugins/screenshotting/README.md b/x-pack/plugins/screenshotting/README.md new file mode 100644 index 0000000000000..3439f06dff8e5 --- /dev/null +++ b/x-pack/plugins/screenshotting/README.md @@ -0,0 +1,11 @@ +# Kibana Screenshotting + +This plugin provides functionality to take screenshots of the Kibana pages. +It uses Chromium and Puppeteer underneath to run the browser in headless mode. + +## API + +The plugin exposes most of the functionality in the start contract. +The Chromium download and setup is happening during the setup stage. + +To learn more about the public API, please use automatically generated API reference or generated TypeDoc comments. diff --git a/x-pack/plugins/screenshotting/common/context.ts b/x-pack/plugins/screenshotting/common/context.ts new file mode 100644 index 0000000000000..c47f8706533b8 --- /dev/null +++ b/x-pack/plugins/screenshotting/common/context.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +/** + * Screenshot context. + * This is a serializable object that can be passed from the screenshotting backend and then deserialized on the target page. + */ +export type Context = Record; + +/** + * @interal + */ +export const SCREENSHOTTING_CONTEXT_KEY = '__SCREENSHOTTING_CONTEXT_KEY__'; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/index.ts b/x-pack/plugins/screenshotting/common/index.ts similarity index 66% rename from x-pack/plugins/reporting/server/browsers/chromium/driver/index.ts rename to x-pack/plugins/screenshotting/common/index.ts index afd31608d5a6e..04296dd5426b5 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/index.ts +++ b/x-pack/plugins/screenshotting/common/index.ts @@ -5,4 +5,6 @@ * 2.0. */ -export { HeadlessChromiumDriver } from './chromium_driver'; +export type { Context } from './context'; +export type { LayoutParams } from './layout'; +export { LayoutTypes } from './layout'; diff --git a/x-pack/plugins/screenshotting/common/layout.ts b/x-pack/plugins/screenshotting/common/layout.ts new file mode 100644 index 0000000000000..aade05eeea04e --- /dev/null +++ b/x-pack/plugins/screenshotting/common/layout.ts @@ -0,0 +1,75 @@ +/* + * 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 type { Ensure, SerializableRecord } from '@kbn/utility-types'; + +/** + * @internal + */ +export type Size = Ensure< + { + /** + * Layout width. + */ + width: number; + + /** + * Layout height. + */ + height: number; + }, + SerializableRecord +>; + +/** + * @internal + */ +export interface LayoutSelectorDictionary { + screenshot: string; + renderComplete: string; + renderError: string; + renderErrorAttribute: string; + itemsCountAttribute: string; + timefilterDurationAttribute: string; +} + +/** + * Screenshot layout parameters. + */ +export type LayoutParams = Ensure< + { + /** + * Unique layout name. + */ + id?: string; + + /** + * Layout sizing. + */ + dimensions?: Size; + + /** + * Element selectors determining the page state. + */ + selectors?: Partial; + + /** + * Page zoom. + */ + zoom?: number; + }, + SerializableRecord +>; + +/** + * Supported layout types. + */ +export const LayoutTypes = { + PRESERVE_LAYOUT: 'preserve_layout', + PRINT: 'print', + CANVAS: 'canvas', // no margins or branding in the layout +}; diff --git a/x-pack/plugins/screenshotting/jest.config.js b/x-pack/plugins/screenshotting/jest.config.js new file mode 100644 index 0000000000000..a02d667f86a19 --- /dev/null +++ b/x-pack/plugins/screenshotting/jest.config.js @@ -0,0 +1,15 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/screenshotting'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/screenshotting', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/plugins/screenshotting/server/**/*.{ts}'], +}; diff --git a/x-pack/plugins/screenshotting/kibana.json b/x-pack/plugins/screenshotting/kibana.json new file mode 100644 index 0000000000000..32446551627e0 --- /dev/null +++ b/x-pack/plugins/screenshotting/kibana.json @@ -0,0 +1,14 @@ +{ + "id": "screenshotting", + "version": "8.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Kibana Reporting Services", + "githubTeam": "kibana-reporting-services" + }, + "description": "Kibana Screenshotting Plugin", + "requiredPlugins": ["screenshotMode"], + "configPath": ["xpack", "screenshotting"], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/screenshotting/public/context_storage.ts b/x-pack/plugins/screenshotting/public/context_storage.ts new file mode 100644 index 0000000000000..76a2cf231cf83 --- /dev/null +++ b/x-pack/plugins/screenshotting/public/context_storage.ts @@ -0,0 +1,20 @@ +/* + * 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 { Context, SCREENSHOTTING_CONTEXT_KEY } from '../common/context'; + +declare global { + interface Window { + [SCREENSHOTTING_CONTEXT_KEY]?: Context; + } +} + +export class ContextStorage { + get(): T { + return (window[SCREENSHOTTING_CONTEXT_KEY] ?? {}) as T; + } +} diff --git a/x-pack/plugins/screenshotting/public/index.ts b/x-pack/plugins/screenshotting/public/index.ts new file mode 100644 index 0000000000000..659dbc81917a7 --- /dev/null +++ b/x-pack/plugins/screenshotting/public/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { ScreenshottingPlugin } from './plugin'; + +/** + * Screenshotting plugin entry point. + */ +export function plugin(...args: ConstructorParameters) { + return new ScreenshottingPlugin(...args); +} + +export { LayoutTypes } from '../common'; +export type { ScreenshottingSetup, ScreenshottingStart } from './plugin'; diff --git a/x-pack/plugins/screenshotting/public/plugin.tsx b/x-pack/plugins/screenshotting/public/plugin.tsx new file mode 100755 index 0000000000000..4ba5046b8a881 --- /dev/null +++ b/x-pack/plugins/screenshotting/public/plugin.tsx @@ -0,0 +1,42 @@ +/* + * 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 type { Plugin } from 'src/core/public'; +import { ContextStorage } from './context_storage'; + +/** + * Setup public contract. + */ +export interface ScreenshottingSetup { + /** + * Gathers screenshot context that has been set on the backend. + */ + getContext: ContextStorage['get']; +} + +/** + * Start public contract. + */ +export type ScreenshottingStart = ScreenshottingSetup; + +export class ScreenshottingPlugin implements Plugin { + private contextStorage = new ContextStorage(); + + setup(): ScreenshottingSetup { + return { + getContext: () => this.contextStorage.get(), + }; + } + + start(): ScreenshottingStart { + return { + getContext: () => this.contextStorage.get(), + }; + } + + stop() {} +} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts similarity index 75% rename from x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts rename to x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts index 0f2572ff2b2e4..245572efe9348 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts @@ -8,22 +8,56 @@ import { i18n } from '@kbn/i18n'; import { map, truncate } from 'lodash'; import open from 'opn'; -import puppeteer, { ElementHandle, EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; +import puppeteer, { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; -import type { LocatorParams } from '../../../../common/types'; -import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../../../common/constants'; -import { getDisallowedOutgoingUrlError } from '../'; -import { ReportingCore } from '../../..'; -import { KBN_SCREENSHOT_MODE_HEADER } from '../../../../../../../src/plugins/screenshot_mode/server'; -import { ConditionalHeaders, ConditionalHeadersConditions } from '../../../export_types/common'; -import { LevelLogger } from '../../../lib'; -import { Layout, ViewZoomWidthHeight } from '../../../lib/layouts/layout'; -import { ElementPosition } from '../../../lib/screenshots'; -import { allowRequest, NetworkPolicy } from '../../network_policy'; - -export interface ChromiumDriverOptions { - inspect: boolean; - networkPolicy: NetworkPolicy; +import { Logger } from 'src/core/server'; +import type { Layout } from 'src/plugins/screenshot_mode/common'; +import { + KBN_SCREENSHOT_MODE_HEADER, + ScreenshotModePluginSetup, +} from '../../../../../../src/plugins/screenshot_mode/server'; +import { Context, SCREENSHOTTING_CONTEXT_KEY } from '../../../common/context'; +import { ConfigType } from '../../config'; +import { allowRequest } from '../network_policy'; + +export interface ConditionalHeadersConditions { + protocol: string; + hostname: string; + port: number; + basePath: string; +} + +export interface ConditionalHeaders { + headers: Record; + conditions: ConditionalHeadersConditions; +} + +export interface ElementPosition { + boundingClientRect: { + // modern browsers support x/y, but older ones don't + top: number; + left: number; + width: number; + height: number; + }; + scroll: { + x: number; + y: number; + }; +} + +export interface Viewport { + zoom: number; + width: number; + height: number; +} + +interface OpenOptions { + conditionalHeaders: ConditionalHeaders; + context?: Context; + waitForSelector: string; + timeout: number; + layout?: Layout; } interface WaitForSelectorOpts { @@ -56,28 +90,30 @@ interface InterceptedRequest { const WAIT_FOR_DELAY_MS: number = 100; -export class HeadlessChromiumDriver { - private readonly page: puppeteer.Page; - private readonly inspect: boolean; - private readonly networkPolicy: NetworkPolicy; +function getDisallowedOutgoingUrlError(interceptedUrl: string) { + return new Error( + i18n.translate('xpack.screenshotting.chromiumDriver.disallowedOutgoingUrl', { + defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}". Failing the request and closing the browser.`, + values: { interceptedUrl }, + }) + ); +} +/** + * @internal + */ +export class HeadlessChromiumDriver { private listenersAttached = false; private interceptedCount = 0; - private core: ReportingCore; constructor( - core: ReportingCore, - page: puppeteer.Page, - { inspect, networkPolicy }: ChromiumDriverOptions - ) { - this.core = core; - this.page = page; - this.inspect = inspect; - this.networkPolicy = networkPolicy; - } + private screenshotMode: ScreenshotModePluginSetup, + private config: ConfigType, + private readonly page: Page + ) {} private allowRequest(url: string) { - return !this.networkPolicy.enabled || allowRequest(url, this.networkPolicy.rules); + return !this.config.networkPolicy.enabled || allowRequest(url, this.config.networkPolicy.rules); } private truncateUrl(url: string) { @@ -90,22 +126,16 @@ export class HeadlessChromiumDriver { /* * Call Page.goto and wait to see the Kibana DOM content */ - public async open( + async open( url: string, { conditionalHeaders, + context, + layout, waitForSelector: pageLoadSelector, timeout, - locator, - layout, - }: { - conditionalHeaders: ConditionalHeaders; - waitForSelector: string; - timeout: number; - locator?: LocatorParams; - layout?: Layout; - }, - logger: LevelLogger + }: OpenOptions, + logger: Logger ): Promise { logger.info(`opening url ${url}`); @@ -116,13 +146,9 @@ export class HeadlessChromiumDriver { * Integrate with the screenshot mode plugin contract by calling this function before any other * scripts have run on the browser page. */ - await this.page.evaluateOnNewDocument(this.core.getEnableScreenshotMode()); - - if (layout) { - await this.page.evaluateOnNewDocument(this.core.getSetScreenshotLayout(), layout.id); - } + await this.page.evaluateOnNewDocument(this.screenshotMode.setScreenshotModeEnabled); - if (locator) { + if (context) { await this.page.evaluateOnNewDocument( (key: string, value: unknown) => { Object.defineProperty(window, key, { @@ -132,18 +158,20 @@ export class HeadlessChromiumDriver { value, }); }, - REPORTING_REDIRECT_LOCATOR_STORE_KEY, - locator + SCREENSHOTTING_CONTEXT_KEY, + context ); } - await this.page.setRequestInterception(true); + if (layout) { + await this.page.evaluateOnNewDocument(this.screenshotMode.setScreenshotLayout, layout); + } + await this.page.setRequestInterception(true); this.registerListeners(conditionalHeaders, logger); - await this.page.goto(url, { waitUntil: 'domcontentloaded' }); - if (this.inspect) { + if (this.config.browser.chromium.inspect) { await this.launchDebugger(); } @@ -159,14 +187,14 @@ export class HeadlessChromiumDriver { /* * Let modules poll if Chrome is still running so they can short circuit if needed */ - public isPageOpen() { + isPageOpen() { return !this.page.isClosed(); } /* * Call Page.screenshot and return a base64-encoded string of the image */ - public async screenshot(elementPosition: ElementPosition): Promise { + async screenshot(elementPosition: ElementPosition): Promise { const { boundingClientRect, scroll } = elementPosition; const screenshot = await this.page.screenshot({ clip: { @@ -188,32 +216,28 @@ export class HeadlessChromiumDriver { return undefined; } - public async evaluate( - { fn, args = [] }: EvaluateOpts, - meta: EvaluateMetaOpts, - logger: LevelLogger - ) { + evaluate({ fn, args = [] }: EvaluateOpts, meta: EvaluateMetaOpts, logger: Logger): Promise { logger.debug(`evaluate ${meta.context}`); - const result = await this.page.evaluate(fn, ...args); - return result; + + return this.page.evaluate(fn, ...args); } - public async waitForSelector( + async waitForSelector( selector: string, opts: WaitForSelectorOpts, context: EvaluateMetaOpts, - logger: LevelLogger + logger: Logger ): Promise> { const { timeout } = opts; logger.debug(`waitForSelector ${selector}`); - const resp = await this.page.waitForSelector(selector, { timeout }); // override default 30000ms + const response = await this.page.waitForSelector(selector, { timeout }); // override default 30000ms - if (!resp) { + if (!response) { throw new Error(`Failure in waitForSelector: void response! Context: ${context.context}`); } logger.debug(`waitForSelector ${selector} resolved`); - return resp; + return response; } public async waitFor({ @@ -228,9 +252,9 @@ export class HeadlessChromiumDriver { await this.page.waitForFunction(fn, { timeout, polling: WAIT_FOR_DELAY_MS }, ...args); } - public async setViewport( - { width: _width, height: _height, zoom }: ViewZoomWidthHeight, - logger: LevelLogger + async setViewport( + { width: _width, height: _height, zoom }: Viewport, + logger: Logger ): Promise { const width = Math.floor(_width); const height = Math.floor(_height); @@ -245,7 +269,7 @@ export class HeadlessChromiumDriver { }); } - private registerListeners(conditionalHeaders: ConditionalHeaders, logger: LevelLogger) { + private registerListeners(conditionalHeaders: ConditionalHeaders, logger: Logger) { if (this.listenersAttached) { return; } @@ -300,10 +324,13 @@ export class HeadlessChromiumDriver { }); } catch (err) { logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { - defaultMessage: 'Failed to complete a request using headers: {error}', - values: { error: err }, - }) + i18n.translate( + 'xpack.screenshotting.chromiumDriver.failedToCompleteRequestUsingHeaders', + { + defaultMessage: 'Failed to complete a request using headers: {error}', + values: { error: err }, + } + ) ); } } else { @@ -313,7 +340,7 @@ export class HeadlessChromiumDriver { await client.send('Fetch.continueRequest', { requestId }); } catch (err) { logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { + i18n.translate('xpack.screenshotting.chromiumDriver.failedToCompleteRequest', { defaultMessage: 'Failed to complete a request: {error}', values: { error: err }, }) diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/args.ts similarity index 81% rename from x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts rename to x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/args.ts index 07ae13fa31849..e5985082b3c1c 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/args.ts @@ -5,18 +5,23 @@ * 2.0. */ -import { CaptureConfig } from '../../../../server/types'; -import { DEFAULT_VIEWPORT } from '../../../../common/constants'; +import type { ConfigType } from '../../../config'; -type BrowserConfig = CaptureConfig['browser']['chromium']; +interface Viewport { + height: number; + width: number; +} + +type Proxy = ConfigType['browser']['chromium']['proxy']; interface LaunchArgs { userDataDir: string; - disableSandbox: BrowserConfig['disableSandbox']; - proxy: BrowserConfig['proxy']; + viewport?: Viewport; + disableSandbox?: boolean; + proxy: Proxy; } -export const args = ({ userDataDir, disableSandbox, proxy: proxyConfig }: LaunchArgs) => { +export const args = ({ userDataDir, disableSandbox, viewport, proxy: proxyConfig }: LaunchArgs) => { const flags = [ // Disable built-in Google Translate service '--disable-translate', @@ -41,14 +46,17 @@ export const args = ({ userDataDir, disableSandbox, proxy: proxyConfig }: Launch '--disable-gpu', '--headless', '--hide-scrollbars', - // NOTE: setting the window size does NOT set the viewport size: viewport and window size are different. - // The viewport may later need to be resized depending on the position of the clip area. - // These numbers come from the job parameters, so this is a close guess. - `--window-size=${Math.floor(DEFAULT_VIEWPORT.width)},${Math.floor(DEFAULT_VIEWPORT.height)}`, // allow screenshot clip region to go outside of the viewport `--mainFrameClipsContent=false`, ]; + if (viewport) { + // NOTE: setting the window size does NOT set the viewport size: viewport and window size are different. + // The viewport may later need to be resized depending on the position of the clip area. + // These numbers come from the job parameters, so this is a close guess. + flags.push(`--window-size=${Math.floor(viewport.width)},${Math.floor(viewport.height)}`); + } + if (proxyConfig.enabled) { flags.push(`--proxy-server=${proxyConfig.server}`); if (proxyConfig.bypass) { diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts new file mode 100644 index 0000000000000..23e276541465a --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.test.ts @@ -0,0 +1,84 @@ +/* + * 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 puppeteer from 'puppeteer'; +import * as Rx from 'rxjs'; +import { take } from 'rxjs/operators'; +import type { Logger } from 'src/core/server'; +import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; +import { ConfigType } from '../../../config'; +import { HeadlessChromiumDriverFactory } from '.'; + +jest.mock('puppeteer'); + +describe('HeadlessChromiumDriverFactory', () => { + const path = 'path/to/headless_shell'; + const config = { + browser: { + chromium: { + proxy: {}, + }, + }, + } as ConfigType; + let logger: jest.Mocked; + let screenshotMode: jest.Mocked; + let factory: HeadlessChromiumDriverFactory; + + beforeEach(async () => { + logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + get: jest.fn(() => logger), + } as unknown as typeof logger; + screenshotMode = {} as unknown as typeof screenshotMode; + + (puppeteer as jest.Mocked).launch.mockResolvedValue({ + newPage: jest.fn().mockResolvedValue({ + target: jest.fn(() => ({ + createCDPSession: jest.fn().mockResolvedValue({ + send: jest.fn(), + }), + })), + emulateTimezone: jest.fn(), + setDefaultTimeout: jest.fn(), + }), + close: jest.fn(), + process: jest.fn(), + } as unknown as puppeteer.Browser); + + factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path); + jest.spyOn(factory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY); + jest.spyOn(factory, 'getProcessLogger').mockReturnValue(Rx.EMPTY); + jest.spyOn(factory, 'getPageExit').mockReturnValue(Rx.EMPTY); + }); + + describe('createPage', () => { + it('returns browser driver and process exit observable', async () => { + await expect( + factory.createPage({ openUrlTimeout: 0 }).pipe(take(1)).toPromise() + ).resolves.toEqual( + expect.objectContaining({ + driver: expect.anything(), + exit$: expect.anything(), + }) + ); + }); + + it('rejects if Puppeteer launch fails', async () => { + (puppeteer as jest.Mocked).launch.mockRejectedValue( + `Puppeteer Launch mock fail.` + ); + expect(() => + factory.createPage({ openUrlTimeout: 0 }).pipe(take(1)).toPromise() + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error spawning Chromium browser! Puppeteer Launch mock fail."` + ); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts new file mode 100644 index 0000000000000..e9656013140c2 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -0,0 +1,379 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { getDataPath } from '@kbn/utils'; +import { spawn } from 'child_process'; +import del from 'del'; +import fs from 'fs'; +import { uniq } from 'lodash'; +import path from 'path'; +import puppeteer, { Browser, ConsoleMessage, HTTPRequest, Page } from 'puppeteer'; +import { createInterface } from 'readline'; +import * as Rx from 'rxjs'; +import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; +import { catchError, ignoreElements, map, mergeMap, reduce, takeUntil, tap } from 'rxjs/operators'; +import type { Logger } from 'src/core/server'; +import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; +import { ConfigType } from '../../../config'; +import { getChromiumDisconnectedError } from '../'; +import { safeChildProcess } from '../../safe_child_process'; +import { HeadlessChromiumDriver } from '../driver'; +import { args } from './args'; +import { getMetrics, PerformanceMetrics } from './metrics'; + +interface CreatePageOptions { + browserTimezone?: string; + openUrlTimeout: number; +} + +interface CreatePageResult { + driver: HeadlessChromiumDriver; + exit$: Rx.Observable; + metrics$: Rx.Observable; +} + +export const DEFAULT_VIEWPORT = { + width: 1950, + height: 1200, +}; + +// Default args used by pptr +// https://github.com/puppeteer/puppeteer/blob/13ea347/src/node/Launcher.ts#L168 +const DEFAULT_ARGS = [ + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=TranslateUI', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', + '--remote-debugging-port=0', + '--headless', +]; + +const DIAGNOSTIC_TIME = 5 * 1000; + +export class HeadlessChromiumDriverFactory { + private userDataDir = fs.mkdtempSync(path.join(getDataPath(), 'chromium-')); + type = 'chromium'; + + constructor( + private screenshotMode: ScreenshotModePluginSetup, + private config: ConfigType, + private logger: Logger, + private binaryPath: string + ) { + if (this.config.browser.chromium.disableSandbox) { + logger.warn(`Enabling the Chromium sandbox provides an additional layer of protection.`); + } + } + + private getChromiumArgs() { + return args({ + userDataDir: this.userDataDir, + disableSandbox: this.config.browser.chromium.disableSandbox, + proxy: this.config.browser.chromium.proxy, + viewport: DEFAULT_VIEWPORT, + }); + } + + /* + * Return an observable to objects which will drive screenshot capture for a page + */ + createPage( + { browserTimezone, openUrlTimeout }: CreatePageOptions, + pLogger = this.logger + ): Rx.Observable { + // FIXME: 'create' is deprecated + return Rx.Observable.create(async (observer: InnerSubscriber) => { + const logger = pLogger.get('browser-driver'); + logger.info(`Creating browser page driver`); + + const chromiumArgs = this.getChromiumArgs(); + logger.debug(`Chromium launch args set to: ${chromiumArgs}`); + + let browser: Browser | undefined; + + try { + browser = await puppeteer.launch({ + pipe: !this.config.browser.chromium.inspect, + userDataDir: this.userDataDir, + executablePath: this.binaryPath, + ignoreHTTPSErrors: true, + handleSIGHUP: false, + args: chromiumArgs, + env: { + TZ: browserTimezone, + }, + }); + } catch (err) { + observer.error(new Error(`Error spawning Chromium browser! ${err}`)); + return; + } + + const page = await browser.newPage(); + const devTools = await page.target().createCDPSession(); + + await devTools.send('Performance.enable', { timeDomain: 'timeTicks' }); + const startMetrics = await devTools.send('Performance.getMetrics'); + const metrics$ = new Rx.Subject(); + + // Log version info for debugging / maintenance + const versionInfo = await devTools.send('Browser.getVersion'); + logger.debug(`Browser version: ${JSON.stringify(versionInfo)}`); + + await page.emulateTimezone(browserTimezone); + + // Set the default timeout for all navigation methods to the openUrl timeout + // All waitFor methods have their own timeout config passed in to them + page.setDefaultTimeout(openUrlTimeout); + + logger.debug(`Browser page driver created`); + + const childProcess = { + async kill() { + try { + if (devTools && startMetrics) { + const endMetrics = await devTools.send('Performance.getMetrics'); + const metrics = getMetrics(startMetrics, endMetrics); + const { cpuInPercentage, memoryInMegabytes } = metrics; + + metrics$.next(metrics); + logger.debug( + `Chromium consumed CPU ${cpuInPercentage}% Memory ${memoryInMegabytes}MB` + ); + } + } catch (error) { + logger.error(error); + } finally { + metrics$.complete(); + } + + try { + await browser?.close(); + } catch (err) { + // do not throw + logger.error(err); + } + }, + }; + const { terminate$ } = safeChildProcess(logger, childProcess); + + // this is adding unsubscribe logic to our observer + // so that if our observer unsubscribes, we terminate our child-process + observer.add(() => { + logger.debug(`The browser process observer has unsubscribed. Closing the browser...`); + childProcess.kill(); // ignore async + }); + + // make the observer subscribe to terminate$ + observer.add( + terminate$ + .pipe( + tap((signal) => { + logger.debug(`Termination signal received: ${signal}`); + }), + ignoreElements() + ) + .subscribe(observer) + ); + + // taps the browser log streams and combine them to Kibana logs + this.getBrowserLogger(page, logger).subscribe(); + this.getProcessLogger(browser, logger).subscribe(); + + // HeadlessChromiumDriver: object to "drive" a browser page + const driver = new HeadlessChromiumDriver(this.screenshotMode, this.config, page); + + // Rx.Observable: stream to interrupt page capture + const exit$ = this.getPageExit(browser, page); + + observer.next({ driver, exit$, metrics$: metrics$.asObservable() }); + + // unsubscribe logic makes a best-effort attempt to delete the user data directory used by chromium + observer.add(() => { + const userDataDir = this.userDataDir; + logger.debug(`deleting chromium user data directory at [${userDataDir}]`); + // the unsubscribe function isn't `async` so we're going to make our best effort at + // deleting the userDataDir and if it fails log an error. + del(userDataDir, { force: true }).catch((error) => { + logger.error(`error deleting user data directory at [${userDataDir}]!`); + logger.error(error); + }); + }); + }); + } + + getBrowserLogger(page: Page, logger: Logger): Rx.Observable { + const consoleMessages$ = Rx.fromEvent(page, 'console').pipe( + map((line) => { + const formatLine = () => `{ text: "${line.text()?.trim()}", url: ${line.location()?.url} }`; + + if (line.type() === 'error') { + logger.get('headless-browser-console').error(`Error in browser console: ${formatLine()}`); + } else { + logger + .get(`headless-browser-console:${line.type()}`) + .debug(`Message in browser console: ${formatLine()}`); + } + }) + ); + + const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( + map((err) => { + logger.warn( + i18n.translate('xpack.screenshotting.browsers.chromium.pageErrorDetected', { + defaultMessage: `Reporting encountered an uncaught error on the page that will be ignored: {err}`, + values: { err: err.toString() }, + }) + ); + }) + ); + + const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( + map((req) => { + const failure = req.failure && req.failure(); + if (failure) { + logger.warn( + `Request to [${req.url()}] failed! [${failure.errorText}]. This error will be ignored.` + ); + } + }) + ); + + return Rx.merge(consoleMessages$, uncaughtExceptionPageError$, pageRequestFailed$); + } + + getProcessLogger(browser: Browser, logger: Logger): Rx.Observable { + const childProcess = browser.process(); + // NOTE: The browser driver can not observe stdout and stderr of the child process + // Puppeteer doesn't give a handle to the original ChildProcess object + // See https://github.com/GoogleChrome/puppeteer/issues/1292#issuecomment-521470627 + + if (childProcess == null) { + throw new TypeError('childProcess is null or undefined!'); + } + + // just log closing of the process + const processClose$ = Rx.fromEvent(childProcess, 'close').pipe( + tap(() => { + logger.get('headless-browser-process').debug('child process closed'); + }) + ); + + return processClose$; // ideally, this would also merge with observers for stdout and stderr + } + + getPageExit(browser: Browser, page: Page) { + const pageError$ = Rx.fromEvent(page, 'error').pipe( + mergeMap((err) => { + return Rx.throwError( + i18n.translate('xpack.screenshotting.browsers.chromium.errorDetected', { + defaultMessage: 'Reporting encountered an error: {err}', + values: { err: err.toString() }, + }) + ); + }) + ); + + const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe( + mergeMap(() => Rx.throwError(getChromiumDisconnectedError())) + ); + + return Rx.merge(pageError$, browserDisconnect$); + } + + diagnose(overrideFlags: string[] = []): Rx.Observable { + const kbnArgs = this.getChromiumArgs(); + const finalArgs = uniq([...DEFAULT_ARGS, ...kbnArgs, ...overrideFlags]); + + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + const browserProcess = spawn(this.binaryPath, finalArgs, { + detached: process.platform !== 'win32', + }); + + const rl = createInterface({ input: browserProcess.stderr }); + + const exit$ = Rx.fromEvent(browserProcess, 'exit').pipe( + map((code) => { + this.logger.error(`Browser exited abnormally, received code: ${code}`); + return i18n.translate('xpack.screenshotting.diagnostic.browserCrashed', { + defaultMessage: `Browser exited abnormally during startup`, + }); + }) + ); + + const error$ = Rx.fromEvent(browserProcess, 'error').pipe( + map((err) => { + this.logger.error(`Browser process threw an error on startup`); + this.logger.error(err as string | Error); + return i18n.translate('xpack.screenshotting.diagnostic.browserErrored', { + defaultMessage: `Browser process threw an error on startup`, + }); + }) + ); + + const browserProcessLogger = this.logger.get('chromium-stderr'); + const log$ = Rx.fromEvent(rl, 'line').pipe( + tap((message: unknown) => { + if (typeof message === 'string') { + browserProcessLogger.info(message); + } + }) + ); + + // Collect all events (exit, error and on log-lines), but let chromium keep spitting out + // logs as sometimes it's "bind" successfully for remote connections, but later emit + // a log indicative of an issue (for example, no default font found). + return Rx.merge(exit$, error$, log$).pipe( + takeUntil(Rx.timer(DIAGNOSTIC_TIME)), + reduce((acc, curr) => `${acc}${curr}\n`, ''), + tap(() => { + if (browserProcess && browserProcess.pid && !browserProcess.killed) { + browserProcess.kill('SIGKILL'); + this.logger.info( + `Successfully sent 'SIGKILL' to browser process (PID: ${browserProcess.pid})` + ); + } + browserProcess.removeAllListeners(); + rl.removeAllListeners(); + rl.close(); + del(this.userDataDir, { force: true }).catch((error) => { + this.logger.error(`Error deleting user data directory at [${this.userDataDir}]!`); + this.logger.error(error); + }); + }), + catchError((error) => { + this.logger.error(error); + + return Rx.of(error); + }) + ); + } +} + +export type { PerformanceMetrics }; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/metrics.test.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/metrics.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/chromium/driver_factory/metrics.test.ts rename to x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/metrics.test.ts diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/metrics.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/metrics.ts similarity index 81% rename from x-pack/plugins/reporting/server/browsers/chromium/driver_factory/metrics.ts rename to x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/metrics.ts index 1659f28dea9b0..6e9971324ae4b 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/metrics.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/metrics.ts @@ -29,10 +29,28 @@ interface NormalizedMetrics extends Required { ProcessTime: number; } -interface PerformanceMetrics { +/** + * Collected performance metrics during a screenshotting session. + */ +export interface PerformanceMetrics { + /** + * The percentage of CPU time spent by the browser divided by number or cores. + */ cpu: number; + + /** + * The percentage of CPU in percent untis. + */ cpuInPercentage: number; + + /** + * The total amount of memory used by the browser. + */ memory: number; + + /** + * The total amount of memory used by the browser in megabytes. + */ memoryInMegabytes: number; } diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts new file mode 100644 index 0000000000000..c51ee0e8b8651 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const getChromiumDisconnectedError = () => + new Error( + i18n.translate('xpack.screenshotting.screencapture.browserWasClosed', { + defaultMessage: 'Browser was closed unexpectedly! Check the server logs for more info.', + }) + ); + +export { ChromiumArchivePaths } from './paths'; +export type { ConditionalHeaders } from './driver'; +export { HeadlessChromiumDriver } from './driver'; +export type { PerformanceMetrics } from './driver_factory'; +export { DEFAULT_VIEWPORT, HeadlessChromiumDriverFactory } from './driver_factory'; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/paths.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/chromium/paths.ts rename to x-pack/plugins/screenshotting/server/browsers/chromium/paths.ts diff --git a/x-pack/plugins/reporting/server/browsers/download/checksum.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/checksum.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/download/checksum.test.ts rename to x-pack/plugins/screenshotting/server/browsers/download/checksum.test.ts diff --git a/x-pack/plugins/reporting/server/browsers/download/checksum.ts b/x-pack/plugins/screenshotting/server/browsers/download/checksum.ts similarity index 62% rename from x-pack/plugins/reporting/server/browsers/download/checksum.ts rename to x-pack/plugins/screenshotting/server/browsers/download/checksum.ts index 35feb1ff534ab..9b177e0b4c756 100644 --- a/x-pack/plugins/reporting/server/browsers/download/checksum.ts +++ b/x-pack/plugins/screenshotting/server/browsers/download/checksum.ts @@ -7,16 +7,15 @@ import { createHash } from 'crypto'; import { createReadStream } from 'fs'; -import { Readable } from 'stream'; - -function readableEnd(stream: Readable) { - return new Promise((resolve, reject) => { - stream.on('error', reject).on('end', resolve); - }); -} +import { finished } from 'stream'; +import { promisify } from 'util'; export async function md5(path: string) { const hash = createHash('md5'); - await readableEnd(createReadStream(path).on('data', (chunk) => hash.update(chunk))); + const stream = createReadStream(path); + + stream.on('data', (chunk) => hash.update(chunk)); + await promisify(finished)(stream, { writable: false }); + return hash.digest('hex'); } diff --git a/x-pack/plugins/screenshotting/server/browsers/download/fetch.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/fetch.test.ts new file mode 100644 index 0000000000000..cc22f152216af --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/download/fetch.test.ts @@ -0,0 +1,57 @@ +/* + * 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 mockFs from 'mock-fs'; +import axios from 'axios'; +import { createHash } from 'crypto'; +import { readFileSync } from 'fs'; +import { resolve as resolvePath } from 'path'; +import { Readable } from 'stream'; +import { fetch } from './fetch'; + +const TEMP_DIR = resolvePath(__dirname, '__tmp__'); +const TEMP_FILE = resolvePath(TEMP_DIR, 'foo/bar/download'); + +describe('fetch', () => { + beforeEach(() => { + jest.spyOn(axios, 'request').mockResolvedValue({ + data: new Readable({ + read() { + this.push('foobar'); + this.push(null); + }, + }), + }); + + mockFs(); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + test('downloads the url to the path', async () => { + await fetch('url', TEMP_FILE); + + expect(readFileSync(TEMP_FILE, 'utf8')).toEqual('foobar'); + }); + + test('returns the md5 hex hash of the http body', async () => { + const hash = createHash('md5').update('foobar').digest('hex'); + + await expect(fetch('url', TEMP_FILE)).resolves.toEqual(hash); + }); + + test('throws if request emits an error', async () => { + (axios.request as jest.Mock).mockImplementationOnce(async () => { + throw new Error('foo'); + }); + + await expect(fetch('url', TEMP_FILE)).rejects.toThrow('foo'); + }); +}); diff --git a/x-pack/plugins/reporting/server/browsers/download/download.ts b/x-pack/plugins/screenshotting/server/browsers/download/fetch.ts similarity index 55% rename from x-pack/plugins/reporting/server/browsers/download/download.ts rename to x-pack/plugins/screenshotting/server/browsers/download/fetch.ts index 528395fe1afb2..aa52f7a4491c4 100644 --- a/x-pack/plugins/reporting/server/browsers/download/download.ts +++ b/x-pack/plugins/screenshotting/server/browsers/download/fetch.ts @@ -9,17 +9,15 @@ import Axios from 'axios'; import { createHash } from 'crypto'; import { closeSync, mkdirSync, openSync, writeSync } from 'fs'; import { dirname } from 'path'; -import { GenericLevelLogger } from '../../lib/level_logger'; +import { finished, Readable } from 'stream'; +import { promisify } from 'util'; +import type { Logger } from 'src/core/server'; /** * Download a url and calculate it's checksum */ -export async function download( - url: string, - path: string, - logger: GenericLevelLogger -): Promise { - logger.info(`Downloading ${url} to ${path}`); +export async function fetch(url: string, path: string, logger?: Logger): Promise { + logger?.info(`Downloading ${url} to ${path}`); const hash = createHash('md5'); @@ -27,30 +25,23 @@ export async function download( const handle = openSync(path, 'w'); try { - const resp = await Axios.request({ + const response = await Axios.request({ url, method: 'GET', responseType: 'stream', }); - resp.data.on('data', (chunk: Buffer) => { + response.data.on('data', (chunk: Buffer) => { writeSync(handle, chunk); hash.update(chunk); }); - await new Promise((resolve, reject) => { - resp.data - .on('error', (err: Error) => { - logger.error(err); - reject(err); - }) - .on('end', () => { - logger.info(`Downloaded ${url}`); - resolve(); - }); - }); - } catch (err) { - throw new Error(`Unable to download ${url}: ${err}`); + await promisify(finished)(response.data, { writable: false }); + logger?.info(`Downloaded ${url}`); + } catch (error) { + logger?.error(error); + + throw new Error(`Unable to download ${url}: ${error}`); } finally { closeSync(handle); } diff --git a/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts new file mode 100644 index 0000000000000..f960b65859172 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/download/index.test.ts @@ -0,0 +1,104 @@ +/* + * 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 path from 'path'; +import mockFs from 'mock-fs'; +import { existsSync, readdirSync } from 'fs'; +import { ChromiumArchivePaths } from '../chromium'; +import { fetch } from './fetch'; +import { md5 } from './checksum'; +import { download } from '.'; + +jest.mock('./checksum'); +jest.mock('./fetch'); + +describe('ensureDownloaded', () => { + let paths: ChromiumArchivePaths; + + beforeEach(() => { + paths = new ChromiumArchivePaths(); + + (md5 as jest.MockedFunction).mockImplementation( + async (packagePath) => + paths.packages.find((packageInfo) => paths.resolvePath(packageInfo) === packagePath) + ?.archiveChecksum ?? 'some-md5' + ); + + (fetch as jest.MockedFunction).mockImplementation( + async (_url, packagePath) => + paths.packages.find((packageInfo) => paths.resolvePath(packageInfo) === packagePath) + ?.archiveChecksum ?? 'some-md5' + ); + + mockFs(); + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should remove unexpected files', async () => { + const unexpectedPath1 = `${paths.archivesPath}/unexpected1`; + const unexpectedPath2 = `${paths.archivesPath}/unexpected2`; + + mockFs({ + [unexpectedPath1]: 'test', + [unexpectedPath2]: 'test', + }); + + await download(paths); + + expect(existsSync(unexpectedPath1)).toBe(false); + expect(existsSync(unexpectedPath2)).toBe(false); + }); + + it('should reject when download fails', async () => { + (fetch as jest.MockedFunction).mockRejectedValueOnce(new Error('some error')); + + await expect(download(paths)).rejects.toBeInstanceOf(Error); + }); + + it('should reject when downloaded md5 hash is different', async () => { + (fetch as jest.MockedFunction).mockResolvedValue('random-md5'); + + await expect(download(paths)).rejects.toBeInstanceOf(Error); + }); + + describe('when archives are already present', () => { + beforeEach(() => { + mockFs( + Object.fromEntries( + paths.packages.map((packageInfo) => [paths.resolvePath(packageInfo), '']) + ) + ); + }); + + it('should not download again', async () => { + await download(paths); + + expect(fetch).not.toHaveBeenCalled(); + expect(readdirSync(path.resolve(`${paths.archivesPath}/x64`))).toEqual( + expect.arrayContaining([ + 'chrome-win.zip', + 'chromium-70f5d88-linux_x64.zip', + 'chromium-d163fd7-darwin_x64.zip', + ]) + ); + expect(readdirSync(path.resolve(`${paths.archivesPath}/arm64`))).toEqual( + expect.arrayContaining(['chromium-70f5d88-linux_arm64.zip']) + ); + }); + + it('should download again if md5 hash different', async () => { + (md5 as jest.MockedFunction).mockResolvedValueOnce('random-md5'); + await download(paths); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/browsers/download/index.ts b/x-pack/plugins/screenshotting/server/browsers/download/index.ts new file mode 100644 index 0000000000000..8866fcc1caf2b --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/download/index.ts @@ -0,0 +1,94 @@ +/* + * 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 { existsSync } from 'fs'; +import del from 'del'; +import type { Logger } from 'src/core/server'; +import type { ChromiumArchivePaths } from '../chromium'; +import { md5 } from './checksum'; +import { fetch } from './fetch'; + +/** + * Clears the unexpected files in the browsers archivesPath + * and ensures that all packages/archives are downloaded and + * that their checksums match the declared value + * @param {BrowserSpec} browsers + * @return {Promise} + */ +export async function download(paths: ChromiumArchivePaths, logger?: Logger) { + const removedFiles = await del(`${paths.archivesPath}/**/*`, { + force: true, + onlyFiles: true, + ignore: paths.getAllArchiveFilenames(), + }); + + removedFiles.forEach((path) => logger?.warn(`Deleting unexpected file ${path}`)); + + const invalidChecksums: string[] = []; + await Promise.all( + paths.packages.map(async (path) => { + const { archiveFilename, archiveChecksum } = path; + if (!archiveFilename || !archiveChecksum) { + return; + } + + const resolvedPath = paths.resolvePath(path); + const pathExists = existsSync(resolvedPath); + + let foundChecksum = 'MISSING'; + try { + foundChecksum = await md5(resolvedPath); + // eslint-disable-next-line no-empty + } catch {} + + if (pathExists && foundChecksum === archiveChecksum) { + logger?.debug( + `Browser archive for ${path.platform}/${path.architecture} found in ${resolvedPath}.` + ); + return; + } + + if (!pathExists) { + logger?.warn( + `Browser archive for ${path.platform}/${path.architecture} not found in ${resolvedPath}.` + ); + } + + if (foundChecksum !== archiveChecksum) { + logger?.warn( + `Browser archive checksum for ${path.platform}/${path.architecture} ` + + `is ${foundChecksum} but ${archiveChecksum} was expected.` + ); + } + + const url = paths.getDownloadUrl(path); + try { + const downloadedChecksum = await fetch(url, resolvedPath, logger); + if (downloadedChecksum !== archiveChecksum) { + logger?.warn( + `Invalid checksum for ${path.platform}/${path.architecture}: ` + + `expected ${archiveChecksum} got ${downloadedChecksum}` + ); + invalidChecksums.push(`${url} => ${resolvedPath}`); + } + } catch (error) { + throw new Error(`Failed to download ${url}: ${error}`); + } + }) + ); + + if (invalidChecksums.length) { + const error = new Error( + `Error downloading browsers, checksums incorrect for:\n - ${invalidChecksums.join( + '\n - ' + )}` + ); + logger?.error(error); + + throw error; + } +} diff --git a/x-pack/plugins/reporting/server/browsers/extract/__fixtures__/file.md b/x-pack/plugins/screenshotting/server/browsers/extract/__fixtures__/file.md similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/__fixtures__/file.md rename to x-pack/plugins/screenshotting/server/browsers/extract/__fixtures__/file.md diff --git a/x-pack/plugins/reporting/server/browsers/extract/__fixtures__/file.md.zip b/x-pack/plugins/screenshotting/server/browsers/extract/__fixtures__/file.md.zip similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/__fixtures__/file.md.zip rename to x-pack/plugins/screenshotting/server/browsers/extract/__fixtures__/file.md.zip diff --git a/x-pack/plugins/reporting/server/browsers/extract/extract.test.ts b/x-pack/plugins/screenshotting/server/browsers/extract/extract.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/extract.test.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/extract.test.ts diff --git a/x-pack/plugins/reporting/server/browsers/extract/extract.ts b/x-pack/plugins/screenshotting/server/browsers/extract/extract.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/extract.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/extract.ts diff --git a/x-pack/plugins/reporting/server/browsers/extract/extract_error.ts b/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts similarity index 99% rename from x-pack/plugins/reporting/server/browsers/extract/extract_error.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts index 838b8a7dbc158..04a915c2afc93 100644 --- a/x-pack/plugins/reporting/server/browsers/extract/extract_error.ts +++ b/x-pack/plugins/screenshotting/server/browsers/extract/extract_error.ts @@ -7,6 +7,7 @@ export class ExtractError extends Error { public readonly cause: string; + constructor(cause: string, message = 'Failed to extract the browser archive') { super(message); this.message = message; diff --git a/x-pack/plugins/reporting/server/browsers/extract/index.ts b/x-pack/plugins/screenshotting/server/browsers/extract/index.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/index.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/index.ts diff --git a/x-pack/plugins/reporting/server/browsers/extract/unzip.test.ts b/x-pack/plugins/screenshotting/server/browsers/extract/unzip.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/unzip.test.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/unzip.test.ts diff --git a/x-pack/plugins/reporting/server/browsers/extract/unzip.ts b/x-pack/plugins/screenshotting/server/browsers/extract/unzip.ts similarity index 100% rename from x-pack/plugins/reporting/server/browsers/extract/unzip.ts rename to x-pack/plugins/screenshotting/server/browsers/extract/unzip.ts diff --git a/x-pack/plugins/screenshotting/server/browsers/index.ts b/x-pack/plugins/screenshotting/server/browsers/index.ts new file mode 100644 index 0000000000000..ef5069ae51112 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { download } from './download'; +export { install } from './install'; +export type { ConditionalHeaders, PerformanceMetrics } from './chromium'; +export { + getChromiumDisconnectedError, + ChromiumArchivePaths, + DEFAULT_VIEWPORT, + HeadlessChromiumDriver, + HeadlessChromiumDriverFactory, +} from './chromium'; diff --git a/x-pack/plugins/screenshotting/server/browsers/install.ts b/x-pack/plugins/screenshotting/server/browsers/install.ts new file mode 100644 index 0000000000000..acd31ec8ef2b5 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/install.ts @@ -0,0 +1,61 @@ +/* + * 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 del from 'del'; +import os from 'os'; +import path from 'path'; +import type { Logger } from 'src/core/server'; +import { ChromiumArchivePaths } from './chromium'; +import { download } from './download'; +import { md5 } from './download/checksum'; +import { extract } from './extract'; + +/** + * "install" a browser by type into installs path by extracting the downloaded + * archive. If there is an error extracting the archive an `ExtractError` is thrown + */ +export async function install( + paths: ChromiumArchivePaths, + logger: Logger, + chromiumPath: string = path.resolve(__dirname, '../../chromium'), + platform: string = process.platform, + architecture: string = os.arch() +): Promise { + const pkg = paths.find(platform, architecture); + + if (!pkg) { + throw new Error(`Unsupported platform: ${platform}-${architecture}`); + } + + const binaryPath = paths.getBinaryPath(pkg); + const binaryChecksum = await md5(binaryPath).catch(() => ''); + + if (binaryChecksum !== pkg.binaryChecksum) { + logger?.warn( + `Found browser binary checksum for ${pkg.platform}/${pkg.architecture} ` + + `is ${binaryChecksum} but ${pkg.binaryChecksum} was expected. Re-installing...` + ); + try { + await del(chromiumPath); + } catch (error) { + logger.error(error); + } + + try { + await download(paths, logger); + const archive = path.join(paths.archivesPath, pkg.architecture, pkg.archiveFilename); + logger.info(`Extracting [${archive}] to [${chromiumPath}]`); + await extract(archive, chromiumPath); + } catch (error) { + logger.error(error); + } + } + + logger.info(`Browser executable: ${binaryPath}`); + + return binaryPath; +} diff --git a/x-pack/plugins/screenshotting/server/browsers/mock.ts b/x-pack/plugins/screenshotting/server/browsers/mock.ts new file mode 100644 index 0000000000000..4b9142b298588 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/browsers/mock.ts @@ -0,0 +1,95 @@ +/* + * 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 { NEVER, of } from 'rxjs'; +import type { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from './chromium'; +import { + CONTEXT_SKIPTELEMETRY, + CONTEXT_GETNUMBEROFITEMS, + CONTEXT_INJECTCSS, + CONTEXT_WAITFORRENDER, + CONTEXT_GETTIMERANGE, + CONTEXT_ELEMENTATTRIBUTES, + CONTEXT_GETRENDERERRORS, +} from '../screenshots/constants'; + +const selectors = { + renderComplete: 'renderedSelector', + itemsCountAttribute: 'itemsSelector', + screenshot: 'screenshotSelector', + timefilterDurationAttribute: 'timefilterDurationSelector', + toastHeader: 'toastHeaderSelector', +}; + +function getElementsPositionAndAttributes(title: string, description: string) { + return [ + { + position: { + boundingClientRect: { top: 0, left: 0, width: 800, height: 600 }, + scroll: { x: 0, y: 0 }, + }, + attributes: { title, description }, + }, + ]; +} + +export function createMockBrowserDriver(): jest.Mocked { + const evaluate = jest.fn(async (_, { context }) => { + switch (context) { + case CONTEXT_SKIPTELEMETRY: + case CONTEXT_INJECTCSS: + case CONTEXT_WAITFORRENDER: + case CONTEXT_GETRENDERERRORS: + return; + case CONTEXT_GETNUMBEROFITEMS: + return 1; + case CONTEXT_GETTIMERANGE: + return 'Default GetTimeRange Result'; + case CONTEXT_ELEMENTATTRIBUTES: + return getElementsPositionAndAttributes('Default Mock Title', 'Default '); + } + + throw new Error(context); + }); + + const screenshot = jest.fn(async () => Buffer.from('screenshot')); + + const waitForSelector = jest.fn(async (selectorArg: string) => { + const { renderComplete, itemsCountAttribute, toastHeader } = selectors; + + if (selectorArg === `${renderComplete},[${itemsCountAttribute}]`) { + return true; + } + + if (selectorArg === toastHeader) { + return NEVER.toPromise(); + } + + throw new Error(selectorArg); + }); + + return { + evaluate, + screenshot, + waitForSelector, + isPageOpen: jest.fn(), + open: jest.fn(), + setViewport: jest.fn(async () => {}), + waitFor: jest.fn(), + } as unknown as ReturnType; +} + +export function createMockBrowserDriverFactory( + driver?: HeadlessChromiumDriver +): jest.Mocked { + return { + createPage: jest.fn(() => + of({ driver: driver ?? createMockBrowserDriver(), exit$: NEVER, metrics$: NEVER }) + ), + diagnose: jest.fn(() => of('message')), + } as unknown as ReturnType; +} diff --git a/x-pack/plugins/reporting/server/browsers/network_policy.test.ts b/x-pack/plugins/screenshotting/server/browsers/network_policy.test.ts similarity index 99% rename from x-pack/plugins/reporting/server/browsers/network_policy.test.ts rename to x-pack/plugins/screenshotting/server/browsers/network_policy.test.ts index 4f0c60f4d14d2..84e42347100ae 100644 --- a/x-pack/plugins/reporting/server/browsers/network_policy.test.ts +++ b/x-pack/plugins/screenshotting/server/browsers/network_policy.test.ts @@ -7,7 +7,7 @@ import { allowRequest } from './network_policy'; -describe('Network Policy', () => { +describe('allowRequest', () => { it('allows requests when there are no rules', () => { expect(allowRequest('https://kibana.com/cool/route/bro', [])).toEqual(true); }); diff --git a/x-pack/plugins/reporting/server/browsers/network_policy.ts b/x-pack/plugins/screenshotting/server/browsers/network_policy.ts similarity index 94% rename from x-pack/plugins/reporting/server/browsers/network_policy.ts rename to x-pack/plugins/screenshotting/server/browsers/network_policy.ts index 721094dce6edf..4d47b01889924 100644 --- a/x-pack/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/plugins/screenshotting/server/browsers/network_policy.ts @@ -26,7 +26,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { +export function allowRequest(url: string, rules: NetworkPolicyRule[]): boolean { const parsed = parse(url); if (!rules.length) { @@ -52,4 +52,4 @@ export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { }, undefined); return typeof allowed !== 'undefined' ? allowed : false; -}; +} diff --git a/x-pack/plugins/reporting/server/browsers/safe_child_process.ts b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts similarity index 70% rename from x-pack/plugins/reporting/server/browsers/safe_child_process.ts rename to x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts index 70e45bf10803f..4bc378a4c8c86 100644 --- a/x-pack/plugins/reporting/server/browsers/safe_child_process.ts +++ b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts @@ -5,9 +5,9 @@ * 2.0. */ -import * as Rx from 'rxjs'; +import { fromEvent, merge, Observable } from 'rxjs'; import { take, share, mapTo, delay, tap } from 'rxjs/operators'; -import { LevelLogger } from '../lib'; +import type { Logger } from 'src/core/server'; interface IChild { kill: (signal: string) => Promise; @@ -16,13 +16,13 @@ interface IChild { // Our process can get sent various signals, and when these occur we wish to // kill the subprocess and then kill our process as long as the observer isn't cancelled export function safeChildProcess( - logger: LevelLogger, + logger: Logger, childProcess: IChild -): { terminate$: Rx.Observable } { - const ownTerminateSignal$ = Rx.merge( - Rx.fromEvent(process as NodeJS.EventEmitter, 'SIGTERM').pipe(mapTo('SIGTERM')), - Rx.fromEvent(process as NodeJS.EventEmitter, 'SIGINT').pipe(mapTo('SIGINT')), - Rx.fromEvent(process as NodeJS.EventEmitter, 'SIGBREAK').pipe(mapTo('SIGBREAK')) +): { terminate$: Observable } { + const ownTerminateSignal$ = merge( + fromEvent(process as NodeJS.EventEmitter, 'SIGTERM').pipe(mapTo('SIGTERM')), + fromEvent(process as NodeJS.EventEmitter, 'SIGINT').pipe(mapTo('SIGINT')), + fromEvent(process as NodeJS.EventEmitter, 'SIGBREAK').pipe(mapTo('SIGBREAK')) ).pipe(take(1), share()); const ownTerminateMapToKill$ = ownTerminateSignal$.pipe( @@ -32,7 +32,7 @@ export function safeChildProcess( mapTo('SIGKILL') ); - const kibanaForceExit$ = Rx.fromEvent(process as NodeJS.EventEmitter, 'exit').pipe( + const kibanaForceExit$ = fromEvent(process as NodeJS.EventEmitter, 'exit').pipe( take(1), tap((signal) => { logger.debug(`Kibana process forcefully exited with signal: ${signal}`); @@ -40,7 +40,7 @@ export function safeChildProcess( mapTo('SIGKILL') ); - const signalForChildProcess$ = Rx.merge(ownTerminateMapToKill$, kibanaForceExit$); + const signalForChildProcess$ = merge(ownTerminateMapToKill$, kibanaForceExit$); const logAndKillChildProcess = tap((signal: string) => { logger.debug(`Child process terminate signal was: ${signal}. Closing the browser...`); @@ -48,7 +48,7 @@ export function safeChildProcess( }); // send termination signals - const terminate$ = Rx.merge( + const terminate$ = merge( signalForChildProcess$.pipe(logAndKillChildProcess), ownTerminateSignal$.pipe( diff --git a/x-pack/plugins/screenshotting/server/config/create_config.test.ts b/x-pack/plugins/screenshotting/server/config/create_config.test.ts new file mode 100644 index 0000000000000..18ac6ceb6874d --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/create_config.test.ts @@ -0,0 +1,39 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import { createConfig } from './create_config'; +import { ConfigType } from './schema'; + +describe('createConfig$', () => { + let logger: jest.Mocked; + + beforeEach(() => { + logger = { + debug: jest.fn(), + get: jest.fn(() => logger), + info: jest.fn(), + warn: jest.fn(), + } as unknown as typeof logger; + }); + + it('should use user-provided disableSandbox', async () => { + const result = await createConfig(logger, { + browser: { chromium: { disableSandbox: false } }, + } as ConfigType); + + expect(result).toHaveProperty('browser.chromium.disableSandbox', false); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('should provide a default for disableSandbox', async () => { + const result = await createConfig(logger, { browser: { chromium: {} } } as ConfigType); + + expect(result).toHaveProperty('browser.chromium.disableSandbox', expect.any(Boolean)); + expect((logger.warn as any).mock.calls.length).toBe(0); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/config/create_config.ts b/x-pack/plugins/screenshotting/server/config/create_config.ts new file mode 100644 index 0000000000000..1819f37e1bccd --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/create_config.ts @@ -0,0 +1,57 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { cloneDeep, set, upperFirst } from 'lodash'; +import type { Logger } from 'src/core/server'; +import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; +import { ConfigType } from './schema'; + +/* + * Set up dynamic config defaults + * - xpack.capture.browser.chromium.disableSandbox + */ +export async function createConfig(parentLogger: Logger, config: ConfigType) { + const logger = parentLogger.get('config'); + + if (config.browser.chromium.disableSandbox != null) { + // disableSandbox was set by user + return config; + } + + // disableSandbox was not set by user, apply default for OS + const { os, disableSandbox } = await getDefaultChromiumSandboxDisabled(); + const osName = [os.os, os.dist, os.release].filter(Boolean).map(upperFirst).join(' '); + + logger.debug( + i18n.translate('xpack.screenshotting.serverConfig.osDetected', { + defaultMessage: `Running on OS: '{osName}'`, + values: { osName }, + }) + ); + + if (disableSandbox === true) { + logger.warn( + i18n.translate('xpack.screenshotting.serverConfig.autoSet.sandboxDisabled', { + defaultMessage: `Chromium sandbox provides an additional layer of protection, but is not supported for {osName} OS. Automatically setting '{configKey}: true'.`, + values: { + configKey: 'xpack.screenshotting.capture.browser.chromium.disableSandbox', + osName, + }, + }) + ); + } else { + logger.info( + i18n.translate('xpack.screenshotting.serverConfig.autoSet.sandboxEnabled', { + defaultMessage: `Chromium sandbox provides an additional layer of protection, and is supported for {osName} OS. Automatically enabling Chromium sandbox.`, + values: { osName }, + }) + ); + } + + return set(cloneDeep(config), 'browser.chromium.disableSandbox', disableSandbox); +} diff --git a/x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.test.ts b/x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.test.ts new file mode 100644 index 0000000000000..7204230ef5160 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.test.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +jest.mock('getos', () => jest.fn()); + +import { getDefaultChromiumSandboxDisabled } from './default_chromium_sandbox_disabled'; +import getos from 'getos'; + +describe('getDefaultChromiumSandboxDisabled', () => { + it.each` + os | dist | release | expected + ${'win32'} | ${'Windows'} | ${'11'} | ${false} + ${'darwin'} | ${'macOS'} | ${'11.2.3'} | ${false} + ${'linux'} | ${'Centos'} | ${'7.0'} | ${true} + ${'linux'} | ${'Red Hat Linux'} | ${'7.0'} | ${true} + ${'linux'} | ${'Ubuntu Linux'} | ${'14.04'} | ${false} + ${'linux'} | ${'Ubuntu Linux'} | ${'16.04'} | ${false} + ${'linux'} | ${'SUSE Linux'} | ${'11'} | ${false} + ${'linux'} | ${'SUSE Linux'} | ${'12'} | ${false} + ${'linux'} | ${'SUSE Linux'} | ${'42.0'} | ${false} + ${'linux'} | ${'Debian'} | ${'8'} | ${true} + ${'linux'} | ${'Debian'} | ${'9'} | ${true} + `('should return $expected for $dist $release', async ({ expected, ...os }) => { + (getos as jest.Mock).mockImplementation((cb) => cb(null, os)); + + await expect(getDefaultChromiumSandboxDisabled()).resolves.toHaveProperty( + 'disableSandbox', + expected + ); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts b/x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.ts similarity index 80% rename from x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts rename to x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.ts index 89872cf63d1bc..4461b53bf0fcf 100644 --- a/x-pack/plugins/reporting/server/config/default_chromium_sandbox_disabled.ts +++ b/x-pack/plugins/screenshotting/server/config/default_chromium_sandbox_disabled.ts @@ -5,10 +5,10 @@ * 2.0. */ -import getosSync from 'getos'; +import getOsSync from 'getos'; import { promisify } from 'util'; -const getos = promisify(getosSync); +const getOs = promisify(getOsSync); const distroSupportsUnprivilegedUsernamespaces = (distro: string) => { // Debian 7 and 8 don't support usernamespaces by default @@ -38,11 +38,10 @@ interface OsSummary { } export async function getDefaultChromiumSandboxDisabled(): Promise { - const os = await getos(); + const os = await getOs(); - if (os.os === 'linux' && !distroSupportsUnprivilegedUsernamespaces(os.dist)) { - return { os, disableSandbox: true }; - } else { - return { os, disableSandbox: false }; - } + return { + os, + disableSandbox: os.os === 'linux' && !distroSupportsUnprivilegedUsernamespaces(os.dist), + }; } diff --git a/x-pack/plugins/screenshotting/server/config/index.ts b/x-pack/plugins/screenshotting/server/config/index.ts new file mode 100644 index 0000000000000..38f5a6e8f20fa --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/index.ts @@ -0,0 +1,49 @@ +/* + * 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 type { PluginConfigDescriptor } from 'src/core/server'; +import { ConfigSchema, ConfigType } from './schema'; + +/** + * Screenshotting plugin configuration schema. + */ +export const config: PluginConfigDescriptor = { + schema: ConfigSchema, + deprecations: ({ renameFromRoot }) => [ + renameFromRoot('xpack.reporting.capture.networkPolicy', 'xpack.screenshotting.networkPolicy'), + renameFromRoot( + 'xpack.reporting.capture.browser.autoDownload', + 'xpack.screenshotting.browser.autoDownload' + ), + renameFromRoot( + 'xpack.reporting.capture.browser.chromium.inspect', + 'xpack.screenshotting.browser.chromium.inspect' + ), + renameFromRoot( + 'xpack.reporting.capture.browser.chromium.disableSandbox', + 'xpack.screenshotting.browser.chromium.disableSandbox' + ), + renameFromRoot( + 'xpack.reporting.capture.browser.chromium.proxy.enabled', + 'xpack.screenshotting.browser.chromium.proxy.enabled' + ), + renameFromRoot( + 'xpack.reporting.capture.browser.chromium.proxy.server', + 'xpack.screenshotting.browser.chromium.proxy.server' + ), + renameFromRoot( + 'xpack.reporting.capture.browser.chromium.proxy.bypass', + 'xpack.screenshotting.browser.chromium.proxy.bypass' + ), + ], + exposeToUsage: { + networkPolicy: false, // show as [redacted] + }, +}; + +export { createConfig } from './create_config'; +export type { ConfigType } from './schema'; diff --git a/x-pack/plugins/screenshotting/server/config/schema.test.ts b/x-pack/plugins/screenshotting/server/config/schema.test.ts new file mode 100644 index 0000000000000..9180f0d180d5f --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/schema.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { ConfigSchema } from './schema'; + +describe('ConfigSchema', () => { + it(`should produce correct config for context {"dev": false,"dist": false}`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchInlineSnapshot(` + Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "proxy": Object { + "enabled": false, + }, + }, + }, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + } + `); + }); + + it(`should produce correct config for context {"dev": false,"dist": true}`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchInlineSnapshot(` + Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "inspect": false, + "proxy": Object { + "enabled": false, + }, + }, + }, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + } + `); + }); + + it(`should allow optional settings`, () => { + const config = ConfigSchema.validate({ browser: { chromium: { disableSandbox: true } } }); + + expect(config).toHaveProperty('browser.chromium', { + disableSandbox: true, + proxy: { enabled: false }, + }); + }); + + it('should allow setting a wildcard for chrome proxy bypass', () => { + expect( + ConfigSchema.validate({ + browser: { + chromium: { + proxy: { + enabled: true, + server: 'http://example.com:8080', + bypass: ['*.example.com', '*bar.example.com', 'bats.example.com'], + }, + }, + }, + }).browser.chromium.proxy + ).toMatchInlineSnapshot(` + Object { + "bypass": Array [ + "*.example.com", + "*bar.example.com", + "bats.example.com", + ], + "enabled": true, + "server": "http://example.com:8080", + } + `); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/config/schema.ts b/x-pack/plugins/screenshotting/server/config/schema.ts new file mode 100644 index 0000000000000..bcf2fa9feead9 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/config/schema.ts @@ -0,0 +1,72 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +const RulesSchema = schema.object({ + allow: schema.boolean(), + host: schema.maybe(schema.string()), + protocol: schema.maybe( + schema.string({ + validate(value) { + if (!/:$/.test(value)) { + return 'must end in colon'; + } + }, + }) + ), +}); + +export const ConfigSchema = schema.object({ + networkPolicy: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + rules: schema.arrayOf(RulesSchema, { + defaultValue: [ + { host: undefined, allow: true, protocol: 'http:' }, + { host: undefined, allow: true, protocol: 'https:' }, + { host: undefined, allow: true, protocol: 'ws:' }, + { host: undefined, allow: true, protocol: 'wss:' }, + { host: undefined, allow: true, protocol: 'data:' }, + { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! + ], + }), + }), + browser: schema.object({ + autoDownload: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.boolean({ defaultValue: true }) + ), + chromium: schema.object({ + inspect: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.maybe(schema.never()) + ), + disableSandbox: schema.maybe(schema.boolean()), // default value is dynamic in createConfig$ + proxy: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + server: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.uri({ scheme: ['http', 'https'] }), + schema.maybe(schema.never()) + ), + bypass: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.arrayOf(schema.string()), + schema.maybe(schema.never()) + ), + }), + }), + }), +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/screenshotting/server/index.ts b/x-pack/plugins/screenshotting/server/index.ts new file mode 100755 index 0000000000000..340a6688e79eb --- /dev/null +++ b/x-pack/plugins/screenshotting/server/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { ScreenshottingPlugin } from './plugin'; + +/** + * Screenshotting plugin entry point. + */ +export function plugin(...args: ConstructorParameters) { + return new ScreenshottingPlugin(...args); +} + +export { config } from './config'; +export type { Layout } from './layouts'; +export type { ScreenshottingStart } from './plugin'; +export type { ScreenshotOptions, ScreenshotResult } from './screenshots'; diff --git a/x-pack/plugins/reporting/server/lib/layouts/layout.ts b/x-pack/plugins/screenshotting/server/layouts/base_layout.ts similarity index 79% rename from x-pack/plugins/reporting/server/lib/layouts/layout.ts rename to x-pack/plugins/screenshotting/server/layouts/base_layout.ts index d68e7690d79f1..846904170a0c1 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/layout.ts +++ b/x-pack/plugins/screenshotting/server/layouts/base_layout.ts @@ -6,7 +6,7 @@ */ import type { CustomPageSize, PredefinedPageSize } from 'pdfmake/interfaces'; -import type { PageSizeParams, PdfImageSize, Size } from '../../../common/types'; +import type { Size } from '../../common/layout'; export interface ViewZoomWidthHeight { zoom: number; @@ -14,7 +14,21 @@ export interface ViewZoomWidthHeight { height: number; } -export abstract class Layout { +export interface PdfImageSize { + width: number; + height?: number; +} + +export interface PageSizeParams { + pageMarginTop: number; + pageMarginBottom: number; + pageMarginWidth: number; + tableBorderWidth: number; + headingHeight: number; + subheadingHeight: number; +} + +export abstract class BaseLayout { public id: string = ''; public groupCount: number = 0; diff --git a/x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts b/x-pack/plugins/screenshotting/server/layouts/canvas_layout.ts similarity index 80% rename from x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts rename to x-pack/plugins/screenshotting/server/layouts/canvas_layout.ts index ec95b0f75997d..d164f8c7e91e2 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/canvas_layout.ts +++ b/x-pack/plugins/screenshotting/server/layouts/canvas_layout.ts @@ -5,15 +5,11 @@ * 2.0. */ -import { - getDefaultLayoutSelectors, - LayoutInstance, - LayoutSelectorDictionary, - LayoutTypes, - PageSizeParams, - Size, -} from './'; -import { Layout } from './layout'; +import type { LayoutSelectorDictionary, Size } from '../../common/layout'; +import { LayoutTypes } from '../../common'; +import { DEFAULT_SELECTORS } from '.'; +import type { Layout } from '.'; +import { BaseLayout } from './base_layout'; // FIXME - should use zoom from capture config const ZOOM: number = 2; @@ -24,8 +20,8 @@ const ZOOM: number = 2; * The single image that was captured should be the only structural part of the * PDF document definition */ -export class CanvasLayout extends Layout implements LayoutInstance { - public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); +export class CanvasLayout extends BaseLayout implements Layout { + public readonly selectors: LayoutSelectorDictionary = { ...DEFAULT_SELECTORS }; public readonly groupCount = 1; public readonly height: number; public readonly width: number; @@ -78,7 +74,7 @@ export class CanvasLayout extends Layout implements LayoutInstance { }; } - public getPdfPageSize(pageSizeParams: PageSizeParams): Size { + public getPdfPageSize(): Size { return { height: this.height, width: this.width, diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts b/x-pack/plugins/screenshotting/server/layouts/create_layout.test.ts similarity index 80% rename from x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts rename to x-pack/plugins/screenshotting/server/layouts/create_layout.test.ts index aebd20451b834..1ea6c7440b455 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.test.ts +++ b/x-pack/plugins/screenshotting/server/layouts/create_layout.test.ts @@ -5,21 +5,16 @@ * 2.0. */ -import { ReportingConfig } from '../..'; -import { createMockConfig, createMockConfigSchema } from '../../test_helpers'; -import { createLayout, LayoutParams, PreserveLayout } from './'; +import type { LayoutParams } from '../../common/layout'; import { CanvasLayout } from './canvas_layout'; +import { PreserveLayout } from './preserve_layout'; +import { createLayout } from './create_layout'; describe('Create Layout', () => { - let config: ReportingConfig; - beforeEach(() => { - config = createMockConfig(createMockConfigSchema()); - }); - it('creates preserve layout instance', () => { const { id, height, width } = new PreserveLayout({ width: 16, height: 16 }); const preserveParams: LayoutParams = { id, dimensions: { height, width } }; - const layout = createLayout(config.get('capture'), preserveParams); + const layout = createLayout(preserveParams); expect(layout).toMatchInlineSnapshot(` PreserveLayout { "groupCount": 1, @@ -44,20 +39,14 @@ describe('Create Layout', () => { }); it('creates the print layout', () => { - const print = createLayout(config.get('capture')); + const print = createLayout({ zoom: 1 }); const printParams: LayoutParams = { id: print.id, + zoom: 1, }; - const layout = createLayout(config.get('capture'), printParams); + const layout = createLayout(printParams); expect(layout).toMatchInlineSnapshot(` PrintLayout { - "captureConfig": Object { - "browser": Object { - "chromium": Object { - "disableSandbox": true, - }, - }, - }, "groupCount": 2, "hasFooter": true, "hasHeader": true, @@ -75,6 +64,7 @@ describe('Create Layout', () => { "height": 1200, "width": 1950, }, + "zoom": 1, } `); }); @@ -82,7 +72,7 @@ describe('Create Layout', () => { it('creates the canvas layout', () => { const { id, height, width } = new CanvasLayout({ width: 18, height: 18 }); const canvasParams: LayoutParams = { id, dimensions: { height, width } }; - const layout = createLayout(config.get('capture'), canvasParams); + const layout = createLayout(canvasParams); expect(layout).toMatchInlineSnapshot(` CanvasLayout { "groupCount": 1, diff --git a/x-pack/plugins/screenshotting/server/layouts/create_layout.ts b/x-pack/plugins/screenshotting/server/layouts/create_layout.ts new file mode 100644 index 0000000000000..29a34a07e696f --- /dev/null +++ b/x-pack/plugins/screenshotting/server/layouts/create_layout.ts @@ -0,0 +1,26 @@ +/* + * 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 type { LayoutParams } from '../../common/layout'; +import { LayoutTypes } from '../../common'; +import type { Layout } from '.'; +import { CanvasLayout } from './canvas_layout'; +import { PreserveLayout } from './preserve_layout'; +import { PrintLayout } from './print_layout'; + +export function createLayout({ id, dimensions, selectors, ...config }: LayoutParams): Layout { + if (dimensions && id === LayoutTypes.PRESERVE_LAYOUT) { + return new PreserveLayout(dimensions, selectors); + } + + if (dimensions && id === LayoutTypes.CANVAS) { + return new CanvasLayout(dimensions); + } + + // layoutParams is optional as PrintLayout doesn't use it + return new PrintLayout(config); +} diff --git a/x-pack/plugins/screenshotting/server/layouts/index.ts b/x-pack/plugins/screenshotting/server/layouts/index.ts new file mode 100644 index 0000000000000..d21b06e6a688a --- /dev/null +++ b/x-pack/plugins/screenshotting/server/layouts/index.ts @@ -0,0 +1,38 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import type { LayoutSelectorDictionary, Size } from '../../common/layout'; +import type { HeadlessChromiumDriver } from '../browsers'; +import type { BaseLayout } from './base_layout'; + +interface LayoutSelectors { + /** + * Element selectors determining the page state. + */ + selectors: LayoutSelectorDictionary; + + /** + * A callback to position elements before taking a screenshot. + * @param browser Browser adapter instance. + * @param logger Message logger. + */ + positionElements?(browser: HeadlessChromiumDriver, logger: Logger): Promise; +} + +export type Layout = BaseLayout & LayoutSelectors & Partial; + +export const DEFAULT_SELECTORS: LayoutSelectorDictionary = { + screenshot: '[data-shared-items-container]', + renderComplete: '[data-shared-item]', + renderError: '[data-render-error]', + renderErrorAttribute: 'data-render-error', + itemsCountAttribute: 'data-shared-items-count', + timefilterDurationAttribute: 'data-shared-timefilter-duration', +}; + +export { createLayout } from './create_layout'; diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_layoutinstance.ts b/x-pack/plugins/screenshotting/server/layouts/mock.ts similarity index 59% rename from x-pack/plugins/reporting/server/test_helpers/create_mock_layoutinstance.ts rename to x-pack/plugins/screenshotting/server/layouts/mock.ts index e9b94c3c98bec..d5395c5db6f82 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/plugins/screenshotting/server/layouts/mock.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { LAYOUT_TYPES } from '../../common/constants'; -import { createLayout, LayoutInstance } from '../lib/layouts'; -import { CaptureConfig } from '../types'; +import { LayoutTypes } from '../../common'; +import { createLayout, Layout } from '.'; -export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { - const mockLayout = createLayout(captureConfig, { - id: LAYOUT_TYPES.PRESERVE_LAYOUT, +export function createMockLayout(): Layout { + const layout = createLayout({ + id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 100, width: 100 }, - }) as LayoutInstance; - mockLayout.selectors = { + zoom: 1, + }) as Layout; + + layout.selectors = { renderComplete: 'renderedSelector', itemsCountAttribute: 'itemsSelector', screenshot: 'screenshotSelector', @@ -22,5 +23,6 @@ export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { renderErrorAttribute: 'dataRenderErrorSelector', timefilterDurationAttribute: 'timefilterDurationSelector', }; - return mockLayout; -}; + + return layout; +} diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css similarity index 100% rename from x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css rename to x-pack/plugins/screenshotting/server/layouts/preserve_layout.css diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.test.ts b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/lib/layouts/preserve_layout.test.ts rename to x-pack/plugins/screenshotting/server/layouts/preserve_layout.test.ts diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.ts similarity index 79% rename from x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts rename to x-pack/plugins/screenshotting/server/layouts/preserve_layout.ts index 7f6bc9e5d9505..f265920675f85 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.ts @@ -5,16 +5,18 @@ * 2.0. */ import path from 'path'; -import { CustomPageSize } from 'pdfmake/interfaces'; -import { LAYOUT_TYPES } from '../../../common/constants'; -import { PageSizeParams, Size } from '../../../common/types'; -import { getDefaultLayoutSelectors, LayoutInstance, LayoutSelectorDictionary } from './'; -import { Layout } from './layout'; +import type { CustomPageSize } from 'pdfmake/interfaces'; +import type { LayoutSelectorDictionary, Size } from '../../common/layout'; +import { LayoutTypes } from '../../common'; +import { DEFAULT_SELECTORS } from '.'; +import type { Layout } from '.'; +import { BaseLayout } from './base_layout'; +import type { PageSizeParams } from './base_layout'; // We use a zoom of two to bump up the resolution of the screenshot a bit. const ZOOM: number = 2; -export class PreserveLayout extends Layout implements LayoutInstance { +export class PreserveLayout extends BaseLayout implements Layout { public readonly selectors: LayoutSelectorDictionary; public readonly groupCount = 1; public readonly height: number; @@ -23,16 +25,13 @@ export class PreserveLayout extends Layout implements LayoutInstance { private readonly scaledWidth: number; constructor(size: Size, selectors?: Partial) { - super(LAYOUT_TYPES.PRESERVE_LAYOUT); + super(LayoutTypes.PRESERVE_LAYOUT); this.height = size.height; this.width = size.width; this.scaledHeight = size.height * ZOOM; this.scaledWidth = size.width * ZOOM; - this.selectors = { - ...getDefaultLayoutSelectors(), - ...selectors, - }; + this.selectors = { ...DEFAULT_SELECTORS, ...selectors }; } public getCssOverridesPath() { diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/screenshotting/server/layouts/print_layout.ts similarity index 64% rename from x-pack/plugins/reporting/server/lib/layouts/print_layout.ts rename to x-pack/plugins/screenshotting/server/layouts/print_layout.ts index 68226affb41e4..bfcbe84842c40 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/screenshotting/server/layouts/print_layout.ts @@ -6,23 +6,26 @@ */ import { PageOrientation, PredefinedPageSize } from 'pdfmake/interfaces'; -import { DEFAULT_VIEWPORT, LAYOUT_TYPES } from '../../../common/constants'; -import { CaptureConfig } from '../../types'; -import { getDefaultLayoutSelectors, LayoutInstance, LayoutSelectorDictionary } from './'; -import { Layout } from './layout'; +import type { LayoutParams, LayoutSelectorDictionary } from '../../common/layout'; +import { LayoutTypes } from '../../common'; +import type { Layout } from '.'; +import { DEFAULT_SELECTORS } from '.'; +import { DEFAULT_VIEWPORT } from '../browsers'; +import { BaseLayout } from './base_layout'; -export class PrintLayout extends Layout implements LayoutInstance { +export class PrintLayout extends BaseLayout implements Layout { public readonly selectors: LayoutSelectorDictionary = { - ...getDefaultLayoutSelectors(), + ...DEFAULT_SELECTORS, screenshot: '[data-shared-item]', // override '[data-shared-items-container]' }; public readonly groupCount = 2; - private readonly captureConfig: CaptureConfig; private readonly viewport = DEFAULT_VIEWPORT; + private zoom: number; - constructor(captureConfig: CaptureConfig) { - super(LAYOUT_TYPES.PRINT); - this.captureConfig = captureConfig; + constructor({ zoom = 1 }: Pick) { + super(LayoutTypes.PRINT); + + this.zoom = zoom; } public getCssOverridesPath() { @@ -34,16 +37,17 @@ export class PrintLayout extends Layout implements LayoutInstance { } public getBrowserZoom() { - return this.captureConfig.zoom; + return this.zoom; } public getViewport(itemsCount: number) { return { - zoom: this.captureConfig.zoom, + zoom: this.zoom, width: this.viewport.width, height: this.viewport.height * itemsCount, }; } + public getPdfImageSize() { return { width: 500, diff --git a/x-pack/plugins/screenshotting/server/mock.ts b/x-pack/plugins/screenshotting/server/mock.ts new file mode 100644 index 0000000000000..49d69521f2c19 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/mock.ts @@ -0,0 +1,22 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import { createMockBrowserDriverFactory } from './browsers/mock'; +import { createMockScreenshots } from './screenshots/mock'; +import type { ScreenshottingStart } from '.'; + +export function createMockScreenshottingStart(): jest.Mocked { + const driver = createMockBrowserDriverFactory(); + const { getScreenshots } = createMockScreenshots(); + const { diagnose } = driver; + + return { + diagnose, + getScreenshots: jest.fn((options) => getScreenshots(driver, {} as Logger, options)), + }; +} diff --git a/x-pack/plugins/screenshotting/server/plugin.ts b/x-pack/plugins/screenshotting/server/plugin.ts new file mode 100755 index 0000000000000..53f855e1f544d --- /dev/null +++ b/x-pack/plugins/screenshotting/server/plugin.ts @@ -0,0 +1,89 @@ +/* + * 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 { from } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import type { + CoreSetup, + CoreStart, + Logger, + Plugin, + PluginInitializerContext, +} from 'src/core/server'; +import type { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server'; +import { ChromiumArchivePaths, HeadlessChromiumDriverFactory, install } from './browsers'; +import { createConfig, ConfigType } from './config'; +import { getScreenshots, ScreenshotOptions } from './screenshots'; + +interface SetupDeps { + screenshotMode: ScreenshotModePluginSetup; +} + +/** + * Start public contract. + */ +export interface ScreenshottingStart { + /** + * Runs browser diagnostics. + * @returns Observable with output messages. + */ + diagnose: HeadlessChromiumDriverFactory['diagnose']; + + /** + * Takes screenshots of multiple pages. + * @param options Screenshots session options. + * @returns Observable with screenshotting results. + */ + getScreenshots(options: ScreenshotOptions): ReturnType; +} + +export class ScreenshottingPlugin implements Plugin { + private config: ConfigType; + private logger: Logger; + private screenshotMode!: ScreenshotModePluginSetup; + private browserDriverFactory!: Promise; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + this.config = context.config.get(); + } + + setup({}: CoreSetup, { screenshotMode }: SetupDeps) { + this.screenshotMode = screenshotMode; + this.browserDriverFactory = (async () => { + try { + const paths = new ChromiumArchivePaths(); + const logger = this.logger.get('chromium'); + const [config, binaryPath] = await Promise.all([ + createConfig(this.logger, this.config), + install(paths, logger), + ]); + + return new HeadlessChromiumDriverFactory(this.screenshotMode, config, logger, binaryPath); + } catch (error) { + this.logger.error('Error in screenshotting setup, it may not function properly.'); + + throw error; + } + })(); + + return {}; + } + + start({}: CoreStart): ScreenshottingStart { + return { + diagnose: () => + from(this.browserDriverFactory).pipe(switchMap((factory) => factory.diagnose())), + getScreenshots: (options) => + from(this.browserDriverFactory).pipe( + switchMap((factory) => getScreenshots(factory, this.logger.get('screenshot'), options)) + ), + }; + } + + stop() {} +} diff --git a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts b/x-pack/plugins/screenshotting/server/screenshots/constants.ts similarity index 92% rename from x-pack/plugins/reporting/server/lib/screenshots/constants.ts rename to x-pack/plugins/screenshotting/server/screenshots/constants.ts index c62b910630874..b1064ec147745 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/constants.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/constants.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; +import { APP_WRAPPER_CLASS } from '../../../../../src/core/server'; export const DEFAULT_PAGELOAD_SELECTOR = `.${APP_WRAPPER_CLASS}`; export const CONTEXT_GETNUMBEROFITEMS = 'GetNumberOfItems'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts similarity index 69% rename from x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.test.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts index 389ae4f49f3b6..7d5791f0dfeb1 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.test.ts @@ -5,48 +5,21 @@ * 2.0. */ -import { HeadlessChromiumDriver } from '../../browsers'; -import { - createMockBrowserDriverFactory, - createMockConfig, - createMockConfigSchema, - createMockLayoutInstance, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; -import { LayoutInstance } from '../layouts'; +import type { Logger } from 'src/core/server'; +import { createMockBrowserDriver } from '../browsers/mock'; +import { createMockLayout } from '../layouts/mock'; import { getElementPositionAndAttributes } from './get_element_position_data'; describe('getElementPositionAndAttributes', () => { - let layout: LayoutInstance; - let logger: ReturnType; - let browser: HeadlessChromiumDriver; + const logger = {} as jest.Mocked; + let browser: ReturnType; + let layout: ReturnType; beforeEach(async () => { - const schema = createMockConfigSchema(); - const config = createMockConfig(schema); - const captureConfig = config.get('capture'); - const core = await createMockReportingCore(schema); + browser = createMockBrowserDriver(); + layout = createMockLayout(); - layout = createMockLayoutInstance(captureConfig); - logger = createMockLevelLogger(); - - await createMockBrowserDriverFactory(core, logger, { - evaluate: jest.fn( - async unknown>({ - fn, - args, - }: { - fn: T; - args: Parameters; - }) => fn(...args) - ), - getCreatePage: (driver) => { - browser = driver; - - return jest.fn(); - }, - }); + browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); // @see https://github.com/jsdom/jsdom/issues/653 const querySelectorAll = document.querySelectorAll.bind(document); @@ -69,7 +42,6 @@ describe('getElementPositionAndAttributes', () => { }); afterEach(() => { - jest.restoreAllMocks(); document.body.innerHTML = ''; }); @@ -87,7 +59,7 @@ describe('getElementPositionAndAttributes', () => { /> `; - await expect(getElementPositionAndAttributes(browser, layout, logger)).resolves + await expect(getElementPositionAndAttributes(browser, logger, layout)).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -131,6 +103,6 @@ describe('getElementPositionAndAttributes', () => { }); it('should return null when there are no elements matching', async () => { - await expect(getElementPositionAndAttributes(browser, layout, logger)).resolves.toBeNull(); + await expect(getElementPositionAndAttributes(browser, logger, layout)).resolves.toBeNull(); }); }); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.ts b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts similarity index 75% rename from x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts index 39163843c732f..f7576a012e738 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_element_position_data.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_element_position_data.ts @@ -6,18 +6,41 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { LayoutInstance } from '../layouts'; -import { AttributesMap, ElementsPositionAndAttribute } from './'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import { Layout } from '../layouts'; import { CONTEXT_ELEMENTATTRIBUTES } from './constants'; +export interface AttributesMap { + [key: string]: string | null; +} + +export interface ElementPosition { + boundingClientRect: { + // modern browsers support x/y, but older ones don't + top: number; + left: number; + width: number; + height: number; + }; + scroll: { + x: number; + y: number; + }; +} + +export interface ElementsPositionAndAttribute { + position: ElementPosition; + attributes: AttributesMap; +} + export const getElementPositionAndAttributes = async ( browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger + logger: Logger, + layout: Layout ): Promise => { - const endTrace = startTrace('get_element_position_data', 'read'); + const span = apm.startSpan('get_element_position_data', 'read'); const { screenshot: screenshotSelector } = layout.selectors; // data-shared-items-container let elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; try { @@ -60,7 +83,7 @@ export const getElementPositionAndAttributes = async ( if (!elementsPositionAndAttributes?.length) { throw new Error( - i18n.translate('xpack.reporting.screencapture.noElements', { + i18n.translate('xpack.screenshotting.screencapture.noElements', { defaultMessage: `An error occurred while reading the page for visualization panels: no panels were found.`, }) ); @@ -69,7 +92,7 @@ export const getElementPositionAndAttributes = async ( elementsPositionAndAttributes = null; } - endTrace(); + span?.end(); return elementsPositionAndAttributes; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts new file mode 100644 index 0000000000000..e5e70f617339d --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.test.ts @@ -0,0 +1,58 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import { createMockBrowserDriver } from '../browsers/mock'; +import { createMockLayout } from '../layouts/mock'; +import { getNumberOfItems } from './get_number_of_items'; + +describe('getNumberOfItems', () => { + const timeout = 10; + let browser: ReturnType; + let layout: ReturnType; + let logger: jest.Mocked; + + beforeEach(async () => { + browser = createMockBrowserDriver(); + layout = createMockLayout(); + logger = { debug: jest.fn() } as unknown as jest.Mocked; + + browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should determine the number of items by attribute', async () => { + document.body.innerHTML = ` +
+ `; + + await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(10); + }); + + it('should determine the number of items by selector ', async () => { + document.body.innerHTML = ` + + + + `; + + await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(3); + }); + + it('should fall back to the selector when the attribute is empty', async () => { + document.body.innerHTML = ` +
+ + + `; + + await expect(getNumberOfItems(browser, logger, timeout, layout)).resolves.toBe(2); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts similarity index 79% rename from x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts index 9e5dfa180fd0f..3677fe99d932f 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_number_of_items.ts @@ -6,23 +6,24 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { LayoutInstance } from '../layouts'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import { Layout } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - timeout: number, browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger + logger: Logger, + timeout: number, + layout: Layout ): Promise => { - const endTrace = startTrace('get_number_of_items', 'read'); + const span = apm.startSpan('get_number_of_items', 'read'); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; logger.debug( - i18n.translate('xpack.reporting.screencapture.logWaitingForElements', { + i18n.translate('xpack.screenshotting.screencapture.logWaitingForElements', { defaultMessage: 'waiting for elements or items count attribute; or not found to interrupt', }) ); @@ -58,17 +59,17 @@ export const getNumberOfItems = async ( { context: CONTEXT_GETNUMBEROFITEMS }, logger ); - } catch (err) { - logger.error(err); + } catch (error) { + logger.error(error); throw new Error( - i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { + i18n.translate('xpack.screenshotting.screencapture.readVisualizationsError', { defaultMessage: `An error occurred when trying to read the page for visualization panel info: {error}`, - values: { error: err }, + values: { error }, }) ); } - endTrace(); + span?.end(); return itemsCount; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts new file mode 100644 index 0000000000000..75576d7221f5e --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.test.ts @@ -0,0 +1,53 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import { createMockBrowserDriver } from '../browsers/mock'; +import { createMockLayout } from '../layouts/mock'; +import { getRenderErrors } from './get_render_errors'; + +describe('getRenderErrors', () => { + let browser: ReturnType; + let layout: ReturnType; + let logger: jest.Mocked; + + beforeEach(async () => { + browser = createMockBrowserDriver(); + layout = createMockLayout(); + logger = { debug: jest.fn(), warn: jest.fn() } as unknown as jest.Mocked; + + browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should extract the error messages', async () => { + document.body.innerHTML = ` +
+
+
+
+ `; + + await expect(getRenderErrors(browser, logger, layout)).resolves.toEqual([ + 'a test error', + 'a test error', + 'a test error', + 'a test error', + ]); + }); + + it('should extract the error messages, even when there are none', async () => { + document.body.innerHTML = ` + + `; + + await expect(getRenderErrors(browser, logger, layout)).resolves.toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.ts b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts similarity index 78% rename from x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts index ded4ed6238872..ad3da8d0ef488 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_render_errors.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_render_errors.ts @@ -6,17 +6,18 @@ */ import { i18n } from '@kbn/i18n'; -import type { HeadlessChromiumDriver } from '../../browsers'; -import type { LayoutInstance } from '../layouts'; -import { LevelLogger, startTrace } from '../'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import type { Layout } from '../layouts'; import { CONTEXT_GETRENDERERRORS } from './constants'; export const getRenderErrors = async ( browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger + logger: Logger, + layout: Layout ): Promise => { - const endTrace = startTrace('get_render_errors', 'read'); + const span = apm.startSpan('get_render_errors', 'read'); logger.debug('reading render errors'); const errorsFound: undefined | string[] = await browser.evaluate( { @@ -38,11 +39,11 @@ export const getRenderErrors = async ( { context: CONTEXT_GETRENDERERRORS }, logger ); - endTrace(); + span?.end(); if (errorsFound?.length) { - logger.warning( - i18n.translate('xpack.reporting.screencapture.renderErrorsFound', { + logger.warn( + i18n.translate('xpack.screenshotting.screencapture.renderErrorsFound', { defaultMessage: 'Found {count} error messages. See report object for more information.', values: { count: errorsFound.length }, }) diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts similarity index 71% rename from x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.test.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts index edd346c9b8928..2bb00413c8231 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.test.ts @@ -5,13 +5,8 @@ * 2.0. */ -import { HeadlessChromiumDriver } from '../../browsers'; -import { - createMockBrowserDriverFactory, - createMockConfigSchema, - createMockLevelLogger, - createMockReportingCore, -} from '../../test_helpers'; +import type { Logger } from 'src/core/server'; +import { createMockBrowserDriver } from '../browsers/mock'; import { getScreenshots } from './get_screenshots'; describe('getScreenshots', () => { @@ -31,31 +26,14 @@ describe('getScreenshots', () => { }, }, ]; - - let logger: ReturnType; - let browser: jest.Mocked; + let browser: ReturnType; + let logger: jest.Mocked; beforeEach(async () => { - const core = await createMockReportingCore(createMockConfigSchema()); - - logger = createMockLevelLogger(); + browser = createMockBrowserDriver(); + logger = { info: jest.fn() } as unknown as jest.Mocked; - await createMockBrowserDriverFactory(core, logger, { - evaluate: jest.fn( - async unknown>({ - fn, - args, - }: { - fn: T; - args: Parameters; - }) => fn(...args) - ), - getCreatePage: (driver) => { - browser = driver as typeof browser; - - return jest.fn(); - }, - }); + browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); }); afterEach(() => { @@ -63,7 +41,7 @@ describe('getScreenshots', () => { }); it('should return screenshots', async () => { - await expect(getScreenshots(browser, elementsPositionAndAttributes, logger)).resolves + await expect(getScreenshots(browser, logger, elementsPositionAndAttributes)).resolves .toMatchInlineSnapshot(` Array [ Object { @@ -109,7 +87,7 @@ describe('getScreenshots', () => { }); it('should forward elements positions', async () => { - await getScreenshots(browser, elementsPositionAndAttributes, logger); + await getScreenshots(browser, logger, elementsPositionAndAttributes); expect(browser.screenshot).toHaveBeenCalledTimes(2); expect(browser.screenshot).toHaveBeenNthCalledWith( @@ -126,7 +104,7 @@ describe('getScreenshots', () => { browser.screenshot.mockResolvedValue(Buffer.from('')); await expect( - getScreenshots(browser, elementsPositionAndAttributes, logger) + getScreenshots(browser, logger, elementsPositionAndAttributes) ).rejects.toBeInstanceOf(Error); }); }); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts similarity index 59% rename from x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts index 9b5f234b78363..8e03bb8a77cc9 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_screenshots.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_screenshots.ts @@ -6,17 +6,35 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { ElementsPositionAndAttribute, Screenshot } from './'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import type { ElementsPositionAndAttribute } from './get_element_position_data'; + +export interface Screenshot { + /** + * Screenshot PNG image data. + */ + data: Buffer; + + /** + * Screenshot title. + */ + title: string | null; + + /** + * Screenshot description. + */ + description: string | null; +} export const getScreenshots = async ( browser: HeadlessChromiumDriver, - elementsPositionAndAttributes: ElementsPositionAndAttribute[], - logger: LevelLogger + logger: Logger, + elementsPositionAndAttributes: ElementsPositionAndAttribute[] ): Promise => { logger.info( - i18n.translate('xpack.reporting.screencapture.takingScreenshots', { + i18n.translate('xpack.screenshotting.screencapture.takingScreenshots', { defaultMessage: `taking screenshots`, }) ); @@ -24,7 +42,7 @@ export const getScreenshots = async ( const screenshots: Screenshot[] = []; for (let i = 0; i < elementsPositionAndAttributes.length; i++) { - const endTrace = startTrace('get_screenshots', 'read'); + const span = apm.startSpan('get_screenshots', 'read'); const item = elementsPositionAndAttributes[i]; const data = await browser.screenshot(item.position); @@ -39,11 +57,11 @@ export const getScreenshots = async ( description: item.attributes.description, }); - endTrace(); + span?.end(); } logger.info( - i18n.translate('xpack.reporting.screencapture.screenshotsTaken', { + i18n.translate('xpack.screenshotting.screencapture.screenshotsTaken', { defaultMessage: `screenshots taken: {numScreenhots}`, values: { numScreenhots: screenshots.length, diff --git a/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts new file mode 100644 index 0000000000000..d277690a08282 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.test.ts @@ -0,0 +1,49 @@ +/* + * 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 type { Logger } from 'src/core/server'; +import { createMockBrowserDriver } from '../browsers/mock'; +import { createMockLayout } from '../layouts/mock'; +import { getTimeRange } from './get_time_range'; + +describe('getTimeRange', () => { + let browser: ReturnType; + let layout: ReturnType; + let logger: jest.Mocked; + + beforeEach(async () => { + browser = createMockBrowserDriver(); + layout = createMockLayout(); + logger = { debug: jest.fn(), info: jest.fn() } as unknown as jest.Mocked; + + browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should return null when there is no duration element', async () => { + await expect(getTimeRange(browser, logger, layout)).resolves.toBeNull(); + }); + + it('should return null when duration attrbute is empty', async () => { + document.body.innerHTML = ` +
+ `; + + await expect(getTimeRange(browser, logger, layout)).resolves.toBeNull(); + }); + + it('should return duration', async () => { + document.body.innerHTML = ` +
+ `; + + await expect(getTimeRange(browser, logger, layout)).resolves.toBe('10'); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts similarity index 79% rename from x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts rename to x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts index 111c68de62bdf..6734a35932b59 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/get_time_range.ts @@ -5,17 +5,18 @@ * 2.0. */ -import { LevelLogger, startTrace } from '../'; -import { LayoutInstance } from '../layouts'; -import { HeadlessChromiumDriver } from '../../browsers'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import { Layout } from '../layouts'; import { CONTEXT_GETTIMERANGE } from './constants'; export const getTimeRange = async ( browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger + logger: Logger, + layout: Layout ): Promise => { - const endTrace = startTrace('get_time_range', 'read'); + const span = apm.startSpan('get_time_range', 'read'); logger.debug('getting timeRange'); const timeRange = await browser.evaluate( @@ -46,7 +47,7 @@ export const getTimeRange = async ( logger.debug('no timeRange'); } - endTrace(); + span?.end(); return timeRange; }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts new file mode 100644 index 0000000000000..1fa7eb66192c8 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts @@ -0,0 +1,411 @@ +/* + * 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 { of, throwError, NEVER } from 'rxjs'; +import type { Logger } from 'src/core/server'; +import { createMockBrowserDriver, createMockBrowserDriverFactory } from '../browsers/mock'; +import type { HeadlessChromiumDriverFactory } from '../browsers'; +import * as Layouts from '../layouts/create_layout'; +import { createMockLayout } from '../layouts/mock'; +import { CONTEXT_ELEMENTATTRIBUTES } from './constants'; +import { getScreenshots, ScreenshotOptions } from '.'; + +/* + * Tests + */ +describe('Screenshot Observable Pipeline', () => { + let driver: ReturnType; + let driverFactory: jest.Mocked; + let layout: ReturnType; + let logger: jest.Mocked; + let options: ScreenshotOptions; + + beforeEach(async () => { + driver = createMockBrowserDriver(); + driverFactory = createMockBrowserDriverFactory(driver); + layout = createMockLayout(); + logger = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + } as unknown as jest.Mocked; + options = { + browserTimezone: 'UTC', + conditionalHeaders: {}, + layout: {}, + timeouts: { + loadDelay: 2000, + openUrl: 120000, + waitForElements: 20000, + renderComplete: 10000, + }, + urls: ['/welcome/home/start/index.htm'], + } as unknown as typeof options; + + jest.spyOn(Layouts, 'createLayout').mockReturnValue(layout); + + driver.isPageOpen.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('pipelines a single url into screenshot and timeRange', async () => { + const result = await getScreenshots(driverFactory, logger, options).toPromise(); + + expect(result).toHaveProperty('results'); + expect(result.results).toMatchInlineSnapshot(` + Array [ + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "error": undefined, + "screenshots": Array [ + Object { + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, + "description": "Default ", + "title": "Default Mock Title", + }, + ], + "timeRange": "Default GetTimeRange Result", + }, + ] + `); + }); + + it('pipelines multiple urls into', async () => { + driver.screenshot.mockResolvedValue(Buffer.from('some screenshots')); + const result = await getScreenshots(driverFactory, logger, { + ...options, + urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], + }).toPromise(); + + expect(result).toHaveProperty('results'); + expect(result.results).toMatchInlineSnapshot(` + Array [ + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "error": undefined, + "screenshots": Array [ + Object { + "data": Object { + "data": Array [ + 115, + 111, + 109, + 101, + 32, + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + 115, + ], + "type": "Buffer", + }, + "description": "Default ", + "title": "Default Mock Title", + }, + ], + "timeRange": "Default GetTimeRange Result", + }, + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object { + "description": "Default ", + "title": "Default Mock Title", + }, + "position": Object { + "boundingClientRect": Object { + "height": 600, + "left": 0, + "top": 0, + "width": 800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "error": undefined, + "screenshots": Array [ + Object { + "data": Object { + "data": Array [ + 115, + 111, + 109, + 101, + 32, + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + 115, + ], + "type": "Buffer", + }, + "description": "Default ", + "title": "Default Mock Title", + }, + ], + "timeRange": "Default GetTimeRange Result", + }, + ] + `); + + expect(driver.open).toHaveBeenCalledTimes(2); + expect(driver.open).nthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ waitForSelector: '.kbnAppWrapper' }), + expect.anything() + ); + expect(driver.open).nthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ waitForSelector: '[data-shared-page="2"]' }), + expect.anything() + ); + }); + + describe('error handling', () => { + it('recovers if waitForSelector fails', async () => { + driver.waitForSelector.mockImplementation((selectorArg: string) => { + throw new Error('Mock error!'); + }); + const result = await getScreenshots(driverFactory, logger, { + ...options, + urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php3'], + }).toPromise(); + + expect(result).toHaveProperty('results'); + expect(result.results).toMatchInlineSnapshot(` + Array [ + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 100, + "left": 0, + "top": 0, + "width": 100, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "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 { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 100, + "left": 0, + "top": 0, + "width": 100, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "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 { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, + "description": undefined, + "title": undefined, + }, + ], + "timeRange": null, + }, + ] + `); + }); + + it('observes page exit', async () => { + driverFactory.createPage.mockReturnValue( + of({ driver, exit$: throwError('Instant timeout has fired!'), metrics$: NEVER }) + ); + + await expect( + getScreenshots(driverFactory, logger, options).toPromise() + ).rejects.toMatchInlineSnapshot(`"Instant timeout has fired!"`); + }); + + it(`uses defaults for element positions and size when Kibana page is not ready`, async () => { + driver.evaluate.mockImplementation(async (_, { context }) => + context === CONTEXT_ELEMENTATTRIBUTES ? null : undefined + ); + + layout.getViewport = () => null; + const result = await getScreenshots(driverFactory, logger, options).toPromise(); + + expect(result).toHaveProperty('results'); + expect(result.results).toMatchInlineSnapshot(` + Array [ + Object { + "elementsPositionAndAttributes": Array [ + Object { + "attributes": Object {}, + "position": Object { + "boundingClientRect": Object { + "height": 1200, + "left": 0, + "top": 0, + "width": 1800, + }, + "scroll": Object { + "x": 0, + "y": 0, + }, + }, + }, + ], + "error": undefined, + "screenshots": Array [ + Object { + "data": Object { + "data": Array [ + 115, + 99, + 114, + 101, + 101, + 110, + 115, + 104, + 111, + 116, + ], + "type": "Buffer", + }, + "description": undefined, + "title": undefined, + }, + ], + "timeRange": undefined, + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts new file mode 100644 index 0000000000000..e264538d8be39 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -0,0 +1,102 @@ +/* + * 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 { from, of, Observable } from 'rxjs'; +import { + catchError, + concatMap, + first, + map, + mergeMap, + take, + takeUntil, + toArray, +} from 'rxjs/operators'; +import type { Logger } from 'src/core/server'; +import { LayoutParams } from '../../common'; +import type { HeadlessChromiumDriverFactory, PerformanceMetrics } from '../browsers'; +import { createLayout } from '../layouts'; +import type { Layout } from '../layouts'; +import { ScreenshotObservableHandler } from './observable'; +import type { ScreenshotObservableOptions, ScreenshotObservableResult } from './observable'; + +export interface ScreenshotOptions extends ScreenshotObservableOptions { + layout: LayoutParams; +} + +export interface ScreenshotResult { + /** + * Used layout instance constructed from the given options. + */ + layout: Layout; + + /** + * Collected performance metrics during the screenshotting session. + */ + metrics$: Observable; + + /** + * Screenshotting results. + */ + results: ScreenshotObservableResult[]; +} + +const DEFAULT_SETUP_RESULT = { + elementsPositionAndAttributes: null, + timeRange: null, +}; + +export function getScreenshots( + browserDriverFactory: HeadlessChromiumDriverFactory, + logger: Logger, + options: ScreenshotOptions +): Observable { + const apmTrans = apm.startTransaction('screenshot-pipeline', 'screenshotting'); + const apmCreateLayout = apmTrans?.startSpan('create-layout', 'setup'); + const layout = createLayout(options.layout); + logger.debug(`Layout: width=${layout.width} height=${layout.height}`); + apmCreateLayout?.end(); + + const apmCreatePage = apmTrans?.startSpan('create-page', 'wait'); + const { + browserTimezone, + timeouts: { openUrl: openUrlTimeout }, + } = options; + + return browserDriverFactory.createPage({ browserTimezone, openUrlTimeout }, logger).pipe( + mergeMap(({ driver, exit$, metrics$ }) => { + apmCreatePage?.end(); + metrics$.subscribe(({ cpu, memory }) => { + apmTrans?.setLabel('cpu', cpu, false); + apmTrans?.setLabel('memory', memory, false); + }); + exit$.subscribe({ error: () => apmTrans?.end() }); + + const screen = new ScreenshotObservableHandler(driver, logger, layout, options); + + return from(options.urls).pipe( + concatMap((url, index) => + screen.setupPage(index, url, apmTrans).pipe( + catchError((error) => { + screen.checkPageIsOpen(); // this fails the job if the browser has closed + + logger.error(error); + return of({ ...DEFAULT_SETUP_RESULT, error }); // allow failover screenshot capture + }), + takeUntil(exit$), + screen.getScreenshots() + ) + ), + take(options.urls.length), + toArray(), + map((results) => ({ layout, metrics$, results })) + ); + }), + first() + ); +} diff --git a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts b/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts similarity index 78% rename from x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts rename to x-pack/plugins/screenshotting/server/screenshots/inject_css.ts index 607441e719c32..d4e38600db7de 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/inject_css.ts @@ -8,8 +8,9 @@ import { i18n } from '@kbn/i18n'; import fs from 'fs'; import { promisify } from 'util'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; import { Layout } from '../layouts'; import { CONTEXT_INJECTCSS } from './constants'; @@ -17,12 +18,12 @@ const fsp = { readFile: promisify(fs.readFile) }; export const injectCustomCss = async ( browser: HeadlessChromiumDriver, - layout: Layout, - logger: LevelLogger + logger: Logger, + layout: Layout ): Promise => { - const endTrace = startTrace('inject_css', 'correction'); + const span = apm.startSpan('inject_css', 'correction'); logger.debug( - i18n.translate('xpack.reporting.screencapture.injectingCss', { + i18n.translate('xpack.screenshotting.screencapture.injectingCss', { defaultMessage: 'injecting custom css', }) ); @@ -49,12 +50,12 @@ export const injectCustomCss = async ( } catch (err) { logger.error(err); throw new Error( - i18n.translate('xpack.reporting.screencapture.injectCss', { + i18n.translate('xpack.screenshotting.screencapture.injectCss', { defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`, values: { error: err }, }) ); } - endTrace(); + span?.end(); }; diff --git a/x-pack/plugins/screenshotting/server/screenshots/mock.ts b/x-pack/plugins/screenshotting/server/screenshots/mock.ts new file mode 100644 index 0000000000000..edef9c9044c9a --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/mock.ts @@ -0,0 +1,31 @@ +/* + * 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 { of, NEVER } from 'rxjs'; +import { createMockLayout } from '../layouts/mock'; +import type { getScreenshots, ScreenshotResult } from '.'; + +export function createMockScreenshots(): jest.Mocked<{ getScreenshots: typeof getScreenshots }> { + return { + getScreenshots: jest.fn((driverFactory, logger, options) => + of({ + layout: createMockLayout(), + metrics$: NEVER, + results: options.urls.map(() => ({ + timeRange: null, + screenshots: [ + { + data: Buffer.from('screenshot'), + description: null, + title: null, + }, + ], + })), + } as ScreenshotResult) + ), + }; +} diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts new file mode 100644 index 0000000000000..5d5fbbde4e048 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { interval, throwError, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import type { Logger } from 'src/core/server'; +import type { ConditionalHeaders } from '../browsers'; +import { createMockBrowserDriver } from '../browsers/mock'; +import { createMockLayout } from '../layouts/mock'; +import { ScreenshotObservableHandler, ScreenshotObservableOptions } from './observable'; + +describe('ScreenshotObservableHandler', () => { + let browser: ReturnType; + let layout: ReturnType; + let logger: jest.Mocked; + let options: ScreenshotObservableOptions; + + beforeEach(async () => { + browser = createMockBrowserDriver(); + layout = createMockLayout(); + logger = { error: jest.fn() } as unknown as jest.Mocked; + options = { + conditionalHeaders: { + headers: { testHeader: 'testHeadValue' }, + conditions: {} as unknown as ConditionalHeaders['conditions'], + }, + timeouts: { + loadDelay: 5000, + openUrl: 30000, + waitForElements: 30000, + renderComplete: 30000, + }, + urls: [], + }; + + browser.isPageOpen.mockReturnValue(true); + }); + + describe('waitUntil', () => { + it('catches TimeoutError and references the timeout config in a custom message', async () => { + const screenshots = new ScreenshotObservableHandler(browser, logger, layout, options); + const test$ = interval(1000).pipe(screenshots.waitUntil(200, 'Test Config')); + + const testPipeline = () => test$.toPromise(); + await expect(testPipeline).rejects.toMatchInlineSnapshot( + `[Error: The "Test Config" phase took longer than 0.2 seconds.]` + ); + }); + + it('catches other Errors and explains where they were thrown', async () => { + const screenshots = new ScreenshotObservableHandler(browser, logger, layout, options); + const test$ = throwError(new Error(`Test Error to Throw`)).pipe( + screenshots.waitUntil(200, '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(browser, logger, layout, options); + const test$ = of('nice to see you').pipe(screenshots.waitUntil(20, 'xxxxxxxxxxx')); + + await expect(test$.toPromise()).resolves.toBe(`nice to see you`); + }); + }); + + describe('checkPageIsOpen', () => { + it('throws a decorated Error when page is not open', async () => { + browser.isPageOpen.mockReturnValue(false); + const screenshots = new ScreenshotObservableHandler(browser, logger, layout, options); + const test$ = 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(browser, logger, layout, options); + const test$ = of(234455).pipe( + map((input) => { + screenshots.checkPageIsOpen(); + return input; + }) + ); + + await expect(test$.toPromise()).resolves.toBe(234455); + }); + }); +}); diff --git a/x-pack/plugins/screenshotting/server/screenshots/observable.ts b/x-pack/plugins/screenshotting/server/screenshots/observable.ts new file mode 100644 index 0000000000000..b77180a9399b1 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/screenshots/observable.ts @@ -0,0 +1,258 @@ +/* + * 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 type { Transaction } from 'elastic-apm-node'; +import { defer, forkJoin, throwError, Observable } from 'rxjs'; +import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators'; +import type { Logger } from 'src/core/server'; +import type { Layout as ScreenshotModeLayout } from 'src/plugins/screenshot_mode/common'; +import type { ConditionalHeaders, HeadlessChromiumDriver } from '../browsers'; +import { getChromiumDisconnectedError } from '../browsers'; +import type { Layout } from '../layouts'; +import type { ElementsPositionAndAttribute } from './get_element_position_data'; +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 type { Screenshot } from './get_screenshots'; +import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; +import { openUrl } from './open_url'; +import type { UrlOrUrlWithContext } from './open_url'; +import { waitForRenderComplete } from './wait_for_render'; +import { waitForVisualizations } from './wait_for_visualizations'; + +export interface PhaseTimeouts { + /** + * Open URL phase timeout. + */ + openUrl: number; + + /** + * Timeout of the page readiness phase. + */ + waitForElements: number; + + /** + * Timeout of the page render phase. + */ + renderComplete: number; + + /** + * An additional delay to wait until the visualizations are ready. + */ + loadDelay: number; +} + +export interface ScreenshotObservableOptions { + /** + * The browser timezone that will be emulated in the browser instance. + * This option should be used to keep timezone on server and client in sync. + */ + browserTimezone?: string; + + /** + * Custom headers to be sent with each request. + */ + conditionalHeaders: ConditionalHeaders; + + /** + * Timeouts for each phase of the screenshot. + */ + timeouts: PhaseTimeouts; + + /** + * The list or URL to take screenshots of. + * Every item can either be a string or a tuple containing a URL and a context. + */ + urls: UrlOrUrlWithContext[]; +} + +export interface ScreenshotObservableResult { + /** + * Used time range filter. + */ + timeRange: string | null; + + /** + * Taken screenshots. + */ + screenshots: Screenshot[]; + + /** + * Error that occurred during the screenshotting. + */ + error?: Error; + + /** + * Individual visualizations might encounter errors at runtime. If there are any they are added to this + * field. Any text captured here is intended to be shown to the user for debugging purposes, reporting + * does no further sanitization on these strings. + */ + renderErrors?: string[]; + + /** + * @internal + */ + elementsPositionAndAttributes?: ElementsPositionAndAttribute[]; // NOTE: for testing +} + +interface PageSetupResults { + elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + timeRange: string | null; + error?: Error; +} + +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, +}); + +export class ScreenshotObservableHandler { + constructor( + private readonly driver: HeadlessChromiumDriver, + private readonly logger: Logger, + private readonly layout: Layout, + private options: ScreenshotObservableOptions + ) {} + + /* + * Decorates a TimeoutError with context of the phase that has timed out. + */ + public waitUntil(timeoutValue: number, label: string) { + return (source: Observable) => + source.pipe( + catchError((error) => { + throw new Error(`The "${label}" phase encountered an error: ${error}`); + }), + timeoutWith( + timeoutValue, + throwError( + new Error(`The "${label}" phase took longer than ${timeoutValue / 1000} seconds.`) + ) + ) + ); + } + + private openUrl(index: number, url: UrlOrUrlWithContext) { + return defer(() => + openUrl( + this.driver, + this.logger, + this.options.timeouts.openUrl, + index, + url, + this.options.conditionalHeaders, + this.layout.id as ScreenshotModeLayout + ) + ).pipe(this.waitUntil(this.options.timeouts.openUrl, 'open URL')); + } + + private waitForElements() { + const driver = this.driver; + const waitTimeout = this.options.timeouts.waitForElements; + + return defer(() => getNumberOfItems(driver, this.logger, waitTimeout, this.layout)).pipe( + mergeMap((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(); + + return forkJoin([ + driver.setViewport(viewport, this.logger), + waitForVisualizations(driver, this.logger, waitTimeout, itemsCount, this.layout), + ]); + }), + this.waitUntil(waitTimeout, 'wait for elements') + ); + } + + private completeRender(apmTrans: Transaction | null) { + const driver = this.driver; + const layout = this.layout; + const logger = this.logger; + + return defer(async () => { + // Waiting till _after_ elements have rendered before injecting our CSS + // allows for them to be displayed properly in many cases + await injectCustomCss(driver, logger, layout); + + const apmPositionElements = apmTrans?.startSpan('position-elements', 'correction'); + // position panel elements for print layout + await layout.positionElements?.(driver, logger); + apmPositionElements?.end(); + + await waitForRenderComplete(driver, logger, this.options.timeouts.loadDelay, layout); + }).pipe( + mergeMap(() => + forkJoin({ + timeRange: getTimeRange(driver, logger, layout), + elementsPositionAndAttributes: getElementPositionAndAttributes(driver, logger, layout), + renderErrors: getRenderErrors(driver, logger, layout), + }) + ), + this.waitUntil(this.options.timeouts.renderComplete, 'render complete') + ); + } + + public setupPage(index: number, url: UrlOrUrlWithContext, apmTrans: Transaction | null) { + return this.openUrl(index, url).pipe( + switchMapTo(this.waitForElements()), + switchMapTo(this.completeRender(apmTrans)) + ); + } + + public getScreenshots() { + return (withRenderComplete: 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, this.logger, elements); + const { timeRange, error: setupError } = data; + + return { + timeRange, + screenshots, + error: setupError, + elementsPositionAndAttributes: elements, + }; + }) + ); + } + + public checkPageIsOpen() { + if (!this.driver.isPageOpen()) { + throw getChromiumDisconnectedError(); + } + } +} diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/screenshotting/server/screenshots/open_url.ts similarity index 54% rename from x-pack/plugins/reporting/server/lib/screenshots/open_url.ts rename to x-pack/plugins/screenshotting/server/screenshots/open_url.ts index b26037aa917b8..08639122a4c26 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/open_url.ts @@ -6,52 +6,57 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { ConditionalHeaders } from '../../export_types/common'; -import { Layout } from '../layouts'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { Layout } from 'src/plugins/screenshot_mode/common'; +import { Context } from '../../common'; +import type { HeadlessChromiumDriver } from '../browsers'; +import type { ConditionalHeaders } from '../browsers'; import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; +type Url = string; +type UrlWithContext = [url: Url, context: Context]; +export type UrlOrUrlWithContext = Url | UrlWithContext; + export const openUrl = async ( - timeout: number, browser: HeadlessChromiumDriver, + logger: Logger, + timeout: number, index: number, - urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, + urlOrUrlWithContext: UrlOrUrlWithContext, conditionalHeaders: ConditionalHeaders, - layout: undefined | Layout, - logger: LevelLogger + layout?: Layout ): 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 span = apm.startSpan('open_url', 'wait'); - const endTrace = startTrace('open_url', 'wait'); let url: string; - let locator: undefined | LocatorParams; + let context: Context | undefined; - if (typeof urlOrUrlLocatorTuple === 'string') { - url = urlOrUrlLocatorTuple; + if (typeof urlOrUrlWithContext === 'string') { + url = urlOrUrlWithContext; } else { - [url, locator] = urlOrUrlLocatorTuple; + [url, context] = urlOrUrlWithContext; } try { await browser.open( url, - { conditionalHeaders, waitForSelector, timeout, locator, layout }, + { conditionalHeaders, context, layout, waitForSelector, timeout }, logger ); } catch (err) { logger.error(err); throw new Error( - i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { + i18n.translate('xpack.screenshotting.screencapture.couldntLoadKibana', { defaultMessage: `An error occurred when trying to open the Kibana URL: {error}`, values: { error: err }, }) ); } - endTrace(); + span?.end(); }; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts similarity index 84% rename from x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts rename to x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts index 1ac4b58b61507..bdc75572e685e 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/wait_for_render.ts @@ -6,21 +6,22 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { LayoutInstance } from '../layouts'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import { Layout } from '../layouts'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - loadDelay: number, browser: HeadlessChromiumDriver, - layout: LayoutInstance, - logger: LevelLogger + logger: Logger, + loadDelay: number, + layout: Layout ) => { - const endTrace = startTrace('wait_for_render', 'wait'); + const span = apm.startSpan('wait_for_render', 'wait'); logger.debug( - i18n.translate('xpack.reporting.screencapture.waitingForRenderComplete', { + i18n.translate('xpack.screenshotting.screencapture.waitingForRenderComplete', { defaultMessage: 'waiting for rendering to complete', }) ); @@ -74,11 +75,11 @@ export const waitForRenderComplete = async ( ) .then(() => { logger.debug( - i18n.translate('xpack.reporting.screencapture.renderIsComplete', { + i18n.translate('xpack.screenshotting.screencapture.renderIsComplete', { defaultMessage: 'rendering is complete', }) ); - endTrace(); + span?.end(); }); }; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts b/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts similarity index 80% rename from x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts rename to x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts index 10a53b238d892..3102f444c2340 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/wait_for_visualizations.ts @@ -6,9 +6,10 @@ */ import { i18n } from '@kbn/i18n'; -import { LevelLogger, startTrace } from '../'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { LayoutInstance } from '../layouts'; +import apm from 'elastic-apm-node'; +import type { Logger } from 'src/core/server'; +import type { HeadlessChromiumDriver } from '../browsers'; +import { Layout } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; interface CompletedItemsCountParameters { @@ -36,17 +37,17 @@ const getCompletedItemsCount = ({ * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - timeout: number, browser: HeadlessChromiumDriver, + logger: Logger, + timeout: number, toEqual: number, - layout: LayoutInstance, - logger: LevelLogger + layout: Layout ): Promise => { - const endTrace = startTrace('wait_for_visualizations', 'wait'); + const span = apm.startSpan('wait_for_visualizations', 'wait'); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( - i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', { + i18n.translate('xpack.screenshotting.screencapture.waitingForRenderedElements', { defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`, values: { itemsCount: toEqual }, }) @@ -63,12 +64,12 @@ export const waitForVisualizations = async ( } catch (err) { logger.error(err); throw new Error( - i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { + i18n.translate('xpack.screenshotting.screencapture.couldntFinishRendering', { defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. {error}`, values: { count: toEqual, error: err }, }) ); } - endTrace(); + span?.end(); }; diff --git a/x-pack/plugins/screenshotting/server/utils.ts b/x-pack/plugins/screenshotting/server/utils.ts new file mode 100644 index 0000000000000..eb6b18ef85906 --- /dev/null +++ b/x-pack/plugins/screenshotting/server/utils.ts @@ -0,0 +1,13 @@ +/* + * 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 { ChromiumArchivePaths, download as baseDownload, install as baseInstall } from './browsers'; + +const paths = new ChromiumArchivePaths(); + +export const download = baseDownload.bind(undefined, paths); +export const install = baseInstall.bind(undefined, paths); diff --git a/x-pack/plugins/screenshotting/tsconfig.json b/x-pack/plugins/screenshotting/tsconfig.json new file mode 100644 index 0000000000000..a1e81c4fb38d9 --- /dev/null +++ b/x-pack/plugins/screenshotting/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/screenshot_mode/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 55efcd4d15a33..7e05d4df7644a 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5962,9 +5962,6 @@ "available": { "type": "boolean" }, - "browser_type": { - "type": "keyword" - }, "enabled": { "type": "boolean" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4694b8c63d7e0..02b3ea61732af 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19972,11 +19972,6 @@ "xpack.remoteClusters.updateRemoteCluster.unknownRemoteClusterErrorMessage": "ES からレスポンスが返らず、クラスターを編集できません。", "xpack.reporting.apiClient.unknownError": "レポートジョブ{job}が失敗しました。不明なエラーです。", "xpack.reporting.breadcrumb": "レポート", - "xpack.reporting.browsers.chromium.errorDetected": "レポートでエラーが発生しました:{err}", - "xpack.reporting.browsers.chromium.pageErrorDetected": "レポートのページで処理されていないエラーが発生し、無視されるます:{err}", - "xpack.reporting.chromiumDriver.disallowedOutgoingUrl": "許可されていない送信URLを受信しました:「{interceptedUrl}」。要求が失敗しています。ブラウザーを終了しています。", - "xpack.reporting.chromiumDriver.failedToCompleteRequest": "リクエストを完了できませんでした:{error}", - "xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders": "ヘッダーを使用してリクエストを完了できませんでした:{error}", "xpack.reporting.dashboard.csvDownloadStartedMessage": "間もなく CSV がダウンロードされます。", "xpack.reporting.dashboard.csvDownloadStartedTitle": "CSV のダウンロードが開始しました", "xpack.reporting.dashboard.downloadCsvPanelTitle": "CSV をダウンロード", @@ -20007,8 +20002,6 @@ "xpack.reporting.deprecations.reportingRoleUsers.manualStepTwo": "存在する場合は、kibana.ymlで「xpack.reporting.roles.allow」を削除します。", "xpack.reporting.deprecations.reportingRoleUsersMessage": "既存のユーザーには廃止予定の設定によって付与されたレポート権限があります。", "xpack.reporting.deprecations.reportingRoleUsersTitle": "\"{reportingUserRoleName}\"ロールは廃止予定です。ユーザーロールを確認してください", - "xpack.reporting.diagnostic.browserCrashed": "ブラウザーは起動中に異常終了しました", - "xpack.reporting.diagnostic.browserErrored": "ブラウザープロセスは起動中にエラーが発生しました", "xpack.reporting.diagnostic.browserMissingDependency": "システム依存関係が不足しているため、ブラウザーを正常に起動できませんでした。{url}を参照してください", "xpack.reporting.diagnostic.browserMissingFonts": "ブラウザーはデフォルトフォントを検索できませんでした。この問題を修正するには、{url}を参照してください。", "xpack.reporting.diagnostic.noUsableSandbox": "Chromiumサンドボックスを使用できません。これは「xpack.reporting.capture.browser.chromium.disableSandbox」で無効にすることができます。この作業はご自身の責任で行ってください。{url}を参照してください", @@ -20059,7 +20052,6 @@ "xpack.reporting.listing.ilmPolicyCallout.migrationNeededDescription": "レポートが一貫して管理されることを保証するために、すべてのレポートインデックスは{ilmPolicyName}ポリシーを使用します。", "xpack.reporting.listing.ilmPolicyCallout.migrationNeededTitle": "レポートの新しいライフサイクルポリシーを適用", "xpack.reporting.listing.infoPanel.attemptsInfo": "試行", - "xpack.reporting.listing.infoPanel.browserTypeInfo": "ブラウザータイプ", "xpack.reporting.listing.infoPanel.completedAtInfo": "完了日時", "xpack.reporting.listing.infoPanel.contentTypeInfo": "コンテンツタイプ", "xpack.reporting.listing.infoPanel.createdAtInfo": "作成日時:", @@ -20135,20 +20127,6 @@ "xpack.reporting.redirectApp.redirectConsoleErrorPrefixLabel": "リダイレクトページエラー:", "xpack.reporting.registerFeature.reportingDescription": "Discover、可視化、ダッシュボードから生成されたレポートを管理します。", "xpack.reporting.registerFeature.reportingTitle": "レポート", - "xpack.reporting.screencapture.browserWasClosed": "ブラウザーは予期せず終了しました。詳細については、サーバーログを確認してください。", - "xpack.reporting.screencapture.couldntFinishRendering": "{count} 件のビジュアライゼーションのレンダリングが完了するのを待つ間にエラーが発生しました。{error}", - "xpack.reporting.screencapture.couldntLoadKibana": "Kibana URLを開こうとするときにエラーが発生しました:{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": "ビジュアライゼーションパネル情報のページを読み取ろうとしたときにエラーが発生しました:{error}", - "xpack.reporting.screencapture.renderErrorsFound": "{count}件のエラーメッセージが見つかりました。詳細については、レポートオブジェクトを参照してください。", - "xpack.reporting.screencapture.renderIsComplete": "レンダリングが完了しました", - "xpack.reporting.screencapture.screenshotsTaken": "撮影したスクリーンショット:{numScreenhots}", - "xpack.reporting.screencapture.takingScreenshots": "スクリーンショットの撮影中", - "xpack.reporting.screencapture.waitingForRenderComplete": "レンダリングの完了を待っています", - "xpack.reporting.screencapture.waitingForRenderedElements": "レンダリングされた {itemsCount} 個の要素が DOM に入るのを待っています", "xpack.reporting.screenCapturePanelContent.canvasLayoutHelpText": "枠線とフッターロゴを削除", "xpack.reporting.screenCapturePanelContent.canvasLayoutLabel": "全ページレイアウト", "xpack.reporting.screenCapturePanelContent.optimizeForPrintingHelpText": "複数のページを使用します。ページごとに最大2のビジュアライゼーションが表示されます", @@ -20447,6 +20425,30 @@ "xpack.savedObjectsTagging.validation.description.errorTooLong": "タグ説明は {length} 文字以下で入力してください", "xpack.savedObjectsTagging.validation.name.errorTooLong": "タグ名は {length} 文字以下で入力してください", "xpack.savedObjectsTagging.validation.name.errorTooShort": "タグ名は {length} 文字以上で入力してください", + "xpack.screenshotting.browsers.chromium.errorDetected": "レポートでエラーが発生しました:{err}", + "xpack.screenshotting.browsers.chromium.pageErrorDetected": "レポートのページで処理されていないエラーが発生し、無視されるます:{err}", + "xpack.screenshotting.chromiumDriver.disallowedOutgoingUrl": "許可されていない送信URLを受信しました:「{interceptedUrl}」。要求が失敗しています。ブラウザーを終了しています。", + "xpack.screenshotting.chromiumDriver.failedToCompleteRequest": "リクエストを完了できませんでした:{error}", + "xpack.screenshotting.chromiumDriver.failedToCompleteRequestUsingHeaders": "ヘッダーを使用してリクエストを完了できませんでした:{error}", + "xpack.screenshotting.diagnostic.browserCrashed": "ブラウザーは起動中に異常終了しました", + "xpack.screenshotting.diagnostic.browserErrored": "ブラウザープロセスは起動中にエラーが発生しました", + "xpack.screenshotting.screencapture.browserWasClosed": "ブラウザーは予期せず終了しました。詳細については、サーバーログを確認してください。", + "xpack.screenshotting.screencapture.couldntFinishRendering": "{count} 件のビジュアライゼーションのレンダリングが完了するのを待つ間にエラーが発生しました。{error}", + "xpack.screenshotting.screencapture.couldntLoadKibana": "Kibana URLを開こうとするときにエラーが発生しました:{error}", + "xpack.screenshotting.screencapture.injectCss": "Kibana CSS をレポート用に更新しようとしたときにエラーが発生しました。{error}", + "xpack.screenshotting.screencapture.injectingCss": "カスタム css の投入中", + "xpack.screenshotting.screencapture.logWaitingForElements": "要素または項目のカウント属性を待ち、または見つからないため中断", + "xpack.screenshotting.screencapture.noElements": "ビジュアライゼーションパネルのページを読み取る間にエラーが発生しました:パネルが見つかりませんでした。", + "xpack.screenshotting.screencapture.readVisualizationsError": "ビジュアライゼーションパネル情報のページを読み取ろうとしたときにエラーが発生しました:{error}", + "xpack.screenshotting.screencapture.renderErrorsFound": "{count}件のエラーメッセージが見つかりました。詳細については、レポートオブジェクトを参照してください。", + "xpack.screenshotting.screencapture.renderIsComplete": "レンダリングが完了しました", + "xpack.screenshotting.screencapture.screenshotsTaken": "撮影したスクリーンショット:{numScreenhots}", + "xpack.screenshotting.screencapture.takingScreenshots": "スクリーンショットの撮影中", + "xpack.screenshotting.screencapture.waitingForRenderComplete": "レンダリングの完了を待っています", + "xpack.screenshotting.screencapture.waitingForRenderedElements": "レンダリングされた {itemsCount} 個の要素が DOM に入るのを待っています", + "xpack.screenshotting.serverConfig.autoSet.sandboxDisabled": "Chromiumサンドボックスは保護が強化されていますが、{osName} OSではサポートされていません。自動的に'{configKey}: true'を設定しています。", + "xpack.screenshotting.serverConfig.autoSet.sandboxEnabled": "Chromiumサンドボックスは保護が強化され、{osName} OSでサポートされています。自動的にChromiumサンドボックスを有効にしています。", + "xpack.screenshotting.serverConfig.osDetected": "OSは'{osName}'で実行しています", "xpack.searchProfiler.advanceTimeDescription": "イテレーターを次のドキュメントに進めるためにかかった時間。", "xpack.searchProfiler.aggregationProfileTabTitle": "集約プロフィール", "xpack.searchProfiler.basicLicenseTitle": "基本", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ba523a4236b7d..4296209d9fce1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20264,11 +20264,6 @@ "xpack.remoteClusters.updateRemoteCluster.unknownRemoteClusterErrorMessage": "无法编辑集群,ES 未返回任何响应。", "xpack.reporting.apiClient.unknownError": "报告作业 {job} 失败。错误未知。", "xpack.reporting.breadcrumb": "Reporting", - "xpack.reporting.browsers.chromium.errorDetected": "报告时遇到错误:{err}", - "xpack.reporting.browsers.chromium.pageErrorDetected": "Reporting 在将忽略的页面上遇到未捕获的错误:{err}", - "xpack.reporting.chromiumDriver.disallowedOutgoingUrl": "收到禁止的传出 URL:“{interceptedUrl}”。请求失败,关闭浏览器。", - "xpack.reporting.chromiumDriver.failedToCompleteRequest": "无法完成请求:{error}", - "xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders": "无法完成使用 headers 的请求:{error}", "xpack.reporting.dashboard.csvDownloadStartedMessage": "您的 CSV 将很快下载。", "xpack.reporting.dashboard.csvDownloadStartedTitle": "CSV 下载已开始", "xpack.reporting.dashboard.downloadCsvPanelTitle": "下载 CSV", @@ -20299,8 +20294,6 @@ "xpack.reporting.deprecations.reportingRoleUsers.manualStepTwo": "移除 kibana.yml 中的“xpack.reporting.roles.allow”(如果存在)。", "xpack.reporting.deprecations.reportingRoleUsersMessage": "现有用户具有由过时设置授予的 Reporting 权限。", "xpack.reporting.deprecations.reportingRoleUsersTitle": "“{reportingUserRoleName}”角色已过时:检查用户角色", - "xpack.reporting.diagnostic.browserCrashed": "启动期间浏览器已异常退出", - "xpack.reporting.diagnostic.browserErrored": "启动时浏览器进程引发了错误", "xpack.reporting.diagnostic.browserMissingDependency": "由于缺少系统依赖项,浏览器无法正常启动。请参见 {url}", "xpack.reporting.diagnostic.browserMissingFonts": "浏览器找不到默认字体。请参见 {url} 以解决此问题。", "xpack.reporting.diagnostic.noUsableSandbox": "无法使用 Chromium 沙盒。您自行承担使用“xpack.reporting.capture.browser.chromium.disableSandbox”禁用此项的风险。请参见 {url}", @@ -20351,7 +20344,6 @@ "xpack.reporting.listing.ilmPolicyCallout.migrationNeededDescription": "为了确保得到一致的管理,所有报告索引应使用 {ilmPolicyName} 策略。", "xpack.reporting.listing.ilmPolicyCallout.migrationNeededTitle": "为报告应用新的生命周期策略", "xpack.reporting.listing.infoPanel.attemptsInfo": "尝试次数", - "xpack.reporting.listing.infoPanel.browserTypeInfo": "浏览器类型", "xpack.reporting.listing.infoPanel.completedAtInfo": "完成时间", "xpack.reporting.listing.infoPanel.contentTypeInfo": "内容类型", "xpack.reporting.listing.infoPanel.createdAtInfo": "创建于", @@ -20428,20 +20420,6 @@ "xpack.reporting.redirectApp.redirectConsoleErrorPrefixLabel": "重定向页面错误:", "xpack.reporting.registerFeature.reportingDescription": "管理您从 Discover、Visualize 和 Dashboard 生成的报告。", "xpack.reporting.registerFeature.reportingTitle": "Reporting", - "xpack.reporting.screencapture.browserWasClosed": "浏览器已意外关闭!有关更多信息,请查看服务器日志。", - "xpack.reporting.screencapture.couldntFinishRendering": "尝试等候 {count} 个可视化完成渲染时发生错误。{error}", - "xpack.reporting.screencapture.couldntLoadKibana": "尝试打开 Kibana URL 时发生错误:{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": "尝试读取页面以获取可视化面板信息时发生错误:{error}", - "xpack.reporting.screencapture.renderErrorsFound": "找到 {count} 条错误消息。请参阅报告对象了解更多信息。", - "xpack.reporting.screencapture.renderIsComplete": "渲染已完成", - "xpack.reporting.screencapture.screenshotsTaken": "已捕获的屏幕截图:{numScreenhots}", - "xpack.reporting.screencapture.takingScreenshots": "正在捕获屏幕截图", - "xpack.reporting.screencapture.waitingForRenderComplete": "正在等候渲染完成", - "xpack.reporting.screencapture.waitingForRenderedElements": "正在等候 {itemsCount} 个已渲染元素进入 DOM", "xpack.reporting.screenCapturePanelContent.canvasLayoutHelpText": "删除边框和页脚徽标", "xpack.reporting.screenCapturePanelContent.canvasLayoutLabel": "全页面布局", "xpack.reporting.screenCapturePanelContent.optimizeForPrintingHelpText": "使用多页,每页最多显示 2 个可视化", @@ -20752,6 +20730,30 @@ "xpack.savedObjectsTagging.validation.description.errorTooLong": "标签描述不能超过 {length} 个字符", "xpack.savedObjectsTagging.validation.name.errorTooLong": "标签名称不能超过 {length} 个字符", "xpack.savedObjectsTagging.validation.name.errorTooShort": "标签名称必须至少有 {length} 个字符", + "xpack.screenshotting.browsers.chromium.errorDetected": "报告时遇到错误:{err}", + "xpack.screenshotting.browsers.chromium.pageErrorDetected": "Reporting 在将忽略的页面上遇到未捕获的错误:{err}", + "xpack.screenshotting.chromiumDriver.disallowedOutgoingUrl": "收到禁止的传出 URL:“{interceptedUrl}”。请求失败,关闭浏览器。", + "xpack.screenshotting.chromiumDriver.failedToCompleteRequest": "无法完成请求:{error}", + "xpack.screenshotting.chromiumDriver.failedToCompleteRequestUsingHeaders": "无法完成使用 headers 的请求:{error}", + "xpack.screenshotting.diagnostic.browserCrashed": "启动期间浏览器已异常退出", + "xpack.screenshotting.diagnostic.browserErrored": "启动时浏览器进程引发了错误", + "xpack.screenshotting.screencapture.browserWasClosed": "浏览器已意外关闭!有关更多信息,请查看服务器日志。", + "xpack.screenshotting.screencapture.couldntFinishRendering": "尝试等候 {count} 个可视化完成渲染时发生错误。{error}", + "xpack.screenshotting.screencapture.couldntLoadKibana": "尝试打开 Kibana URL 时发生错误:{error}", + "xpack.screenshotting.screencapture.injectCss": "尝试为 Reporting 更新 Kibana CSS 时发生错误。{error}", + "xpack.screenshotting.screencapture.injectingCss": "正在注入定制 css", + "xpack.screenshotting.screencapture.logWaitingForElements": "等候元素或项目计数属性;或未发现要中断", + "xpack.screenshotting.screencapture.noElements": "读取页面以获取可视化面板时发生了错误:未找到任何面板。", + "xpack.screenshotting.screencapture.readVisualizationsError": "尝试读取页面以获取可视化面板信息时发生错误:{error}", + "xpack.screenshotting.screencapture.renderErrorsFound": "找到 {count} 条错误消息。请参阅报告对象了解更多信息。", + "xpack.screenshotting.screencapture.renderIsComplete": "渲染已完成", + "xpack.screenshotting.screencapture.screenshotsTaken": "已捕获的屏幕截图:{numScreenhots}", + "xpack.screenshotting.screencapture.takingScreenshots": "正在捕获屏幕截图", + "xpack.screenshotting.screencapture.waitingForRenderComplete": "正在等候渲染完成", + "xpack.screenshotting.screencapture.waitingForRenderedElements": "正在等候 {itemsCount} 个已渲染元素进入 DOM", + "xpack.screenshotting.serverConfig.autoSet.sandboxDisabled": "Chromium 沙盒提供附加保护层,但不受 {osName} OS 支持。自动设置“{configKey}: true”。", + "xpack.screenshotting.serverConfig.autoSet.sandboxEnabled": "Chromium 沙盒提供附加保护层,受 {osName} OS 支持。自动启用 Chromium 沙盒。", + "xpack.screenshotting.serverConfig.osDetected": "正在以下 OS 上运行:“{osName}”", "xpack.searchProfiler.advanceTimeDescription": "将迭代器推进至下一文档所用时间。", "xpack.searchProfiler.aggregationProfileTabTitle": "聚合配置文件", "xpack.searchProfiler.basicLicenseTitle": "基本级", diff --git a/x-pack/tasks/download_chromium.ts b/x-pack/tasks/download_chromium.ts index 6e1efc60f3185..51394bfb00349 100644 --- a/x-pack/tasks/download_chromium.ts +++ b/x-pack/tasks/download_chromium.ts @@ -5,14 +5,13 @@ * 2.0. */ -import { LevelLogger } from '../plugins/reporting/server/lib'; -import { ensureBrowserDownloaded } from '../plugins/reporting/server/browsers/download'; +import { download } from '../plugins/screenshotting/server/utils'; export const downloadChromium = async () => { // eslint-disable-next-line no-console const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); - const innerLogger = { - get: () => innerLogger, + const logger = { + get: () => logger, debug: consoleLogger('debug'), info: consoleLogger('info'), warn: consoleLogger('warn'), @@ -22,6 +21,5 @@ export const downloadChromium = async () => { log: consoleLogger('log'), }; - const levelLogger = new LevelLogger(innerLogger); - await ensureBrowserDownloaded(levelLogger); + await download(logger); }; diff --git a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts index b5f75ed7d501c..3574ee5b8b2b1 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security.config.ts @@ -33,7 +33,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...apiConfig.get('kbnTestServer'), serverArgs: [ ...apiConfig.get('kbnTestServer.serverArgs'), - `--xpack.reporting.capture.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, + `--xpack.screenshotting.networkPolicy.rules=${JSON.stringify(testPolicyRules)}`, `--xpack.reporting.capture.maxAttempts=1`, `--xpack.reporting.csv.maxSizeBytes=6000`, '--xpack.reporting.roles.enabled=false', // Reporting access control is implemented by sub-feature application privileges