diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index bdac6ca6d1329a..e5b3bf67ab1c78 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -11,7 +11,12 @@ export const JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY = export const API_BASE_URL = '/api/reporting'; -export const WHITELISTED_JOB_CONTENT_TYPES = ['application/json', 'application/pdf', 'text/csv']; +export const WHITELISTED_JOB_CONTENT_TYPES = [ + 'application/json', + 'application/pdf', + 'text/csv', + 'image/png', +]; export const UI_SETTINGS_CUSTOM_PDF_LOGO = 'xpackReporting:customPdfLogo'; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/common/constants.ts b/x-pack/plugins/reporting/export_types/common/constants.ts similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/common/constants.ts rename to x-pack/plugins/reporting/export_types/common/constants.ts diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js b/x-pack/plugins/reporting/export_types/common/execute_job/get_absolute_url.js similarity index 92% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js rename to x-pack/plugins/reporting/export_types/common/execute_job/get_absolute_url.js index b224d0835fa945..bc3fefe36f0497 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js +++ b/x-pack/plugins/reporting/export_types/common/execute_job/get_absolute_url.js @@ -5,7 +5,7 @@ */ import url from 'url'; -import { oncePerServer } from '../../../../server/lib/once_per_server'; +import { oncePerServer } from '../../../server/lib/once_per_server'; function getAbsoluteUrlFn(server) { const config = server.config(); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js b/x-pack/plugins/reporting/export_types/common/execute_job/get_absolute_url.test.js similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js rename to x-pack/plugins/reporting/export_types/common/execute_job/get_absolute_url.test.js diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/export_types/common/layouts/create_layout.ts similarity index 87% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/create_layout.ts rename to x-pack/plugins/reporting/export_types/common/layouts/create_layout.ts index 271db928635591..646ca46bdbf5ac 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/create_layout.ts +++ b/x-pack/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,8 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { KbnServer, Size } from '../../../../../types'; -import { LayoutTypes } from '../../../common/constants'; +import { KbnServer, Size } from '../../../types'; +import { LayoutTypes } from '../constants'; import { Layout } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/index.ts b/x-pack/plugins/reporting/export_types/common/layouts/index.ts similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/index.ts rename to x-pack/plugins/reporting/export_types/common/layouts/index.ts diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/layout.ts b/x-pack/plugins/reporting/export_types/common/layouts/layout.ts similarity index 94% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/layout.ts rename to x-pack/plugins/reporting/export_types/common/layouts/layout.ts index 2516dfe18e5e11..e6e29e36d36481 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/layout.ts +++ b/x-pack/plugins/reporting/export_types/common/layouts/layout.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Size, ViewZoomWidthHeight } from '../../../../../types'; +import { Size, ViewZoomWidthHeight } from '../../../types'; export interface PageSizeParams { pageMarginTop: number; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/export_types/common/layouts/preserve_layout.css similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/preserve_layout.css rename to x-pack/plugins/reporting/export_types/common/layouts/preserve_layout.css diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/export_types/common/layouts/preserve_layout.ts similarity index 95% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/preserve_layout.ts rename to x-pack/plugins/reporting/export_types/common/layouts/preserve_layout.ts index d5090ddb93833e..29e184a39f9f69 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/export_types/common/layouts/preserve_layout.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import path from 'path'; -import { Size } from '../../../../../types'; -import { LayoutTypes } from '../../../common/constants'; +import { Size } from '../../../types'; +import { LayoutTypes } from '../constants'; import { Layout, PageSizeParams } from './layout'; // We use a zoom of two to bump up the resolution of the screenshot a bit. diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/print.css b/x-pack/plugins/reporting/export_types/common/layouts/print.css similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/print.css rename to x-pack/plugins/reporting/export_types/common/layouts/print.css diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/export_types/common/layouts/print_layout.ts similarity index 95% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/print_layout.ts rename to x-pack/plugins/reporting/export_types/common/layouts/print_layout.ts index 73503adde5df93..8b3d0ac1fa85ea 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import path from 'path'; -import { EvaluateOptions, KbnServer, Size } from '../../../../../types'; -import { LayoutTypes } from '../../../common/constants'; +import { EvaluateOptions, KbnServer, Size } from '../../../types'; +import { LayoutTypes } from '../constants'; import { Layout } from './layout'; import { CaptureConfig } from './types'; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/types.d.ts b/x-pack/plugins/reporting/export_types/common/layouts/types.d.ts similarity index 87% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/types.d.ts rename to x-pack/plugins/reporting/export_types/common/layouts/types.d.ts index 8a2157078be330..aa8702994d1a55 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/layouts/types.d.ts +++ b/x-pack/plugins/reporting/export_types/common/layouts/types.d.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Size } from '../../../../../types'; +import { Size } from '../../../types'; export interface CaptureConfig { zoom: number; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/__tests__/get_absolute_time.js b/x-pack/plugins/reporting/export_types/common/lib/__tests__/get_absolute_time.js similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/__tests__/get_absolute_time.js rename to x-pack/plugins/reporting/export_types/common/lib/__tests__/get_absolute_time.js diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/get_absolute_time.js b/x-pack/plugins/reporting/export_types/common/lib/get_absolute_time.js similarity index 100% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/get_absolute_time.js rename to x-pack/plugins/reporting/export_types/common/lib/get_absolute_time.js diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/screenshots.js b/x-pack/plugins/reporting/export_types/common/lib/screenshots.js similarity index 99% rename from x-pack/plugins/reporting/export_types/printable_pdf/server/lib/screenshots.js rename to x-pack/plugins/reporting/export_types/common/lib/screenshots.js index 4d61371dfb0f6e..2b14c885d3c267 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/screenshots.js +++ b/x-pack/plugins/reporting/export_types/common/lib/screenshots.js @@ -9,7 +9,7 @@ import { first, tap, mergeMap } from 'rxjs/operators'; import fs from 'fs'; import getPort from 'get-port'; import { promisify } from 'bluebird'; -import { LevelLogger } from '../../../../server/lib/level_logger'; +import { LevelLogger } from '../../../server/lib/level_logger'; const fsp = { readFile: promisify(fs.readFile, fs) diff --git a/x-pack/plugins/reporting/export_types/png/metadata.js b/x-pack/plugins/reporting/export_types/png/metadata.js new file mode 100644 index 00000000000000..8be016568d9f17 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/metadata.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const metadata = { + id: 'png', + name: 'PNG' +}; diff --git a/x-pack/plugins/reporting/export_types/png/server/create_job/index.js b/x-pack/plugins/reporting/export_types/png/server/create_job/index.js new file mode 100644 index 00000000000000..4b6158613317c0 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/create_job/index.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cryptoFactory } from '../../../../server/lib/crypto'; +import { oncePerServer } from '../../../../server/lib/once_per_server'; + +function createJobFn(server) { + const crypto = cryptoFactory(server); + + return async function createJob({ + objectType, + title, + relativeUrl, + browserTimezone, + layout + }, headers, serializedSession, request) { + const serializedEncryptedHeaders = await crypto.encrypt(headers); + const encryptedSerializedSession = await crypto.encrypt(serializedSession); + + + return { + type: objectType, + title: title, + relativeUrl, + headers: serializedEncryptedHeaders, + browserTimezone, + session: encryptedSerializedSession, + layout, + basePath: request.getBasePath(), + forceNow: new Date().toISOString(), + }; + }; +} + +export const createJobFactory = oncePerServer(createJobFn); diff --git a/x-pack/plugins/reporting/export_types/png/server/execute_job/__snapshots__/index.test.js.snap b/x-pack/plugins/reporting/export_types/png/server/execute_job/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000000..c43293cb11afdb --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/execute_job/__snapshots__/index.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sessionCookie Fails if no relativeURL is passed in 1`] = `"Unable to generate report. Url is not defined."`; diff --git a/x-pack/plugins/reporting/export_types/png/server/execute_job/index.js b/x-pack/plugins/reporting/export_types/png/server/execute_job/index.js new file mode 100644 index 00000000000000..ad641bcff87b11 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/execute_job/index.js @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import url from 'url'; +import cookie from 'cookie'; +import * as Rx from 'rxjs'; +import { mergeMap, catchError, map, takeUntil } from 'rxjs/operators'; +import { oncePerServer } from '../../../../server/lib/once_per_server'; +import { generatePngObservableFactory } from '../lib/generate_png'; +import { cryptoFactory } from '../../../../server/lib/crypto'; +import { getAbsoluteUrlFactory } from '../../../common/execute_job/get_absolute_url'; + +function executeJobFn(server) { + const generatePngObservable = generatePngObservableFactory(server); + const crypto = cryptoFactory(server); + const getAbsoluteUrl = getAbsoluteUrlFactory(server); + const config = server.config(); + + const decryptJobHeaders = async (job) => { + const decryptedHeaders = await crypto.decrypt(job.headers); + return { job, decryptedHeaders }; + }; + + const getSavedObjectAbsoluteUrl = (job, relativeUrl) => { + + if (relativeUrl) { + const { pathname: path, hash, search } = url.parse(relativeUrl); + return getAbsoluteUrl({ basePath: job.basePath, path, hash, search }); + } + + throw new Error(`Unable to generate report. Url is not defined.`); + }; + + const getSerializedSession = async ({ job, decryptedHeaders }) => { + if (!server.plugins.security) { + job.serializedSession = null; + return { decryptedHeaders, job }; + } + + if (job.session) { + try { + job.serializedSession = await crypto.decrypt(job.session); + return { decryptedHeaders, job }; + } catch (err) { + throw new Error('Failed to decrypt report job data. Please re-generate this report.'); + } + } + + const cookies = decryptedHeaders.cookie ? cookie.parse(decryptedHeaders.cookie) : null; + if (cookies === null) { + job.serializedSession = null; + return { decryptedHeaders, job }; + } + + const cookieName = server.plugins.security.getSessionCookieOptions().name; + if (!cookieName) { + throw new Error('Unable to determine the session cookie name'); + } + + job.serializedSession = cookies[cookieName]; + + return { decryptedHeaders, job }; + }; + + const getSessionCookie = async ({ job, logo }) => { + if (!job.serializedSession) { + return { job, logo, sessionCookie: null }; + } + + const cookieOptions = await server.plugins.security.getSessionCookieOptions(); + const { httpOnly, name, path, secure } = cookieOptions; + + return { job, logo, sessionCookie: { + domain: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), + httpOnly, + name, + path, + sameSite: 'Strict', + secure, + value: job.serializedSession, + } }; + }; + + const addForceNowQuerystring = async ({ job, sessionCookie }) => { + + const jobUrl = getSavedObjectAbsoluteUrl(job, job.relativeUrl); + + if (!job.forceNow) { + return { job, sessionCookie, hashUrl: jobUrl }; + } + + const parsed = url.parse(jobUrl, true); + const hash = url.parse(parsed.hash.replace(/^#/, ''), true); + + const transformedHash = url.format({ + pathname: hash.pathname, + query: { + ...hash.query, + forceNow: job.forceNow + } + }); + + const hashUrl = url.format({ + ...parsed, + hash: transformedHash + }); + //}); + return { job, sessionCookie, hashUrl }; + }; + + return function executeJob(jobToExecute, cancellationToken) { + const process$ = Rx.of(jobToExecute).pipe( + mergeMap(decryptJobHeaders), + catchError(() => Rx.throwError('Failed to decrypt report job data. Please re-generate this report.')), + mergeMap(getSerializedSession), + mergeMap(getSessionCookie), + mergeMap(addForceNowQuerystring), + mergeMap(({ job, sessionCookie, hashUrl }) => { + return generatePngObservable(hashUrl, job.browserTimezone, sessionCookie, job.layout); + }), + map(buffer => ({ + content_type: 'image/png', + content: buffer.toString('base64') + })) + ); + + const stop$ = Rx.fromEventPattern(cancellationToken.on); + + return process$.pipe( + takeUntil(stop$) + ).toPromise(); + }; +} + +export const executeJobFactory = oncePerServer(executeJobFn); diff --git a/x-pack/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/plugins/reporting/export_types/png/server/execute_job/index.test.js new file mode 100644 index 00000000000000..7e45aeec8d87b5 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -0,0 +1,246 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { memoize } from 'lodash'; +import { cryptoFactory } from '../../../../server/lib/crypto'; +import { executeJobFactory } from './index'; +import { generatePngObservableFactory } from '../lib/generate_png'; + +jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); + +const cancellationToken = { + on: jest.fn() +}; + +let mockServer; +let config; +beforeEach(() => { + config = { + 'xpack.security.cookieName': 'sid', + 'xpack.reporting.encryptionKey': 'testencryptionkey', + 'xpack.reporting.kibanaServer.protocol': 'http', + 'xpack.reporting.kibanaServer.hostname': 'localhost', + 'xpack.reporting.kibanaServer.port': 5601, + 'server.basePath': '/sbp' + }; + + mockServer = { + expose: () => { }, + config: memoize(() => ({ get: jest.fn() })), + plugins: { + elasticsearch: { + getCluster: memoize(() => { + return { + callWithRequest: jest.fn() + }; + }) + }, + security: null, + }, + savedObjects: { + getScopedSavedObjectsClient: jest.fn(), + }, + uiSettingsServiceFactory: jest.fn().mockReturnValue({ get: jest.fn() }), + }; + + mockServer.config().get.mockImplementation((key) => { + return config[key]; + }); + + generatePngObservableFactory.mockReturnValue(jest.fn()); +}); + +afterEach(() => generatePngObservableFactory.mockReset()); + +const encrypt = async (headers) => { + const crypto = cryptoFactory(mockServer); + return await crypto.encrypt(headers); +}; + +describe(`sessionCookie`, () => { + test(`Fails if no relativeURL is passed in`, async () => { + const executeJob = executeJobFactory(mockServer); + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + await expect(executeJob({ headers: encryptedHeaders }, cancellationToken)) + .rejects + .toThrowErrorMatchingSnapshot(); + }); + + test(`if serializedSession doesn't exist it doesn't pass sessionCookie to generatePngObservable`, async () => { + mockServer.plugins.security = {}; + const headers = {}; + const encryptedHeaders = await encrypt(headers); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + await executeJob({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders, session: null }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something', undefined, null, undefined); + }); + + test(`if uses xpack.reporting.kibanaServer.hostname for domain of sessionCookie passed to generatePngObservable`, async () => { + const sessionCookieOptions = { + httpOnly: true, + name: 'foo', + path: '/bar', + secure: false, + }; + mockServer.plugins.security = { + getSessionCookieOptions() { + return sessionCookieOptions; + }, + }; + const headers = {}; + const encryptedHeaders = await encrypt(headers); + + const session = 'thisoldesession'; + const encryptedSession = await encrypt(session); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + await executeJob({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders, session: encryptedSession }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something', undefined, { + domain: config['xpack.reporting.kibanaServer.hostname'], + httpOnly: sessionCookieOptions.httpOnly, + name: sessionCookieOptions.name, + path: sessionCookieOptions.path, + sameSite: 'Strict', + secure: sessionCookieOptions.secure, + value: session + }, undefined); + }); + + test(`if uses server.host and reporting config isn't set for domain of sessionCookie passed to generatePngObservable`, async () => { + config['xpack.reporting.kibanaServer.hostname'] = undefined; + config['server.host'] = 'something.com'; + const sessionCookieOptions = { + httpOnly: true, + name: 'foo', + path: '/bar', + secure: false, + }; + mockServer.plugins.security = { + getSessionCookieOptions() { + return sessionCookieOptions; + }, + }; + const headers = {}; + const encryptedHeaders = await encrypt(headers); + + const session = 'thisoldesession'; + const encryptedSession = await encrypt(session); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + await executeJob({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders, session: encryptedSession }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://something.com:5601/sbp/app/kibana#/something', undefined, { + domain: config['server.host'], + httpOnly: sessionCookieOptions.httpOnly, + name: sessionCookieOptions.name, + path: sessionCookieOptions.path, + sameSite: 'Strict', + secure: sessionCookieOptions.secure, + value: session + }, undefined); + }); +}); + +test(`passes browserTimezone to generatePng`, async () => { + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + const browserTimezone = 'UTC'; + await executeJob({ relativeUrl: '/app/kibana#/something', browserTimezone, headers: encryptedHeaders }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something', browserTimezone, null, undefined); +}); + +test(`adds forceNow to hash's query, if it exists`, async () => { + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + const forceNow = '2000-01-01T00:00:00.000Z'; + + await executeJob({ relativeUrl: '/app/kibana#/something', forceNow, headers: encryptedHeaders }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something?forceNow=2000-01-01T00%3A00%3A00.000Z', undefined, null, undefined); +}); + +test(`appends forceNow to hash's query, if it exists`, async () => { + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + const forceNow = '2000-01-01T00:00:00.000Z'; + + await executeJob({ + relativeUrl: '/app/kibana#/something?_g=something', + forceNow, + headers: encryptedHeaders + }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something?_g=something&forceNow=2000-01-01T00%3A00%3A00.000Z', undefined, null, undefined); +}); + +test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + + await executeJob({ relativeUrl: '/app/kibana#/something', headers: encryptedHeaders }, cancellationToken); + + expect(generatePngObservable).toBeCalledWith('http://localhost:5601/sbp/app/kibana#/something', undefined, null, undefined); +}); + +test(`returns content_type of image/png`, async () => { + const executeJob = executeJobFactory(mockServer); + const encryptedHeaders = await encrypt({}); + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const { content_type: contentType } = await executeJob({ relativeUrl: '/app/kibana#/something', + timeRange: {}, headers: encryptedHeaders }, cancellationToken); + expect(contentType).toBe('image/png'); +}); + +test(`returns content of generatePng getBuffer base64 encoded`, async () => { + const testContent = 'test content'; + + const generatePngObservable = generatePngObservableFactory(); + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(testContent))); + + const executeJob = executeJobFactory(mockServer); + const encryptedHeaders = await encrypt({}); + const { content } = await executeJob({ relativeUrl: '/app/kibana#/something', + timeRange: {}, headers: encryptedHeaders }, cancellationToken); + + expect(content).toEqual(Buffer.from(testContent).toString('base64')); +}); diff --git a/x-pack/plugins/reporting/export_types/png/server/index.js b/x-pack/plugins/reporting/export_types/png/server/index.js new file mode 100644 index 00000000000000..cb60775dbb02d2 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/index.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createJobFactory } from './create_job'; +import { executeJobFactory } from './execute_job'; +import { metadata } from '../metadata'; + +export function register(registry) { + registry.register({ + ...metadata, + jobType: 'PNG', + jobContentEncoding: 'base64', + jobContentExtension: 'PNG', + createJobFactory, + executeJobFactory, + validLicenses: ['trial', 'standard', 'gold', 'platinum'], + }); +} diff --git a/x-pack/plugins/reporting/export_types/png/server/lib/generate_png.js b/x-pack/plugins/reporting/export_types/png/server/lib/generate_png.js new file mode 100644 index 00000000000000..d94032a9163935 --- /dev/null +++ b/x-pack/plugins/reporting/export_types/png/server/lib/generate_png.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { toArray, mergeMap } from 'rxjs/operators'; +import { oncePerServer } from '../../../../server/lib/once_per_server'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { PreserveLayout } from '../../../common/layouts/preserve_layout'; + +function generatePngObservableFn(server) { + const screenshotsObservable = screenshotsObservableFactory(server); + const captureConcurrency = 1; + + const urlScreenshotsObservable = (url, sessionCookie, layout, browserTimezone) => { + return Rx.of(url).pipe( + mergeMap(url => screenshotsObservable(url, sessionCookie, layout, browserTimezone), + (outer, inner) => inner, + captureConcurrency + ) + ); + }; + + const createPngWithScreenshots = async ({ urlScreenshots }) => { + + if (urlScreenshots.length !== 1) { + throw new Error(`Expected there to be 1 URL screenshot, but there are ${urlScreenshots.length}`); + } + if (urlScreenshots[0].screenshots.length !== 1) { + throw new Error(`Expected there to be 1 screenshot, but there are ${urlScreenshots[0].screenshots.length}`); + } + + return urlScreenshots[0].screenshots[0].base64EncodedData; + + }; + + return function generatePngObservable(url, browserTimezone, sessionCookie, layoutParams) { + + if (!layoutParams || !layoutParams.dimensions) { + throw new Error(`LayoutParams.Dimensions is undefined.`); + } + + const layout = new PreserveLayout(layoutParams.dimensions); + + const screenshots$ = urlScreenshotsObservable(url, sessionCookie, layout, browserTimezone); + + return screenshots$.pipe( + toArray(), + mergeMap(urlScreenshots => createPngWithScreenshots({ urlScreenshots })) + ); + }; +} + +export const generatePngObservableFactory = oncePerServer(generatePngObservableFn); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/__snapshots__/index.test.js.snap b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000000..00b5344254669c --- /dev/null +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/__snapshots__/index.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`headers it fails if it can't decrypt the headers 1`] = `undefined`; + +exports[`sessionCookie it fails if if cookie name can't be determined 1`] = `"server.plugins.security.getSessionCookieOptions is not a function"`; + +exports[`sessionCookie it fails if it can't decrypt the session 1`] = `"Failed to decrypt report job data. Please re-generate this report."`; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js index df55bb75d2621b..9912ed1b70126d 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js @@ -5,7 +5,7 @@ */ import url from 'url'; -import { getAbsoluteUrlFactory } from './get_absolute_url'; +import { getAbsoluteUrlFactory } from '../../../common/execute_job/get_absolute_url'; export function compatibilityShimFactory(server) { const getAbsoluteUrl = getAbsoluteUrlFactory(server); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js index c340546b868f89..16553c1a91e410 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js @@ -5,13 +5,13 @@ */ import url from 'url'; +import { cryptoFactory } from '../../../../server/lib/crypto'; import * as Rx from 'rxjs'; import { mergeMap, catchError, map, takeUntil } from 'rxjs/operators'; import { omit } from 'lodash'; import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../../common/constants'; import { oncePerServer } from '../../../../server/lib/once_per_server'; import { generatePdfObservableFactory } from '../lib/generate_pdf'; -import { cryptoFactory } from '../../../../server/lib/crypto'; import { compatibilityShimFactory } from './compatibility_shim'; const KBN_SCREENSHOT_HEADER_BLACKLIST = [ diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.js index 225f78a083af41..677acadaac2a12 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.js @@ -10,8 +10,8 @@ import moment from 'moment'; import { pdf } from './pdf'; import { groupBy } from 'lodash'; import { oncePerServer } from '../../../../server/lib/once_per_server'; -import { screenshotsObservableFactory } from './screenshots'; -import { createLayout } from './layouts'; +import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; +import { createLayout } from '../../../common/layouts'; const getTimeRange = (urlScreenshots) => { const grouped = groupBy(urlScreenshots.map(u => u.timeRange)); diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx index 7aa49e0e6680f8..544eea05cd70bd 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -126,6 +126,8 @@ export class ReportingPanelContent extends Component { return 'PDF'; case 'csv': return 'CSV'; + case 'png': + return 'PNG'; default: return this.props.reportType; } diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx index c18f1bbbc5160a..8d94d9938a9ec3 100644 --- a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx @@ -45,17 +45,25 @@ export class ScreenCapturePanelContent extends Component { } private renderOptions = () => { - return ( - - - - - ); + if (this.props.reportType === 'png') { + return ( + + + + ); + } else { + return ( + + + + + ); + } }; private handlePrintLayoutChange = (evt: any) => { @@ -69,13 +77,23 @@ export class ScreenCapturePanelContent extends Component { const el = document.querySelector('[data-shared-items-container]'); const bounds = el ? el.getBoundingClientRect() : { height: 768, width: 1024 }; - return { - id: 'preserve_layout', - dimensions: { - height: bounds.height, - width: bounds.width, - }, - }; + + if (this.props.reportType === 'png') { + return { + dimensions: { + height: bounds.height, + width: bounds.width, + }, + }; + } else { + return { + id: 'preserve_layout', + dimensions: { + height: bounds.height, + width: bounds.width, + }, + }; + } }; private getJobParams = () => { diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx index de4b5500316da4..703fb3a4b58496 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_reporting.tsx @@ -51,6 +51,24 @@ function reportingProvider(Private: any, dashboardConfig: any) { }; }; + const getPngJobParams = () => { + // Replace hashes with original RISON values. + const unhashedUrl = unhashUrl(window.location.href, getUnhashableStates()); + const relativeUrl = unhashedUrl.replace(window.location.origin + chrome.getBasePath(), ''); + + const browserTimezone = + chrome.getUiSettingsClient().get('dateFormat:tz') === 'Browser' + ? moment.tz.guess() + : chrome.getUiSettingsClient().get('dateFormat:tz'); + + return { + ...sharingData, + objectType, + browserTimezone, + relativeUrl, + }; + }; + const shareActions = []; if (xpackInfo.get('features.reporting.printablePdf.showLinks', false)) { const panelTitle = 'PDF Reports'; @@ -82,6 +100,32 @@ function reportingProvider(Private: any, dashboardConfig: any) { } // TODO register PNG menu item once PNG is supported on server side + if (xpackInfo.get('features.reporting.png.showLinks', false)) { + const panelTitle = 'PNG Reports'; + + shareActions.push({ + shareMenuItem: { + name: panelTitle, + icon: 'document', + toolTipContent: xpackInfo.get('features.reporting.png.message'), + disabled: !xpackInfo.get('features.reporting.png.enableLinks', false) ? true : false, + ['data-test-subj']: 'pngReportMenuItem', + }, + panel: { + title: panelTitle, + content: ( + + ), + }, + }); + } return shareActions; }; diff --git a/x-pack/test/functional/page_objects/reporting_page.js b/x-pack/test/functional/page_objects/reporting_page.js index 0bb9259190d834..d5e6ef80c28cba 100644 --- a/x-pack/test/functional/page_objects/reporting_page.js +++ b/x-pack/test/functional/page_objects/reporting_page.js @@ -116,6 +116,11 @@ export function ReportingPageProvider({ getService, getPageObjects }) { await PageObjects.share.openShareMenuItem('PDF Reports'); } + async openPngReportingPanel() { + log.debug('openPngReportingPanel'); + await PageObjects.share.openShareMenuItem('PNG Reports'); + } + async clickDownloadReportButton(timeout) { await testSubjects.click('downloadCompletedReportButton', timeout); } diff --git a/x-pack/test/reporting/functional/lib/common.js b/x-pack/test/reporting/functional/lib/common.js new file mode 100644 index 00000000000000..b21c41d9c4109b --- /dev/null +++ b/x-pack/test/reporting/functional/lib/common.js @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fs from 'fs'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; + +export async function comparePngs(actualPath, expectedPath, diffPath, log) { + log.debug(`comparePngs: ${actualPath} vs ${expectedPath}`); + return new Promise(resolve => { + const actual = fs.createReadStream(actualPath).pipe(new PNG()).on('parsed', doneReading); + const expected = fs.createReadStream(expectedPath).pipe(new PNG()).on('parsed', doneReading); + let filesRead = 0; + + // Note that this threshold value only affects color comparison from pixel to pixel. It won't have + // any affect when comparing neighboring pixels - so slight shifts, font variations, or "blurry-ness" + // will still show up as diffs, but upping this will not help that. Instead we keep the threshold low, and expect + // some the diffCount to be lower than our own threshold value. + const THRESHOLD = .1; + + function doneReading() { + if (++filesRead < 2) return; + const diffPng = new PNG({ width: actual.width, height: actual.height }); + log.debug(`calculating diff pixels...`); + const diffPixels = pixelmatch( + actual.data, + expected.data, + diffPng.data, + actual.width, + actual.height, + { + threshold: THRESHOLD, + // Adding this doesn't seem to make a difference at all, but ideally we want to avoid picking up anti aliasing + // differences from fonts on different OSs. + includeAA: true + } + ); + log.debug(`diff pixels: ${diffPixels}`); + diffPng.pack().pipe(fs.createWriteStream(diffPath)); + resolve(diffPixels); + } + }); +} \ No newline at end of file diff --git a/x-pack/test/reporting/functional/lib/compare_pdfs.js b/x-pack/test/reporting/functional/lib/compare_pdfs.js index dce750a4e0875e..9609e483442e63 100644 --- a/x-pack/test/reporting/functional/lib/compare_pdfs.js +++ b/x-pack/test/reporting/functional/lib/compare_pdfs.js @@ -7,52 +7,13 @@ import path from 'path'; import fs from 'fs'; import { promisify } from 'bluebird'; -import pixelmatch from 'pixelmatch'; import mkdirp from 'mkdirp'; - -import { PNG } from 'pngjs'; import { PDFImage } from 'pdf-image'; import PDFJS from 'pdfjs-dist'; +import { comparePngs } from './common'; const mkdirAsync = promisify(mkdirp); -function comparePngs(actualPath, expectedPath, diffPath, log) { - log.debug(`comparePngs: ${actualPath} vs ${expectedPath}`); - return new Promise(resolve => { - const actual = fs.createReadStream(actualPath).pipe(new PNG()).on('parsed', doneReading); - const expected = fs.createReadStream(expectedPath).pipe(new PNG()).on('parsed', doneReading); - let filesRead = 0; - - // Note that this threshold value only affects color comparison from pixel to pixel. It won't have - // any affect when comparing neighboring pixels - so slight shifts, font variations, or "blurry-ness" - // will still show up as diffs, but upping this will not help that. Instead we keep the threshold low, and expect - // some the diffCount to be lower than our own threshold value. - const THRESHOLD = .1; - - function doneReading() { - if (++filesRead < 2) return; - const diffPng = new PNG({ width: actual.width, height: actual.height }); - log.debug(`calculating diff pixels...`); - const diffPixels = pixelmatch( - actual.data, - expected.data, - diffPng.data, - actual.width, - actual.height, - { - threshold: THRESHOLD, - // Adding this doesn't seem to make a difference at all, but ideally we want to avoid picking up anti aliasing - // differences from fonts on different OSs. - includeAA: true - } - ); - log.debug(`diff pixels: ${diffPixels}`); - diffPng.pack().pipe(fs.createWriteStream(diffPath)); - resolve(diffPixels); - } - }); -} - export async function checkIfPdfsMatch(actualPdfPath, baselinePdfPath, screenshotsDirectory, log) { log.debug(`checkIfPdfsMatch: ${actualPdfPath} vs ${baselinePdfPath}`); // Copy the pdfs into the screenshot session directory, as that's where the generated pngs will automatically be diff --git a/x-pack/test/reporting/functional/lib/compare_pngs.js b/x-pack/test/reporting/functional/lib/compare_pngs.js new file mode 100644 index 00000000000000..105be520a1607b --- /dev/null +++ b/x-pack/test/reporting/functional/lib/compare_pngs.js @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import fs from 'fs'; +import { promisify } from 'bluebird'; +import mkdirp from 'mkdirp'; +import { comparePngs } from './common'; + +const mkdirAsync = promisify(mkdirp); + +export async function checkIfPngsMatch(actualpngPath, baselinepngPath, screenshotsDirectory, log) { + log.debug(`checkIfpngsMatch: ${actualpngPath} vs ${baselinepngPath}`); + // Copy the pngs into the screenshot session directory, as that's where the generated pngs will automatically be + // stored. + const sessionDirectoryPath = path.resolve(screenshotsDirectory, 'session'); + const failureDirectoryPath = path.resolve(screenshotsDirectory, 'failure'); + + await mkdirAsync(sessionDirectoryPath); + await mkdirAsync(failureDirectoryPath); + + const actualpngFileName = path.basename(actualpngPath, '.png'); + const baselinepngFileName = path.basename(baselinepngPath, '.png'); + + const baselineCopyPath = path.resolve(sessionDirectoryPath, `${baselinepngFileName}_baseline.png`); + const actualCopyPath = path.resolve(sessionDirectoryPath, `${actualpngFileName}_actual.png`); + + // Don't cause a test failure if the baseline snapshot doesn't exist - we don't have all OS's covered and we + // don't want to start causing failures for other devs working on OS's which are lacking snapshots. We have + // mac and linux covered which is better than nothing for now. + try { + log.debug(`writeFileSync: ${baselineCopyPath}`); + fs.writeFileSync(baselineCopyPath, fs.readFileSync(baselinepngPath)); + } catch (error) { + log.error(`No baseline png found at ${baselinepngPath}`); + return 0; + } + log.debug(`writeFileSync: ${actualCopyPath}`); + fs.writeFileSync(actualCopyPath, fs.readFileSync(actualpngPath)); + + let diffTotal = 0; + + const diffPngPath = path.resolve(failureDirectoryPath, `${baselinepngFileName}-${1}.png`); + diffTotal += await comparePngs(actualCopyPath, baselineCopyPath, diffPngPath, log); + + + return diffTotal; +} diff --git a/x-pack/test/reporting/functional/lib/index.js b/x-pack/test/reporting/functional/lib/index.js index ab471be5b9d26f..3590995002c085 100644 --- a/x-pack/test/reporting/functional/lib/index.js +++ b/x-pack/test/reporting/functional/lib/index.js @@ -5,3 +5,4 @@ */ export { checkIfPdfsMatch } from './compare_pdfs'; +export { checkIfPngsMatch } from './compare_pngs'; diff --git a/x-pack/test/reporting/functional/reporting.js b/x-pack/test/reporting/functional/reporting.js index 07ae1d18040477..7dc2a803e0fef6 100644 --- a/x-pack/test/reporting/functional/reporting.js +++ b/x-pack/test/reporting/functional/reporting.js @@ -9,7 +9,7 @@ import path from 'path'; import mkdirp from 'mkdirp'; import fs from 'fs'; import { promisify } from 'bluebird'; -import { checkIfPdfsMatch } from './lib'; +import { checkIfPdfsMatch, checkIfPngsMatch } from './lib'; const writeFileAsync = promisify(fs.writeFile); const mkdirAsync = promisify(mkdirp); @@ -51,17 +51,17 @@ export default function ({ getService, getPageObjects }) { expect(success).to.be(true); }; - const writeSessionReport = async (name, rawPdf) => { + const writeSessionReport = async (name, rawPdf, reportExt = 'pdf') => { const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); await mkdirAsync(sessionDirectory); - const sessionReportPath = path.resolve(sessionDirectory, `${name}.pdf`); + const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); await writeFileAsync(sessionReportPath, rawPdf); return sessionReportPath; }; - const getBaselineReportPath = (fileName) => { + const getBaselineReportPath = (fileName, reportExt = 'pdf') => { const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); - return path.resolve(baselineFolder, `${fileName}.pdf`); + return path.resolve(baselineFolder, `${fileName}.${reportExt}`); }; describe('Dashboard', () => { @@ -76,7 +76,7 @@ export default function ({ getService, getPageObjects }) { }); it('becomes available when saved', async () => { - await PageObjects.dashboard.saveDashboard('mydash'); + await PageObjects.dashboard.saveDashboard('mypdfdash'); await PageObjects.reporting.openPdfReportingPanel(); await expectEnabledGenerateReportButton(); }); @@ -186,6 +186,69 @@ export default function ({ getService, getPageObjects }) { }); }); + + describe('Print PNG button', () => { + it('is not available if new', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.reporting.openPngReportingPanel(); + await expectDisabledGenerateReportButton(); + }); + + it('becomes available when saved', async () => { + await PageObjects.dashboard.saveDashboard('mypngdash'); + await PageObjects.reporting.openPngReportingPanel(); + await expectEnabledGenerateReportButton(); + }); + }); + + describe('Preserve Layout', () => { + it('matches baseline report', async function () { + + // Generating and then comparing reports can take longer than the default 60s timeout because the comparePngs + // function is taking about 15 seconds per comparison in jenkins. Also Chromium takes a lot longer to generate a + // report than phantom. + this.timeout(360000); + + await PageObjects.dashboard.switchToEditMode(); + await PageObjects.reporting.setTimepickerInDataRange(); + const visualizations = PageObjects.dashboard.getTestVisualizationNames(); + + // There is a current issue causing reports with tilemaps to timeout: + // https://github.com/elastic/kibana/issues/14136. Once that is resolved, add the tilemap visualization + // back in! + const tileMapIndex = visualizations.indexOf('Visualization TileMap'); + visualizations.splice(tileMapIndex, 1); + await PageObjects.dashboard.addVisualizations(visualizations); + + await PageObjects.dashboard.saveDashboard('PNG report test'); + + await PageObjects.reporting.openPngReportingPanel(); + await PageObjects.reporting.forceSharedItemsContainerSize({ width: 1405 }); + await PageObjects.reporting.clickGenerateReportButton(); + await PageObjects.reporting.removeForceSharedItemsContainerSize(); + + await PageObjects.reporting.clickDownloadReportButton(60000); + const url = await PageObjects.reporting.getUrlOfTab(1); + await PageObjects.reporting.closeTab(1); + const reportData = await PageObjects.reporting.getRawPdfReportData(url); + + const reportFileName = 'dashboard_preserve_layout'; + const sessionReportPath = await writeSessionReport(reportFileName, reportData, 'png'); + const diffCount = await checkIfPngsMatch( + sessionReportPath, + getBaselineReportPath(reportFileName, 'png'), + config.get('screenshots.directory'), + log + ); + // After expected OS differences, the diff count came to be around 350k. Due to + // https://github.com/elastic/kibana/issues/21485 this jumped up to something like 368 when + // comparing the same baseline for chromium and phantom. + expect(diffCount).to.be.lessThan(400000); + + }); + }); + }); describe('Discover', () => { diff --git a/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png b/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png new file mode 100644 index 00000000000000..a0dfea9ef4fa79 Binary files /dev/null and b/x-pack/test/reporting/functional/reports/baseline/dashboard_preserve_layout.png differ