kibana/x-pack/test/functional/page_objects/reporting_page.ts
Tim Sullivan bf1ef87b35
[8.18] [Canvas/PDF report] Allow canvas to generate PDF report (#224309) (#224339)
# Backport

This will backport the following commits from `main` to `8.18`:
- [[Canvas/PDF report] Allow canvas to generate PDF report
(#224309)](https://github.com/elastic/kibana/pull/224309)

<!--- Backport version: 10.0.1 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Tim
Sullivan","email":"tsullivan@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-06-17T22:47:22Z","message":"[Canvas/PDF
report] Allow canvas to generate PDF report (#224309)\n\n##
Summary\n\nCloses
https://github.com/elastic/kibana/issues/224275\n\n**Context:** In
https://github.com/elastic/kibana/pull/222273, we added\nrestrictions to
the \"Reporting redirect app\" to make sure it could not\nbe abused by
using unexpected locator types (such as the short URL\nlocator or the
\"legacy\" locator type) when triggering redirects in the\nReporting
headless browser. The restrictions are on the basis of a list\nof
allowed locator types, which should be a list of every analytical
app\nthat supports Reporting.\n\n**Problem:** Unfortunately that added a
regression to Canvas PDF\nreporting, because the allow-list for locator
types neglected to include\nthe Canvas locator type.\n\nThis PR solves
the problem by adding the Canvas locator type to the set\nof allowed
locator types.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"f5dee1347f019d4e98a45070eeb80356453a9827","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:version","v8.15.6","v9.1.0","v8.19.0","v7.17.29","v9.0.3","v8.18.3","v8.17.8"],"title":"[Canvas/PDF
report] Allow canvas to generate PDF
report","number":224309,"url":"https://github.com/elastic/kibana/pull/224309","mergeCommit":{"message":"[Canvas/PDF
report] Allow canvas to generate PDF report (#224309)\n\n##
Summary\n\nCloses
https://github.com/elastic/kibana/issues/224275\n\n**Context:** In
https://github.com/elastic/kibana/pull/222273, we added\nrestrictions to
the \"Reporting redirect app\" to make sure it could not\nbe abused by
using unexpected locator types (such as the short URL\nlocator or the
\"legacy\" locator type) when triggering redirects in the\nReporting
headless browser. The restrictions are on the basis of a list\nof
allowed locator types, which should be a list of every analytical
app\nthat supports Reporting.\n\n**Problem:** Unfortunately that added a
regression to Canvas PDF\nreporting, because the allow-list for locator
types neglected to include\nthe Canvas locator type.\n\nThis PR solves
the problem by adding the Canvas locator type to the set\nof allowed
locator types.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"f5dee1347f019d4e98a45070eeb80356453a9827"}},"sourceBranch":"main","suggestedTargetBranches":["8.15","7.17","9.0","8.18","8.17"],"targetPullRequestStates":[{"branch":"8.15","label":"v8.15.6","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/224309","number":224309,"mergeCommit":{"message":"[Canvas/PDF
report] Allow canvas to generate PDF report (#224309)\n\n##
Summary\n\nCloses
https://github.com/elastic/kibana/issues/224275\n\n**Context:** In
https://github.com/elastic/kibana/pull/222273, we added\nrestrictions to
the \"Reporting redirect app\" to make sure it could not\nbe abused by
using unexpected locator types (such as the short URL\nlocator or the
\"legacy\" locator type) when triggering redirects in the\nReporting
headless browser. The restrictions are on the basis of a list\nof
allowed locator types, which should be a list of every analytical
app\nthat supports Reporting.\n\n**Problem:** Unfortunately that added a
regression to Canvas PDF\nreporting, because the allow-list for locator
types neglected to include\nthe Canvas locator type.\n\nThis PR solves
the problem by adding the Canvas locator type to the set\nof allowed
locator types.\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"f5dee1347f019d4e98a45070eeb80356453a9827"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/224335","number":224335,"state":"OPEN"},{"branch":"7.17","label":"v7.17.29","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.0","label":"v9.0.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.8","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
2025-06-18 17:47:29 +02:00

256 lines
9.2 KiB
TypeScript

/*
* 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 type SuperTest from 'supertest';
import { format as formatUrl } from 'url';
import { promisify } from 'util';
import { INTERNAL_ROUTES, REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common';
import { FtrService } from '../ftr_provider_context';
const writeFileAsync = promisify(fs.writeFile);
const mkdirAsync = promisify(fs.mkdir);
export class ReportingPageObject extends FtrService {
private readonly browser = this.ctx.getService('browser');
private readonly config = this.ctx.getService('config');
private readonly log = this.ctx.getService('log');
private readonly retry = this.ctx.getService('retry');
private readonly security = this.ctx.getService('security');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly find = this.ctx.getService('find');
private readonly share = this.ctx.getPageObject('share');
private readonly timePicker = this.ctx.getPageObject('timePicker');
async forceSharedItemsContainerSize({ width }: { width: number }) {
await this.browser.execute(`
var el = document.querySelector('[data-shared-items-container]');
el.style.flex="none";
el.style.width="${width}px";
`);
}
async getReportJobId(timeout: number): Promise<string> {
this.log.debug('getReportJobId');
try {
// get the report job id from a data attribute on the download button
const jobIdElement = await this.find.byCssSelector('[data-test-jobId]', timeout);
if (!jobIdElement) {
throw new Error('Failed to find report job id.');
}
const jobId = await jobIdElement.getAttribute('data-test-jobId');
if (!jobId) {
throw new Error('Failed to find report job id.');
}
return jobId;
} catch (err) {
let errorText = 'Unknown error';
if (await this.find.existsByCssSelector('[data-test-errorText]')) {
const errorTextEl = await this.find.byCssSelector('[data-test-errorText]');
errorText = (await errorTextEl.getAttribute('data-test-errorText')) ?? errorText;
}
throw new Error(`Test report failed: ${errorText}: ${err}`, { cause: err });
}
}
async getReportURL(timeout: number) {
this.log.debug('getReportURL');
try {
const url = await this.testSubjects.getAttribute(
'downloadCompletedReportButton',
'href',
timeout
);
this.log.debug(`getReportURL got url: ${url}`);
return url;
} catch (err) {
const errorTextEl = await this.find.byCssSelector('[data-test-errorText]');
const errorText = await errorTextEl.getAttribute('data-test-errorText');
const newError = new Error(`Test report failed: ${errorText}: ${err}`);
throw newError;
}
}
async removeForceSharedItemsContainerSize() {
await this.browser.execute(`
var el = document.querySelector('[data-shared-items-container]');
el.style.flex = null;
el.style.width = null;
`);
}
async getResponse(fullUrl: string): Promise<SuperTest.Response> {
this.log.debug(`getResponse for ${fullUrl}`);
const kibanaServerConfig = this.config.get('servers.kibana');
const baseURL = formatUrl({
...kibanaServerConfig,
auth: false,
});
const urlWithoutBase = fullUrl.replace(baseURL, '');
const res = await this.security.testUserSupertest.get(urlWithoutBase);
return res ?? '';
}
async getReportInfo(jobId: string) {
this.log.debug(`getReportInfo for ${jobId}`);
const response = await this.getResponse(INTERNAL_ROUTES.JOBS.INFO_PREFIX + `/${jobId}`);
return response.body;
}
async getRawReportData(url: string): Promise<Buffer> {
this.log.debug(`getRawReportData for ${url}`);
const response = await this.getResponse(url);
expect(response.body).to.be.a(Buffer);
return response.body as Buffer;
}
async openShareMenuItem(itemTitle: string) {
this.log.debug(`openShareMenuItem title:${itemTitle}`);
const isShareMenuOpen = await this.testSubjects.exists('shareContextMenu');
if (!isShareMenuOpen) {
await this.testSubjects.click('shareTopNavButton');
} else {
// there is no easy way to ensure the menu is at the top level
// so just close the existing menu
await this.testSubjects.click('shareTopNavButton');
// and then re-open the menu
await this.testSubjects.click('shareTopNavButton');
}
const menuPanel = await this.find.byCssSelector('div.euiContextMenuPanel');
await this.testSubjects.click(`sharePanel-${itemTitle.replace(' ', '')}`);
await this.testSubjects.waitForDeleted(menuPanel);
}
async openExportTab() {
this.log.debug('open export modal');
await this.share.clickTab('Export');
}
async getQueueReportError() {
return await this.testSubjects.exists('errorToastMessage');
}
async getGenerateReportButton() {
return await this.retry.try(async () => await this.testSubjects.find('generateReportButton'));
}
async isGenerateReportButtonDisabled() {
const generateReportButton = await this.getGenerateReportButton();
return await this.retry.try(async () => {
const isDisabled = await generateReportButton.getAttribute('disabled');
return isDisabled;
});
}
async canReportBeCreated() {
await this.clickGenerateReportButton();
const success = await this.checkForReportingToasts();
return success;
}
async checkUsePrintLayout() {
// The print layout checkbox slides in as part of an animation, and tests can
// attempt to click it too quickly, leading to flaky tests. The 500ms wait allows
// the animation to complete before we attempt a click.
const menuAnimationDelay = 500;
await this.retry.tryForTime(menuAnimationDelay, () =>
this.testSubjects.click('usePrintLayout')
);
}
async clickGenerateReportButton() {
await this.testSubjects.click('generateReportButton');
}
async toggleReportMode() {
await this.testSubjects.click('reportModeToggle');
}
async checkForReportingToasts() {
this.log.debug('Reporting:checkForReportingToasts');
const isToastPresent = await this.testSubjects.exists('completeReportSuccess', {
allowHidden: true,
timeout: 90000,
});
// Close toast so it doesn't obscure the UI.
if (isToastPresent) {
await this.testSubjects.click('completeReportSuccess > toastCloseButton');
}
return isToastPresent;
}
// set the time picker to a range matching 720 documents when using the
// reporting/ecommerce archive
async setTimepickerInEcommerceDataRange() {
this.log.debug('Reporting:setTimepickerInDataRange');
const fromTime = 'Jun 20, 2019 @ 00:00:00.000';
const toTime = 'Jun 25, 2019 @ 00:00:00.000';
await this.timePicker.setAbsoluteRange(fromTime, toTime);
}
// set the time picker to a range matching 0 documents when using the
// reporting/ecommerce archive
async setTimepickerInEcommerceNoDataRange() {
this.log.debug('Reporting:setTimepickerInNoDataRange');
const fromTime = 'Sep 19, 1999 @ 06:31:44.000';
const toTime = 'Sep 23, 1999 @ 18:31:44.000';
await this.timePicker.setAbsoluteRange(fromTime, toTime);
}
async getManagementList() {
const table = await this.testSubjects.find(REPORT_TABLE_ID);
const allRows = await table.findAllByTestSubject(REPORT_TABLE_ROW_ID);
return await Promise.all(
allRows.map(async (row) => {
const $ = await row.parseDomContent();
return {
report: $.findTestSubject('reportingListItemObjectTitle').text().trim(),
createdAt: $.findTestSubject('reportJobCreatedAt').text().trim(),
status: $.findTestSubject('reportJobStatus').text().trim(),
actions: $.findTestSubject('reportJobActions').text().trim(),
};
})
);
}
async openReportFlyout(reportTitle: string) {
const table = await this.testSubjects.find(REPORT_TABLE_ID);
const allRows = await table.findAllByTestSubject(REPORT_TABLE_ROW_ID);
for (const row of allRows) {
const titleColumn = await row.findByTestSubject('reportingListItemObjectTitle');
const title = await titleColumn.getVisibleText();
if (title === reportTitle) {
await titleColumn.click();
return;
}
}
}
async writeSessionReport(name: string, reportExt: string, rawPdf: Buffer, folder: string) {
const sessionDirectory = path.resolve(folder, 'session');
await mkdirAsync(sessionDirectory, { recursive: true });
const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`);
await writeFileAsync(sessionReportPath, rawPdf);
this.log.debug(`sessionReportPath (${sessionReportPath})`);
return sessionReportPath;
}
getBaselineReportPath(fileName: string, reportExt: string, folder: string) {
const baselineFolder = path.resolve(folder, 'baseline');
const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`);
this.log.debug(`baselineReportPath (${fullPath})`);
return fullPath;
}
}