mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Reporting/fix visual warning test (#164383)
## Summary Closes https://github.com/elastic/kibana/issues/135309 This PR eliminates a skipped functional test by replacing the test coverage with unit tests. * `x-pack/plugins/screenshotting/server/screenshots/screenshots.test.ts`: ensures that waiting too long for the URL to open will return the expected error message * `x-pack/plugins/screenshotting/server/browsers/chromium/driver.test.ts`: ensures that when the screenshot capture method is passed an error message, that error message is injected into the screenshot --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
7c896218dd
commit
deb64c19cf
11 changed files with 549 additions and 343 deletions
|
@ -353,7 +353,6 @@ enabled:
|
|||
- x-pack/test/reporting_functional/reporting_and_deprecated_security.config.ts
|
||||
- x-pack/test/reporting_functional/reporting_and_security.config.ts
|
||||
- x-pack/test/reporting_functional/reporting_without_security.config.ts
|
||||
- x-pack/test/reporting_functional/reporting_and_timeout.config.ts
|
||||
- x-pack/test/rule_registry/security_and_spaces/config_basic.ts
|
||||
- x-pack/test/rule_registry/security_and_spaces/config_trial.ts
|
||||
- x-pack/test/rule_registry/spaces_only/config_basic.ts
|
||||
|
|
47
x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts
Normal file
47
x-pack/plugins/screenshotting/server/__mocks__/puppeteer.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const stubDevTools = {
|
||||
send: jest.fn(),
|
||||
};
|
||||
const stubTarget = {
|
||||
createCDPSession: jest.fn(() => {
|
||||
return stubDevTools;
|
||||
}),
|
||||
};
|
||||
const stubPage = {
|
||||
target: jest.fn(() => {
|
||||
return stubTarget;
|
||||
}),
|
||||
emulateTimezone: jest.fn(),
|
||||
setDefaultTimeout: jest.fn(),
|
||||
isClosed: jest.fn(),
|
||||
setViewport: jest.fn(),
|
||||
evaluate: jest.fn(),
|
||||
screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`),
|
||||
evaluateOnNewDocument: jest.fn(),
|
||||
setRequestInterception: jest.fn(),
|
||||
_client: jest.fn(() => ({ on: jest.fn() })),
|
||||
on: jest.fn(),
|
||||
goto: jest.fn(),
|
||||
waitForSelector: jest.fn().mockResolvedValue(true),
|
||||
waitForFunction: jest.fn(),
|
||||
};
|
||||
const stubBrowser = {
|
||||
newPage: jest.fn(() => {
|
||||
return stubPage;
|
||||
}),
|
||||
};
|
||||
|
||||
const puppeteer = {
|
||||
launch: jest.fn(() => {
|
||||
return stubBrowser;
|
||||
}),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default puppeteer;
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import { Size } from '../../../common/layout';
|
||||
import { ConfigType } from '../../config';
|
||||
import { PreserveLayout } from '../../layouts/preserve_layout';
|
||||
import { HeadlessChromiumDriver } from './driver';
|
||||
|
||||
describe('chromium driver', () => {
|
||||
let mockConfig: ConfigType;
|
||||
let mockLogger: Logger;
|
||||
let mockScreenshotModeSetup: ScreenshotModePluginSetup;
|
||||
let mockPage: puppeteer.Page;
|
||||
|
||||
const mockBasePath = '/kibanaTest1';
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() } as unknown as Logger;
|
||||
mockLogger.get = () => mockLogger;
|
||||
|
||||
mockConfig = {
|
||||
networkPolicy: {
|
||||
enabled: false,
|
||||
rules: [],
|
||||
},
|
||||
browser: {
|
||||
autoDownload: false,
|
||||
chromium: { proxy: { enabled: false } },
|
||||
},
|
||||
capture: {
|
||||
timeouts: {
|
||||
openUrl: 60000,
|
||||
waitForElements: 60000,
|
||||
renderComplete: 60000,
|
||||
},
|
||||
zoom: 2,
|
||||
},
|
||||
poolSize: 1,
|
||||
};
|
||||
|
||||
mockPage = {
|
||||
screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`),
|
||||
evaluate: jest.fn(),
|
||||
} as unknown as puppeteer.Page;
|
||||
|
||||
mockScreenshotModeSetup = {
|
||||
setScreenshotContext: jest.fn(),
|
||||
setScreenshotModeEnabled: jest.fn(),
|
||||
isScreenshotMode: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('return screenshot with preserve layout option', async () => {
|
||||
const driver = new HeadlessChromiumDriver(
|
||||
mockScreenshotModeSetup,
|
||||
mockConfig,
|
||||
mockBasePath,
|
||||
mockPage
|
||||
);
|
||||
|
||||
const result = await driver.screenshot({
|
||||
elementPosition: {
|
||||
boundingClientRect: { top: 200, left: 10, height: 10, width: 100 },
|
||||
scroll: { x: 100, y: 300 },
|
||||
},
|
||||
layout: new PreserveLayout({ width: 16, height: 16 }),
|
||||
});
|
||||
|
||||
expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64'));
|
||||
});
|
||||
|
||||
it('add error to screenshot contents', async () => {
|
||||
const driver = new HeadlessChromiumDriver(
|
||||
mockScreenshotModeSetup,
|
||||
mockConfig,
|
||||
mockBasePath,
|
||||
mockPage
|
||||
);
|
||||
|
||||
// @ts-expect-error spy on non-public class method
|
||||
const testSpy = jest.spyOn(driver, 'injectScreenshottingErrorHeader');
|
||||
|
||||
const result = await driver.screenshot({
|
||||
elementPosition: {
|
||||
boundingClientRect: { top: 200, left: 10, height: 10, width: 100 },
|
||||
scroll: { x: 100, y: 300 },
|
||||
},
|
||||
layout: new PreserveLayout({} as Size),
|
||||
error: new Error(`Here's the fake error!`),
|
||||
});
|
||||
|
||||
expect(testSpy.mock.lastCall).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[Error: Here's the fake error!],
|
||||
"[data-shared-items-container]",
|
||||
]
|
||||
`);
|
||||
expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64'));
|
||||
});
|
||||
});
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
||||
import * as puppeteer from 'puppeteer';
|
||||
import * as Rx from 'rxjs';
|
||||
|
@ -24,22 +25,18 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
},
|
||||
},
|
||||
} as ConfigType;
|
||||
let logger: jest.Mocked<Logger>;
|
||||
let screenshotMode: jest.Mocked<ScreenshotModePluginSetup>;
|
||||
let logger: Logger;
|
||||
let screenshotMode: ScreenshotModePluginSetup;
|
||||
let factory: HeadlessChromiumDriverFactory;
|
||||
let mockBrowser: jest.Mocked<puppeteer.Browser>;
|
||||
let mockBrowser: puppeteer.Browser;
|
||||
|
||||
beforeEach(async () => {
|
||||
logger = {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
get: jest.fn(() => logger),
|
||||
} as unknown as typeof logger;
|
||||
screenshotMode = {} as unknown as typeof screenshotMode;
|
||||
logger = loggerMock.create();
|
||||
|
||||
screenshotMode = {} as unknown as ScreenshotModePluginSetup;
|
||||
|
||||
let pageClosed = false;
|
||||
|
||||
mockBrowser = {
|
||||
newPage: jest.fn().mockResolvedValue({
|
||||
target: jest.fn(() => ({
|
||||
|
@ -57,9 +54,8 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
pageClosed = true;
|
||||
}),
|
||||
process: jest.fn(),
|
||||
} as unknown as jest.Mocked<puppeteer.Browser>;
|
||||
|
||||
(puppeteer as jest.Mocked<typeof puppeteer>).launch.mockResolvedValue(mockBrowser);
|
||||
} as unknown as puppeteer.Browser;
|
||||
jest.spyOn(puppeteer, 'launch').mockResolvedValue(mockBrowser);
|
||||
|
||||
factory = new HeadlessChromiumDriverFactory(screenshotMode, config, logger, path, '');
|
||||
jest.spyOn(factory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY);
|
||||
|
@ -84,9 +80,8 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
});
|
||||
|
||||
it('rejects if Puppeteer launch fails', async () => {
|
||||
(puppeteer as jest.Mocked<typeof puppeteer>).launch.mockRejectedValue(
|
||||
`Puppeteer Launch mock fail.`
|
||||
);
|
||||
jest.spyOn(puppeteer, 'launch').mockRejectedValue(`Puppeteer Launch mock fail.`);
|
||||
|
||||
expect(() =>
|
||||
factory
|
||||
.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT })
|
||||
|
@ -99,9 +94,8 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
|
||||
describe('close behaviour', () => {
|
||||
it('does not allow close to be called on the browse more than once', async () => {
|
||||
await factory
|
||||
.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT })
|
||||
.pipe(
|
||||
await Rx.firstValueFrom(
|
||||
factory.createPage({ openUrlTimeout: 0, defaultViewport: DEFAULT_VIEWPORT }).pipe(
|
||||
take(1),
|
||||
mergeMap(async ({ close }) => {
|
||||
expect(mockBrowser.close).not.toHaveBeenCalled();
|
||||
|
@ -110,7 +104,7 @@ describe('HeadlessChromiumDriverFactory', () => {
|
|||
expect(mockBrowser.close).toHaveBeenCalledTimes(1);
|
||||
})
|
||||
)
|
||||
.toPromise();
|
||||
);
|
||||
// Check again, after the observable completes
|
||||
expect(mockBrowser.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
|
@ -5,48 +5,18 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CloudSetup } from '@kbn/cloud-plugin/server';
|
||||
import type { HttpServiceSetup, KibanaRequest, Logger, PackageInfo } from '@kbn/core/server';
|
||||
import type { KibanaRequest } from '@kbn/core/server';
|
||||
import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common';
|
||||
import type { Optional } from '@kbn/utility-types';
|
||||
import { Semaphore } from '@kbn/std';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { defaultsDeep, sum } from 'lodash';
|
||||
import { from, Observable, of, throwError } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
first,
|
||||
map,
|
||||
mergeMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
toArray,
|
||||
} from 'rxjs/operators';
|
||||
import {
|
||||
errors,
|
||||
LayoutParams,
|
||||
SCREENSHOTTING_APP_ID,
|
||||
SCREENSHOTTING_EXPRESSION,
|
||||
SCREENSHOTTING_EXPRESSION_INPUT,
|
||||
} from '../../common';
|
||||
import { HeadlessChromiumDriverFactory, PerformanceMetrics } from '../browsers';
|
||||
import { systemHasInsufficientMemory } from '../cloud';
|
||||
import type { ConfigType } from '../config';
|
||||
import { durationToNumber } from '../config';
|
||||
import { LayoutParams } from '../../common';
|
||||
import { PerformanceMetrics } from '../browsers';
|
||||
import {
|
||||
PdfScreenshotOptions,
|
||||
PdfScreenshotResult,
|
||||
PngScreenshotOptions,
|
||||
PngScreenshotResult,
|
||||
toPdf,
|
||||
toPng,
|
||||
} from '../formats';
|
||||
import { createLayout, Layout } from '../layouts';
|
||||
import { EventLogger, Transactions } from './event_logger';
|
||||
import type { ScreenshotObservableOptions, ScreenshotObservableResult } from './observable';
|
||||
import { ScreenshotObservableHandler, UrlOrUrlWithContext } from './observable';
|
||||
|
||||
export type { ScreenshotObservableResult, UrlOrUrlWithContext } from './observable';
|
||||
|
||||
|
@ -55,17 +25,14 @@ export interface CaptureOptions extends Optional<ScreenshotObservableOptions, 'u
|
|||
* Expression to render. Mutually exclusive with `urls`.
|
||||
*/
|
||||
expression?: string | ExpressionAstExpression;
|
||||
|
||||
/**
|
||||
* Expression input.
|
||||
*/
|
||||
input?: unknown;
|
||||
|
||||
/**
|
||||
* Layout parameters.
|
||||
*/
|
||||
layout?: LayoutParams;
|
||||
|
||||
/**
|
||||
* Source Kibana request object from where the headers will be extracted.
|
||||
*/
|
||||
|
@ -79,7 +46,6 @@ export interface CaptureResult {
|
|||
* Collected performance metrics during the screenshotting session.
|
||||
*/
|
||||
metrics?: CaptureMetrics;
|
||||
|
||||
/**
|
||||
* Screenshotting results.
|
||||
*/
|
||||
|
@ -89,163 +55,4 @@ export interface CaptureResult {
|
|||
export type ScreenshotOptions = PdfScreenshotOptions | PngScreenshotOptions;
|
||||
export type ScreenshotResult = PdfScreenshotResult | PngScreenshotResult;
|
||||
|
||||
const DEFAULT_SETUP_RESULT = {
|
||||
elementsPositionAndAttributes: null,
|
||||
timeRange: null,
|
||||
};
|
||||
|
||||
export class Screenshots {
|
||||
private semaphore: Semaphore;
|
||||
|
||||
constructor(
|
||||
private readonly browserDriverFactory: HeadlessChromiumDriverFactory,
|
||||
private readonly logger: Logger,
|
||||
private readonly packageInfo: PackageInfo,
|
||||
private readonly http: HttpServiceSetup,
|
||||
private readonly config: ConfigType,
|
||||
private readonly cloud?: CloudSetup
|
||||
) {
|
||||
this.semaphore = new Semaphore(config.poolSize);
|
||||
}
|
||||
|
||||
private captureScreenshots(
|
||||
eventLogger: EventLogger,
|
||||
layout: Layout,
|
||||
options: ScreenshotObservableOptions
|
||||
): Observable<CaptureResult> {
|
||||
const { browserTimezone } = options;
|
||||
|
||||
return this.browserDriverFactory
|
||||
.createPage(
|
||||
{
|
||||
browserTimezone,
|
||||
openUrlTimeout: durationToNumber(this.config.capture.timeouts.openUrl),
|
||||
defaultViewport: { width: layout.width, deviceScaleFactor: layout.getBrowserZoom() },
|
||||
},
|
||||
this.logger
|
||||
)
|
||||
.pipe(
|
||||
this.semaphore.acquire(),
|
||||
mergeMap(({ driver, error$, close }) => {
|
||||
const screen: ScreenshotObservableHandler = new ScreenshotObservableHandler(
|
||||
driver,
|
||||
this.config,
|
||||
eventLogger,
|
||||
layout,
|
||||
options
|
||||
);
|
||||
|
||||
return from(options.urls).pipe(
|
||||
concatMap((url, index) =>
|
||||
screen.setupPage(index, url).pipe(
|
||||
catchError((error) => {
|
||||
screen.checkPageIsOpen(); // this fails the job if the browser has closed
|
||||
|
||||
this.logger.error(error);
|
||||
eventLogger.error(error, Transactions.SCREENSHOTTING);
|
||||
return of({ ...DEFAULT_SETUP_RESULT, error }); // allow "as-is" screenshot with injected warning message
|
||||
}),
|
||||
takeUntil(error$),
|
||||
screen.getScreenshots()
|
||||
)
|
||||
),
|
||||
take(options.urls.length),
|
||||
toArray(),
|
||||
mergeMap((results) =>
|
||||
// At this point we no longer need the page, close it and send out the results
|
||||
close().pipe(map(({ metrics }) => ({ metrics, results })))
|
||||
)
|
||||
);
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
|
||||
private getScreenshottingAppUrl() {
|
||||
const info = this.http.getServerInfo();
|
||||
const { protocol, port } = info;
|
||||
let { hostname } = info;
|
||||
|
||||
if (ipaddr.isValid(hostname) && !sum(ipaddr.parse(hostname).toByteArray())) {
|
||||
hostname = 'localhost';
|
||||
}
|
||||
|
||||
return `${protocol}://${hostname}:${port}${this.http.basePath.serverBasePath}/app/${SCREENSHOTTING_APP_ID}`;
|
||||
}
|
||||
|
||||
private getCaptureOptions({
|
||||
expression,
|
||||
input,
|
||||
request,
|
||||
...options
|
||||
}: ScreenshotOptions): ScreenshotObservableOptions {
|
||||
const headers = { ...(request?.headers ?? {}), ...(options.headers ?? {}) };
|
||||
const urls = expression
|
||||
? [
|
||||
[
|
||||
this.getScreenshottingAppUrl(),
|
||||
{
|
||||
[SCREENSHOTTING_EXPRESSION]: expression,
|
||||
[SCREENSHOTTING_EXPRESSION_INPUT]: input,
|
||||
},
|
||||
] as UrlOrUrlWithContext,
|
||||
]
|
||||
: options.urls;
|
||||
|
||||
return defaultsDeep(
|
||||
{
|
||||
...options,
|
||||
headers,
|
||||
urls,
|
||||
},
|
||||
{
|
||||
timeouts: {
|
||||
openUrl: 60000,
|
||||
waitForElements: 60000,
|
||||
renderComplete: 120000,
|
||||
},
|
||||
urls: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
systemHasInsufficientMemory(): boolean {
|
||||
return systemHasInsufficientMemory(this.cloud, this.logger.get('cloud'));
|
||||
}
|
||||
|
||||
getScreenshots(options: PngScreenshotOptions): Observable<PngScreenshotResult>;
|
||||
getScreenshots(options: PdfScreenshotOptions): Observable<PdfScreenshotResult>;
|
||||
getScreenshots(options: ScreenshotOptions): Observable<ScreenshotResult>;
|
||||
getScreenshots(options: ScreenshotOptions): Observable<ScreenshotResult> {
|
||||
if (this.systemHasInsufficientMemory()) {
|
||||
return throwError(() => new errors.InsufficientMemoryAvailableOnCloudError());
|
||||
}
|
||||
|
||||
const eventLogger = new EventLogger(this.logger, this.config);
|
||||
const transactionEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING);
|
||||
|
||||
const layout = createLayout(options.layout ?? {});
|
||||
const captureOptions = this.getCaptureOptions(options);
|
||||
|
||||
return this.captureScreenshots(eventLogger, layout, captureOptions).pipe(
|
||||
tap(({ results, metrics }) => {
|
||||
transactionEnd({
|
||||
labels: {
|
||||
cpu: metrics?.cpu,
|
||||
memory: metrics?.memory,
|
||||
memory_mb: metrics?.memoryInMegabytes,
|
||||
...eventLogger.getByteLengthFromCaptureResults(results),
|
||||
},
|
||||
});
|
||||
}),
|
||||
mergeMap((result) => {
|
||||
switch (options.format) {
|
||||
case 'pdf':
|
||||
return toPdf(eventLogger, this.packageInfo, layout, options, result);
|
||||
default:
|
||||
return toPng(result);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
export { Screenshots } from './screenshots';
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* 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 { CloudSetup } from '@kbn/cloud-plugin/server';
|
||||
import type { HttpServiceSetup } from '@kbn/core-http-server';
|
||||
import type { PackageInfo } from '@kbn/core/server';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
||||
import puppeteer from 'puppeteer';
|
||||
import * as Rx from 'rxjs';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import type { PngScreenshotOptions } from '..';
|
||||
import { HeadlessChromiumDriverFactory } from '../browsers';
|
||||
import type { ConfigType } from '../config';
|
||||
import { Screenshots } from './screenshots';
|
||||
|
||||
jest.mock('puppeteer');
|
||||
|
||||
describe('class Screenshots', () => {
|
||||
let mockConfig: ConfigType;
|
||||
let browserDriverFactory: HeadlessChromiumDriverFactory;
|
||||
let mockPackageInfo: PackageInfo;
|
||||
let mockHttpSetup: HttpServiceSetup;
|
||||
let mockCloudSetup: CloudSetup;
|
||||
let mockLogger: Logger;
|
||||
let mockScreenshotModeSetup: ScreenshotModePluginSetup;
|
||||
|
||||
const mockBinaryPath = '/kibana/x-pack/plugins/screenshotting/chromium/linux/headless_shell';
|
||||
const mockBasePath = '/kibanaTest1';
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = loggerMock.create();
|
||||
|
||||
mockConfig = {
|
||||
networkPolicy: {
|
||||
enabled: false,
|
||||
rules: [],
|
||||
},
|
||||
browser: {
|
||||
autoDownload: false,
|
||||
chromium: { proxy: { enabled: false } },
|
||||
},
|
||||
capture: {
|
||||
timeouts: {
|
||||
openUrl: 60000,
|
||||
waitForElements: 60000,
|
||||
renderComplete: 60000,
|
||||
},
|
||||
zoom: 2,
|
||||
},
|
||||
poolSize: 1,
|
||||
};
|
||||
|
||||
mockScreenshotModeSetup = {} as unknown as ScreenshotModePluginSetup;
|
||||
|
||||
browserDriverFactory = new HeadlessChromiumDriverFactory(
|
||||
mockScreenshotModeSetup,
|
||||
mockConfig,
|
||||
mockLogger,
|
||||
mockBinaryPath,
|
||||
mockBasePath
|
||||
);
|
||||
|
||||
mockCloudSetup = { isCloudEnabled: true, instanceSizeMb: 8000 } as unknown as CloudSetup;
|
||||
});
|
||||
|
||||
const getScreenshotsInstance = () =>
|
||||
new Screenshots(
|
||||
browserDriverFactory,
|
||||
mockLogger,
|
||||
mockPackageInfo,
|
||||
mockHttpSetup,
|
||||
mockConfig,
|
||||
mockCloudSetup
|
||||
);
|
||||
|
||||
it('detects sufficient memory from cloud plugin', () => {
|
||||
const screenshotsInstance = getScreenshotsInstance();
|
||||
const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory();
|
||||
expect(hasInsufficient).toBe(false);
|
||||
});
|
||||
|
||||
it('detects insufficient memory from cloud plugin', () => {
|
||||
mockCloudSetup = { isCloudEnabled: true, instanceSizeMb: 1000 } as unknown as CloudSetup;
|
||||
const screenshotsInstance = getScreenshotsInstance();
|
||||
const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory();
|
||||
expect(hasInsufficient).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores insufficient memory if cloud is not enabled', () => {
|
||||
mockCloudSetup = { isCloudEnabled: false, instanceSizeMb: 1000 } as unknown as CloudSetup;
|
||||
const screenshotsInstance = getScreenshotsInstance();
|
||||
const hasInsufficient = screenshotsInstance.systemHasInsufficientMemory();
|
||||
expect(hasInsufficient).toBe(false);
|
||||
});
|
||||
|
||||
describe('getScreenshots', () => {
|
||||
beforeAll(() => {
|
||||
jest.mock('puppeteer'); // see __mocks__/puppeteer.ts
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(browserDriverFactory, 'getBrowserLogger').mockReturnValue(Rx.EMPTY);
|
||||
jest.spyOn(browserDriverFactory, 'getProcessLogger').mockReturnValue(Rx.EMPTY);
|
||||
jest.spyOn(browserDriverFactory, 'getPageExit').mockReturnValue(Rx.EMPTY);
|
||||
});
|
||||
|
||||
it('getScreenshots with PngScreenshotOptions', async () => {
|
||||
const screenshotsInstance = getScreenshotsInstance();
|
||||
|
||||
const options: PngScreenshotOptions = {
|
||||
format: 'png',
|
||||
layout: { id: 'preserve_layout' },
|
||||
urls: ['/app/home/test'],
|
||||
};
|
||||
|
||||
const observe = screenshotsInstance.getScreenshots(options);
|
||||
await firstValueFrom(observe).then((captureResult) => {
|
||||
expect(captureResult.results[0].screenshots[0].data).toEqual(
|
||||
Buffer.from(`you won't believe this one weird screenshot`, 'base64')
|
||||
);
|
||||
expect(captureResult.results[0].renderErrors).toBe(undefined);
|
||||
expect(captureResult.results[0].error).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds warning to the screenshot in case of openUrl timeout', async () => {
|
||||
// @ts-expect-error should not assign new value to read-only property
|
||||
mockConfig.capture.timeouts.openUrl = 10; // must be a small amount of milliseconds
|
||||
|
||||
// mock override
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage(); // should be stubPage
|
||||
const pageGotoSpy = jest.spyOn(page, 'goto');
|
||||
pageGotoSpy.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 100); // must be larger than 10
|
||||
})
|
||||
);
|
||||
|
||||
const screenshotsInstance = getScreenshotsInstance();
|
||||
|
||||
const options: PngScreenshotOptions = {
|
||||
format: 'png',
|
||||
layout: { id: 'preserve_layout' },
|
||||
urls: ['/app/home/test'],
|
||||
};
|
||||
|
||||
const observe = screenshotsInstance.getScreenshots(options);
|
||||
await firstValueFrom(observe).then((captureResult) => {
|
||||
expect(captureResult.results[0].error).toEqual(
|
||||
new Error(
|
||||
`Screenshotting encountered a timeout error: "open URL" took longer than 0.01 seconds.` +
|
||||
` You may need to increase "xpack.screenshotting.capture.timeouts.openUrl" in kibana.yml.`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
208
x-pack/plugins/screenshotting/server/screenshots/screenshots.ts
Normal file
208
x-pack/plugins/screenshotting/server/screenshots/screenshots.ts
Normal file
|
@ -0,0 +1,208 @@
|
|||
/*
|
||||
* 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 { CloudSetup } from '@kbn/cloud-plugin/server';
|
||||
import type { HttpServiceSetup, Logger, PackageInfo } from '@kbn/core/server';
|
||||
import { Semaphore } from '@kbn/std';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { defaultsDeep, sum } from 'lodash';
|
||||
import { from, Observable, of, throwError } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
first,
|
||||
map,
|
||||
mergeMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
toArray,
|
||||
} from 'rxjs/operators';
|
||||
import { CaptureResult, ScreenshotOptions, ScreenshotResult } from '.';
|
||||
import {
|
||||
errors,
|
||||
SCREENSHOTTING_APP_ID,
|
||||
SCREENSHOTTING_EXPRESSION,
|
||||
SCREENSHOTTING_EXPRESSION_INPUT,
|
||||
} from '../../common';
|
||||
import { HeadlessChromiumDriverFactory } from '../browsers';
|
||||
import { systemHasInsufficientMemory } from '../cloud';
|
||||
import type { ConfigType } from '../config';
|
||||
import { durationToNumber } from '../config';
|
||||
import {
|
||||
PdfScreenshotOptions,
|
||||
PdfScreenshotResult,
|
||||
PngScreenshotOptions,
|
||||
PngScreenshotResult,
|
||||
toPdf,
|
||||
toPng,
|
||||
} from '../formats';
|
||||
import { createLayout, Layout } from '../layouts';
|
||||
import { EventLogger, Transactions } from './event_logger';
|
||||
import type { ScreenshotObservableOptions } from './observable';
|
||||
import { ScreenshotObservableHandler, UrlOrUrlWithContext } from './observable';
|
||||
|
||||
const DEFAULT_SETUP_RESULT = {
|
||||
elementsPositionAndAttributes: null,
|
||||
timeRange: null,
|
||||
};
|
||||
|
||||
export class Screenshots {
|
||||
private semaphore: Semaphore;
|
||||
|
||||
constructor(
|
||||
private readonly browserDriverFactory: HeadlessChromiumDriverFactory,
|
||||
private readonly logger: Logger,
|
||||
private readonly packageInfo: PackageInfo,
|
||||
private readonly http: HttpServiceSetup,
|
||||
private readonly config: ConfigType,
|
||||
private readonly cloud?: CloudSetup
|
||||
) {
|
||||
this.semaphore = new Semaphore(config.poolSize);
|
||||
}
|
||||
|
||||
private captureScreenshots(
|
||||
eventLogger: EventLogger,
|
||||
layout: Layout,
|
||||
options: ScreenshotObservableOptions
|
||||
): Observable<CaptureResult> {
|
||||
const { browserTimezone } = options;
|
||||
|
||||
return this.browserDriverFactory
|
||||
.createPage(
|
||||
{
|
||||
browserTimezone,
|
||||
openUrlTimeout: durationToNumber(this.config.capture.timeouts.openUrl),
|
||||
defaultViewport: { width: layout.width, deviceScaleFactor: layout.getBrowserZoom() },
|
||||
},
|
||||
this.logger
|
||||
)
|
||||
.pipe(
|
||||
this.semaphore.acquire(),
|
||||
mergeMap(({ driver, error$, close }) => {
|
||||
const screen: ScreenshotObservableHandler = new ScreenshotObservableHandler(
|
||||
driver,
|
||||
this.config,
|
||||
eventLogger,
|
||||
layout,
|
||||
options
|
||||
);
|
||||
|
||||
return from(options.urls).pipe(
|
||||
concatMap((url, index) =>
|
||||
screen.setupPage(index, url).pipe(
|
||||
catchError((error) => {
|
||||
screen.checkPageIsOpen(); // this fails the job if the browser has closed
|
||||
|
||||
this.logger.error(error);
|
||||
eventLogger.error(error, Transactions.SCREENSHOTTING);
|
||||
return of({ ...DEFAULT_SETUP_RESULT, error }); // allow "as-is" screenshot with injected warning message
|
||||
}),
|
||||
takeUntil(error$),
|
||||
screen.getScreenshots()
|
||||
)
|
||||
),
|
||||
take(options.urls.length),
|
||||
toArray(),
|
||||
mergeMap((results) =>
|
||||
// At this point we no longer need the page, close it and send out the results
|
||||
close().pipe(map(({ metrics }) => ({ metrics, results })))
|
||||
)
|
||||
);
|
||||
}),
|
||||
first()
|
||||
);
|
||||
}
|
||||
|
||||
private getScreenshottingAppUrl() {
|
||||
const info = this.http.getServerInfo();
|
||||
const { protocol, port } = info;
|
||||
let { hostname } = info;
|
||||
|
||||
if (ipaddr.isValid(hostname) && !sum(ipaddr.parse(hostname).toByteArray())) {
|
||||
hostname = 'localhost';
|
||||
}
|
||||
|
||||
return `${protocol}://${hostname}:${port}${this.http.basePath.serverBasePath}/app/${SCREENSHOTTING_APP_ID}`;
|
||||
}
|
||||
|
||||
private getCaptureOptions({
|
||||
expression,
|
||||
input,
|
||||
request,
|
||||
...options
|
||||
}: ScreenshotOptions): ScreenshotObservableOptions {
|
||||
const headers = { ...(request?.headers ?? {}), ...(options.headers ?? {}) };
|
||||
const urls = expression
|
||||
? [
|
||||
[
|
||||
this.getScreenshottingAppUrl(),
|
||||
{
|
||||
[SCREENSHOTTING_EXPRESSION]: expression,
|
||||
[SCREENSHOTTING_EXPRESSION_INPUT]: input,
|
||||
},
|
||||
] as UrlOrUrlWithContext,
|
||||
]
|
||||
: options.urls;
|
||||
|
||||
return defaultsDeep(
|
||||
{
|
||||
...options,
|
||||
headers,
|
||||
urls,
|
||||
},
|
||||
{
|
||||
timeouts: {
|
||||
openUrl: 60000,
|
||||
waitForElements: 60000,
|
||||
renderComplete: 120000,
|
||||
},
|
||||
urls: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
systemHasInsufficientMemory(): boolean {
|
||||
return systemHasInsufficientMemory(this.cloud, this.logger.get('cloud'));
|
||||
}
|
||||
|
||||
getScreenshots(options: PngScreenshotOptions): Observable<PngScreenshotResult>;
|
||||
getScreenshots(options: PdfScreenshotOptions): Observable<PdfScreenshotResult>;
|
||||
getScreenshots(options: ScreenshotOptions): Observable<ScreenshotResult>;
|
||||
getScreenshots(options: ScreenshotOptions): Observable<ScreenshotResult> {
|
||||
if (this.systemHasInsufficientMemory()) {
|
||||
return throwError(() => new errors.InsufficientMemoryAvailableOnCloudError());
|
||||
}
|
||||
|
||||
const eventLogger = new EventLogger(this.logger, this.config);
|
||||
const transactionEnd = eventLogger.startTransaction(Transactions.SCREENSHOTTING);
|
||||
|
||||
const layout = createLayout(options.layout ?? {});
|
||||
const captureOptions = this.getCaptureOptions(options);
|
||||
|
||||
return this.captureScreenshots(eventLogger, layout, captureOptions).pipe(
|
||||
tap(({ results, metrics }) => {
|
||||
transactionEnd({
|
||||
labels: {
|
||||
cpu: metrics?.cpu,
|
||||
memory: metrics?.memory,
|
||||
memory_mb: metrics?.memoryInMegabytes,
|
||||
...eventLogger.getByteLengthFromCaptureResults(results),
|
||||
},
|
||||
});
|
||||
}),
|
||||
mergeMap((result) => {
|
||||
switch (options.format) {
|
||||
case 'pdf':
|
||||
return toPdf(eventLogger, this.packageInfo, layout, options, result);
|
||||
default:
|
||||
return toPng(result);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -23,6 +23,8 @@
|
|||
"@kbn/utils",
|
||||
"@kbn/safer-lodash-set",
|
||||
"@kbn/core-logging-server-mocks",
|
||||
"@kbn/logging-mocks",
|
||||
"@kbn/core-http-server",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -1,26 +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 { FtrConfigProviderContext } from '@kbn/test';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const functionalConfig = await readConfigFile(require.resolve('./reporting_and_security.config'));
|
||||
|
||||
return {
|
||||
...functionalConfig.getAll(),
|
||||
junit: { reportName: 'X-Pack Reporting Functional Tests: Reports and Timeout Handling' },
|
||||
testFiles: [resolve(__dirname, './reporting_and_timeout')],
|
||||
kbnTestServer: {
|
||||
...functionalConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...functionalConfig.get('kbnTestServer.serverArgs'),
|
||||
`--xpack.reporting.capture.timeouts.waitForElements=1s`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 93 KiB |
|
@ -1,98 +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 expect from '@kbn/expect';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PNG } from 'pngjs';
|
||||
import { FtrProviderContext } from '../ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const PageObjects = getPageObjects(['common', 'reporting', 'dashboard']);
|
||||
const log = getService('log');
|
||||
const reportingAPI = getService('reportingAPI');
|
||||
const reportingFunctional = getService('reportingFunctional');
|
||||
const browser = getService('browser');
|
||||
const png = getService('png');
|
||||
const config = getService('config');
|
||||
const screenshotDir = config.get('screenshots.directory');
|
||||
|
||||
// FLAKY: https://github.com/elastic/kibana/issues/135309
|
||||
describe.skip('Reporting Functional Tests with forced timeout', function () {
|
||||
const dashboardTitle = 'Ecom Dashboard Hidden Panel Titles';
|
||||
const sessionPngFullPage = 'warnings_capture_session_a';
|
||||
const sessionPngCropped = 'warnings_capture_session_b';
|
||||
const baselinePng = path.resolve(__dirname, 'fixtures/baseline/warnings_capture_b.png');
|
||||
|
||||
let url: string;
|
||||
before(async () => {
|
||||
await reportingAPI.logTaskManagerHealth();
|
||||
await reportingFunctional.initEcommerce();
|
||||
|
||||
await PageObjects.common.navigateToApp('dashboard');
|
||||
await PageObjects.dashboard.loadSavedDashboard(dashboardTitle);
|
||||
await PageObjects.reporting.setTimepickerInEcommerceDataRange();
|
||||
await browser.setWindowSize(800, 850);
|
||||
|
||||
await PageObjects.reporting.openPngReportingPanel();
|
||||
await PageObjects.reporting.clickGenerateReportButton();
|
||||
|
||||
url = await PageObjects.reporting.getReportURL(60000);
|
||||
|
||||
const res = await PageObjects.reporting.getResponse(url);
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.get('content-type')).to.equal('image/png');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await reportingFunctional.teardownEcommerce();
|
||||
});
|
||||
|
||||
it('adds a visual warning in the report output', async () => {
|
||||
const captureData = await PageObjects.reporting.getRawPdfReportData(url);
|
||||
const sessionReport = await PageObjects.reporting.writeSessionReport(
|
||||
sessionPngFullPage,
|
||||
'png',
|
||||
captureData,
|
||||
screenshotDir
|
||||
);
|
||||
|
||||
const region = { height: 320, width: 1540, srcX: 20, srcY: 10 };
|
||||
const dstPath = path.resolve(screenshotDir, sessionPngCropped + '.png');
|
||||
const dst = new PNG({ width: region.width, height: region.height });
|
||||
|
||||
const pngSessionFilePath = await new Promise<string>((resolve) => {
|
||||
fs.createReadStream(sessionReport)
|
||||
.pipe(new PNG())
|
||||
.on('parsed', function () {
|
||||
log.info(`cropping report to the visual warning area`);
|
||||
this.bitblt(dst, region.srcX, region.srcY, region.width, region.height, 0, 0);
|
||||
dst.pack().pipe(fs.createWriteStream(dstPath));
|
||||
resolve(dstPath);
|
||||
});
|
||||
});
|
||||
|
||||
log.info(`saved cropped file to ${dstPath}`);
|
||||
|
||||
expect(
|
||||
await png.checkIfPngsMatch(pngSessionFilePath, baselinePng, screenshotDir)
|
||||
).to.be.lessThan(0.09);
|
||||
|
||||
/**
|
||||
* This test may fail when styling differences affect the result. To update the snapshot:
|
||||
*
|
||||
* 1. Run the functional test, to generate new temporary files for screenshot comparison.
|
||||
* 2. Save the screenshot as the new baseline file:
|
||||
* cp \
|
||||
* x-pack/test/functional/screenshots/session/warnings_capture_session_b_actual.png \
|
||||
* x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png
|
||||
* 3. Commit the changes to the .png file
|
||||
*/
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue