[Reporting] Decouple screenshotting plugin from the reporting (#120110)

* Add screenshotting plugin
* Move screenshotting plugin configuration options
* Remove unused browser type configuration option
This commit is contained in:
Michael Dokolin 2021-12-06 22:00:57 +01:00 committed by GitHub
parent a74825f86f
commit 903e75ee03
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
178 changed files with 3775 additions and 3981 deletions

View file

@ -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

View file

@ -65,7 +65,7 @@ it('produces the right watch and ignore list', () => {
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/target/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/scripts/**,
<absolute path>/x-pack/test/plugin_functional/plugins/resolver_test/docs/**,
<absolute path>/x-pack/plugins/reporting/chromium,
<absolute path>/x-pack/plugins/screenshotting/chromium,
<absolute path>/x-pack/plugins/security_solution/cypress,
<absolute path>/x-pack/plugins/apm/scripts,
<absolute path>/x-pack/plugins/apm/ftr_e2e,

View file

@ -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

View file

@ -117,3 +117,4 @@ pageLoadAssetSize:
dataViewManagement: 5000
reporting: 57003
visTypeHeatmap: 25340
screenshotting: 17017

View file

@ -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();
}
},
};

View file

@ -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)(

2
x-pack/.gitignore vendored
View file

@ -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

View file

@ -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",

View file

@ -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"]
}

View file

@ -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,

View file

@ -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';

View file

@ -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',

View file

@ -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;

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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
>;

View file

@ -18,6 +18,7 @@
"uiActions",
"taskManager",
"embeddable",
"screenshotting",
"screenshotMode",
"share",
"features"

View file

@ -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;

View file

@ -141,12 +141,6 @@ export const ReportInfoFlyoutContent: FunctionComponent<Props> = ({ 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();

View file

@ -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,

View file

@ -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(
<EuiErrorBoundary>
<RedirectApp apiClient={apiClient} history={history} share={share} />
<RedirectApp
apiClient={apiClient}
history={history}
screenshotting={screenshotting}
share={share}
/>
</EuiErrorBoundary>,
element
);

View file

@ -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<Props> = ({ share, apiClient }) => {
type ReportingContext = Record<string, LocatorParams>;
export const RedirectApp: FunctionComponent<Props> = ({ apiClient, screenshotting, share }) => {
const [error, setError] = useState<undefined | Error>();
useEffect(() => {
@ -53,9 +57,8 @@ export const RedirectApp: FunctionComponent<Props> = ({ share, apiClient }) => {
const result = await apiClient.getInfo(jobId as string);
locatorParams = result?.locatorParams?.[0];
} else {
locatorParams = (window as unknown as Record<string, LocatorParams>)[
REPORTING_REDIRECT_LOCATOR_STORE_KEY
];
locatorParams =
screenshotting.getContext<ReportingContext>()?.[REPORTING_REDIRECT_LOCATOR_STORE_KEY];
}
if (!locatorParams) {
@ -70,7 +73,7 @@ export const RedirectApp: FunctionComponent<Props> = ({ share, apiClient }) => {
throw e;
}
})();
}, [share, apiClient]);
}, [apiClient, screenshotting, share]);
return (
<div className="reportingRedirectApp__interstitialPage">

View file

@ -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 {

View file

@ -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<Props, State> {
this.setState({ useCanvasLayout: evt.target.checked, usePrintLayout: false });
};
private getLayout = (): Required<LayoutParams> => {
private getLayout = (): LayoutParams => {
const { layout: outerLayout } = this.props.getJobParams();
let dimensions = outerLayout?.dimensions;

View file

@ -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<typeof puppeteer>).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<typeof puppeteer>).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."`
);
});
});

View file

@ -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<never> }> {
// FIXME: 'create' is deprecated
return Rx.Observable.create(async (observer: InnerSubscriber<unknown, unknown>) => {
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<never>: 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<void> {
const consoleMessages$ = Rx.fromEvent<puppeteer.ConsoleMessage>(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<Error>(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<puppeteer.HTTPRequest>(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<void> {
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<void>(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<Error>(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$);
}
}

View file

@ -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);
})
);
};

View file

@ -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 };

View file

@ -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));

View file

@ -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<LevelLogger>;
beforeEach(() => {
logger = {
debug: jest.fn(),
error: jest.fn(),
warning: jest.fn(),
} as unknown as typeof logger;
(md5 as jest.MockedFunction<typeof md5>).mockImplementation(
async (packagePath) =>
chromium.paths.packages.find(
(packageInfo) => chromium.paths.resolvePath(packageInfo) === packagePath
)?.archiveChecksum ?? 'some-md5'
);
(download as jest.MockedFunction<typeof download>).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<typeof download>).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<typeof download>).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<typeof md5>).mockResolvedValueOnce('random-md5');
await ensureBrowserDownloaded(logger);
expect(download).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -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;
}
})
);
}

View file

@ -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';

View file

@ -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);
};

View file

@ -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<string> } {
const binaryPath$ = new Rx.Subject<string>();
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$,
};
}

View file

@ -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",

View file

@ -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) => {

View file

@ -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 },
},
},
};
})
);
}

View file

@ -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);

View file

@ -19,6 +19,7 @@ export const config: PluginConfigDescriptor<ReportingConfigType> = {
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<ReportingConfigType> = {
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 } },

View file

@ -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) => {

View file

@ -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,

View file

@ -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<BasePath, 'set'>;
@ -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<ScreenshotResult> {
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);
}

View file

@ -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<LayoutSelectorDictionary> }
): 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);
if (apmLayout) 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();
})
);
return screenshots$;
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,
};
apmLayout?.end();
const apmScreenshots = apmTrans?.startSpan('screenshots-pipeline', 'setup');
let apmBuffer: typeof apm.currentSpan;
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();
})
);
}

View file

@ -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 {

View file

@ -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,

View file

@ -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<typeof createMockLayout>;
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);
});
});

View file

@ -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: {

View file

@ -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());

View file

@ -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<RunTaskFn<TaskPayloadPNG>> =
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<TaskRunResult> = 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())
);

View file

@ -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());

View file

@ -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<RunTaskFn<TaskPayloadPNGV2>> =
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<TaskRunResult> = Rx.of(1).pipe(
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
@ -41,25 +40,21 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPNGV2>> =
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())
);

View file

@ -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<Writable>;
@ -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());

View file

@ -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<RunTaskFn<TaskPayloadPDF>> =
@ -32,8 +32,6 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDF>> =
const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup');
let apmGeneratePdf: { end: () => void } | null | undefined;
const generatePdfObservable = await generatePdfObservableFactory(reporting);
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
@ -49,12 +47,15 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDF>> =
apmGeneratePdf = apmTrans?.startSpan('generate-pdf-pipeline', 'execute');
return generatePdfObservable(
reporting,
jobLogger,
title,
urls,
browserTimezone,
conditionalHeaders,
layout,
{
urls,
browserTimezone,
conditionalHeaders,
layout,
},
logo
);
}),

View file

@ -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();
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 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();
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 layout = createLayout(captureConfig, layoutParams);
logger.debug(`Layout: width=${layout.width} height=${layout.height}`);
tracker.endLayout();
const pdfOutput = new PdfMaker(layout, logo);
if (title) {
const timeRange = getTimeRange(results);
title += timeRange ? ` - ${timeRange}` : '';
pdfOutput.setTitle(title);
}
tracker.endSetup();
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,
});
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();
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();
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);
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.endGetBuffer();
} catch (err) {
logger.error(`Could not generate the PDF buffer!`);
logger.error(err);
}
tracker.end();
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$;
};
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[]),
};
})
);
}

View file

@ -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();
},

View file

@ -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());

View file

@ -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<RunTaskFn<TaskPayloadPDFV2>> =
@ -31,8 +31,6 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDFV2>> =
const apmGetAssets = apmTrans?.startSpan('get-assets', 'setup');
let apmGeneratePdf: { end: () => void } | null | undefined;
const generatePdfObservable = await generatePdfObservableFactory(reporting);
const process$: Rx.Observable<TaskRunResult> = Rx.of(1).pipe(
mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)),
map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)),
@ -46,13 +44,16 @@ export const runTaskFnFactory: RunTaskFnFactory<RunTaskFn<TaskPayloadPDFV2>> =
apmGeneratePdf = apmTrans?.startSpan('generate-pdf-pipeline', 'execute');
return generatePdfObservable(
reporting,
jobLogger,
job,
title,
locatorParams,
browserTimezone,
conditionalHeaders,
layout,
{
browserTimezone,
conditionalHeaders,
layout,
},
logo
);
}),

View file

@ -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();
export function generatePdfObservable(
reporting: ReportingCore,
logger: LevelLogger,
job: TaskPayloadPDFV2,
title: string,
locatorParams: LocatorParams[],
options: Omit<ScreenshotOptions, 'urls'>,
logo?: string
): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> {
const tracker = getTracker();
tracker.startScreenshots();
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();
/**
* 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 layout = createLayout(captureConfig, layoutParams);
logger.debug(`Layout: width=${layout.width} height=${layout.height}`);
tracker.endLayout();
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();
tracker.startScreenshots();
const pdfOutput = new PdfMaker(layout, logo);
if (title) {
const timeRange = getTimeRange(results);
title += timeRange ? ` - ${timeRange}` : '';
pdfOutput.setTitle(title);
}
tracker.endSetup();
/**
* 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,
});
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();
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();
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);
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.endGetBuffer();
} catch (err) {
logger.error(`Could not generate the PDF buffer!`);
logger.error(err);
}
tracker.end();
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 {
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$;
};
return screenshots$;
}

View file

@ -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();
},

View file

@ -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);
}

View file

@ -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<void>;
}
export type LayoutInstance = Layout & LayoutSelectors & Partial<Size>;

View file

@ -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<LevelLogger>;
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 <T extends (...args: unknown[]) => unknown>({
fn,
args,
}: {
fn: T;
args: Parameters<T>;
}) => 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 = `
<div itemsSelector="10" />
`;
await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(10);
});
it('should determine the number of items by selector ', async () => {
document.body.innerHTML = `
<renderedSelector />
<renderedSelector />
<renderedSelector />
`;
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 = `
<div itemsSelector />
<renderedSelector />
<renderedSelector />
`;
await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(2);
});
});

View file

@ -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<LevelLogger>;
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 <T extends (...args: unknown[]) => unknown>({
fn,
args,
}: {
fn: T;
args: Parameters<T>;
}) => fn(...args)
),
getCreatePage: (driver) => {
browser = driver;
return jest.fn();
},
});
});
afterEach(() => {
document.body.innerHTML = '';
});
it('should extract the error messages', async () => {
document.body.innerHTML = `
<div dataRenderErrorSelector="a test error" />
<div dataRenderErrorSelector="a test error" />
<div dataRenderErrorSelector="a test error" />
<div dataRenderErrorSelector="a test error" />
`;
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 = `
<renderedSelector />
`;
await expect(getRenderErrors(browser, layout, logger)).resolves.toEqual(undefined);
});
});

View file

@ -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<LevelLogger>;
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 <T extends (...args: unknown[]) => unknown>({
fn,
args,
}: {
fn: T;
args: Parameters<T>;
}) => 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 = `
<div timefilterDurationSelector />
`;
await expect(getTimeRange(browser, layout, logger)).resolves.toBeNull();
});
it('should return duration', async () => {
document.body.innerHTML = `
<div timefilterDurationSelector="10" />
`;
await expect(getTimeRange(browser, layout, logger)).resolves.toBe('10');
});
});

View file

@ -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
}

View file

@ -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,
},
]
`);
});
});
});

View file

@ -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<ScreenshotResults[]> {
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,
};

View file

@ -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);
});
});
});

View file

@ -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<O>(phase: PhaseInstance) {
const { timeoutValue, label, configValue } = phase;
return (source: Rx.Observable<O>) =>
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<PageSetupResults>) =>
withRenderComplete.pipe(
mergeMap(async (data: PageSetupResults): Promise<ScreenshotResults> => {
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,
});

View file

@ -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 },

View file

@ -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',

View file

@ -38,7 +38,6 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
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<ReportSource & ReportDocumentHead> {
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<ReportSource & ReportDocumentHead> {
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<ReportSource & ReportDocumentHead> {
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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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';

View file

@ -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<ReportingRequestHandlerContext>();
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

View file

@ -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<ReturnType<typeof setupServer>>;
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<ScreenshottingStart>;
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);
});
});
});

View file

@ -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<keyof typeof logsToHelpMap>;
const boundSuccessfully = logs.includes(`DevTools listening on`);

View file

@ -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<ReturnType<typeof setupServer>>;
@ -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 } });

View file

@ -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<string, string>;
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) => {

View file

@ -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",

View file

@ -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<Promise<any>, any[]>;
waitForSelector: jest.Mock<Promise<any>, any[]>;
waitFor: jest.Mock<Promise<any>, any[]>;
screenshot: jest.Mock<Promise<any>, any[]>;
open: jest.Mock<Promise<any>, any[]>;
getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock<any, any>;
}
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<CreateMockBrowserDriverFactoryOpts> = {}
): Promise<HeadlessChromiumDriverFactory> => {
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;
};

View file

@ -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<Promise<HeadlessChromiumDriverFactory>>
).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,

View file

@ -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,

View file

@ -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<ReportingRequestHandlerContext>;
/**
* @internal
*/
export interface ScreenshotOptions extends Omit<BaseScreenshotOptions, 'timeouts' | 'urls'> {
urls: UrlOrUrlLocatorTuple[];
}

View file

@ -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,

View file

@ -206,10 +206,6 @@ export async function getReportingUsage(
.search(params)
.then(({ body: response }) => handleResponse(response))
.then((usage: Partial<RangeStatSets>): 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),

View file

@ -92,7 +92,6 @@ const rangeStatsSchema: MakeSchemaFrom<RangeStats> = {
export const reportingSchema: MakeSchemaFrom<ReportingUsageType> = {
...rangeStatsSchema,
available: { type: 'boolean' },
browser_type: { type: 'keyword' },
enabled: { type: 'boolean' },
last7Days: rangeStatsSchema,
};

View file

@ -129,7 +129,6 @@ export type RangeStats = JobTypes & {
export type ReportingUsageType = RangeStats & {
available: boolean;
browser_type: string;
enabled: boolean;
last7Days: RangeStats;
};

View file

@ -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" },
]

View file

@ -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.

View file

@ -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<string, unknown>;
/**
* @interal
*/
export const SCREENSHOTTING_CONTEXT_KEY = '__SCREENSHOTTING_CONTEXT_KEY__';

View file

@ -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';

View file

@ -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<LayoutSelectorDictionary>;
/**
* 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
};

View file

@ -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: ['<rootDir>/x-pack/plugins/screenshotting'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/x-pack/plugins/screenshotting',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/screenshotting/server/**/*.{ts}'],
};

View file

@ -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
}

Some files were not shown because too many files have changed in this diff Show more