Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions __mocks__/@react-pdf/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,42 @@ export const Font = {
clear: jest.fn(),
};

export const pdf = jest.fn( () => ( {
toBlob: jest.fn( () =>
Promise.resolve(
/*
* The layout the mocked `toBlob()` passes to the document's `onRender`
* callback, mirroring the real library's internal layout shape. The deepest
* box bottom is 24 + 476 = 500, so `measurePDFContentHeight` returns 500,
* and the nested section node puts one anchor at absolute top 224 (200 + 24)
* for `extractPDFSectionAnchors`.
*/
export const MOCK_PDF_LAYOUT = {
_INTERNAL__LAYOUT__DATA_: {
children: [
{
children: [
{ box: { top: 24, height: 176 } },
{
box: { top: 200, height: 300 },
children: [
{
props: { id: 'section-mockArea' },
box: { top: 24, height: 276 },
},
],
},
{ box: { top: 24, height: 476 } },
],
},
],
},
};

export const pdf = jest.fn( ( element ) => ( {
toBlob: jest.fn( () => {
element?.props?.onRender?.( MOCK_PDF_LAYOUT );
return Promise.resolve(
new Blob( [ 'mock-pdf' ], { type: 'application/pdf' } )
)
),
);
} ),
toBuffer: jest.fn( () => Promise.resolve( Buffer.from( 'mock-pdf' ) ) ),
toString: jest.fn( () => Promise.resolve( 'mock-pdf' ) ),
updateContainer: jest.fn(),
Expand Down
154 changes: 153 additions & 1 deletion assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
waitFor,
} from '@tests/js/test-utils';
import { registerPDFFonts } from './pdf-fonts-react';
import { PDF_MEASURE_PAGE_HEIGHT, PDF_PAGE_BOTTOM_PADDING } from './pdf-theme';
import { triggerDownload } from './pdf-utils';
import PDFExportOrchestrator from './PDFExportOrchestrator';
import { SECTION_ICONS } from './section-icons';
import { PDFHeaderSection, PDFReportArea } from './types';
Expand Down Expand Up @@ -80,6 +82,32 @@ function NullComponent() {
return null;
}

// The bottom edge of the layout fixture the mocked `toBlob()` passes to
// `onRender` (see `MOCK_PDF_LAYOUT` in `__mocks__/@react-pdf/renderer.js`).
const MOCKED_MEASURED_HEIGHT = 500;

/**
* Builds a `pdf()` implementation whose `toBlob()` fires the document's
* `onRender` callback with the given layout, overriding the mock's fixture.
*
* @since n.e.x.t
*
* @param layout The layout to pass to `onRender`.
* @return The `pdf()` implementation.
*/
function pdfImplementationWithLayout( layout: unknown ) {
return ( element: {
props?: { onRender?: ( renderedLayout: unknown ) => void };
} ) => ( {
toBlob: () => {
element?.props?.onRender?.( layout );
return Promise.resolve(
new Blob( [ 'mock-pdf' ], { type: 'application/pdf' } )
);
},
} );
}

describe( 'PDFExportOrchestrator', () => {
const ADMIN_URL = 'http://example.com/wp-admin/';
let registry: ReturnType< typeof createTestRegistry >;
Expand Down Expand Up @@ -109,6 +137,7 @@ describe( 'PDFExportOrchestrator', () => {
// call that the test still needs to check.
( pdf as jest.Mock ).mockClear();
jest.mocked( registerPDFFonts ).mockClear();
jest.mocked( triggerDownload ).mockClear();
mockTrackEvent.mockClear();

// Put the real `AbortController` back after a test replaced it with the
Expand Down Expand Up @@ -296,7 +325,130 @@ describe( 'PDFExportOrchestrator', () => {
expect( dates.compareStartDate ).toBeDefined();
expect( signal ).toBeInstanceOf( AbortSignal );

// A measurement pass and a final pass.
expect( pdf ).toHaveBeenCalledTimes( 2 );
} );

it( 'sizes the final page to the measured content height plus the bottom padding', async () => {
const getData: jest.Mock = jest.fn( () =>
Promise.resolve( { data: { totalUsers: 100 } } )
);
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
registry.dispatch( CORE_PDF ).setSelection( {
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
widgetSlugs: [ 'trafficWidget' ],
} );

renderOrchestrator();

await waitFor( () => {
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' );
} );

expect( pdf ).toHaveBeenCalledTimes( 2 );

const measurementPass = ( pdf as jest.Mock ).mock.calls[ 0 ][ 0 ];
expect( measurementPass.props.pageHeight ).toBe(
PDF_MEASURE_PAGE_HEIGHT
);
expect( measurementPass.props.onRender ).toEqual(
expect.any( Function )
);

const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ];
expect( finalPass.props.pageHeight ).toBe(
MOCKED_MEASURED_HEIGHT + PDF_PAGE_BOTTOM_PADDING
);
expect( finalPass.props.onRender ).toBeUndefined();
} );

it( 'passes the section anchors extracted from the measurement pass to the final pass', async () => {
const getData: jest.Mock = jest.fn( () =>
Promise.resolve( { data: { totalUsers: 100 } } )
);
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
registry.dispatch( CORE_PDF ).setSelection( {
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
widgetSlugs: [ 'trafficWidget' ],
} );

renderOrchestrator();

await waitFor( () => {
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' );
} );

const measurementPass = ( pdf as jest.Mock ).mock.calls[ 0 ][ 0 ];
expect( measurementPass.props.sectionAnchors ).toBeUndefined();

// The section node in the mock layout sits at 200 + 24 = 224.
const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ];
expect( finalPass.props.sectionAnchors ).toEqual( [
{ id: 'section-mockArea', top: 224 },
] );
} );

it( 'caps the final page height at the measurement page height', async () => {
( pdf as jest.Mock ).mockImplementationOnce(
pdfImplementationWithLayout( {
_INTERNAL__LAYOUT__DATA_: {
children: [
{
children: [
{
box: {
top: 0,
height: PDF_MEASURE_PAGE_HEIGHT,
},
},
],
},
],
},
} )
);

const getData: jest.Mock = jest.fn( () =>
Promise.resolve( { data: { totalUsers: 100 } } )
);
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
registry.dispatch( CORE_PDF ).setSelection( {
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
widgetSlugs: [ 'trafficWidget' ],
} );

renderOrchestrator();

await waitFor( () => {
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' );
} );

const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ];
expect( finalPass.props.pageHeight ).toBe( PDF_MEASURE_PAGE_HEIGHT );
} );

it( 'transitions to error and skips the final pass when the layout measurement fails', async () => {
( pdf as jest.Mock ).mockImplementationOnce(
pdfImplementationWithLayout( { unexpected: 'shape' } )
);

const getData: jest.Mock = jest.fn( () =>
Promise.resolve( { data: { totalUsers: 100 } } )
);
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
registry.dispatch( CORE_PDF ).setSelection( {
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
widgetSlugs: [ 'trafficWidget' ],
} );

renderOrchestrator();

await waitFor( () => {
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'error' );
} );

expect( pdf ).toHaveBeenCalledTimes( 1 );
expect( triggerDownload ).not.toHaveBeenCalled();
} );

it( 'should transition to error and not build a PDF when the only widget fails', async () => {
Expand Down Expand Up @@ -340,7 +492,7 @@ describe( 'PDFExportOrchestrator', () => {

expect( failing ).toHaveBeenCalledTimes( 1 );
expect( succeeding ).toHaveBeenCalledTimes( 1 );
expect( pdf ).toHaveBeenCalledTimes( 1 );
expect( pdf ).toHaveBeenCalledTimes( 2 );
} );

it( 'includes only the checked widget when the user unchecks the other widget in the same section', async () => {
Expand Down
91 changes: 76 additions & 15 deletions assets/js/components/pdf-export/PDFExportOrchestrator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,20 @@ import useViewContext from '@/js/hooks/useViewContext';
import useViewOnly from '@/js/hooks/useViewOnly';
import { getPreviousDate, trackEvent } from '@/js/util';
import { ORDERED_MAIN_DASHBOARD_CONTEXTS } from './constants';
import extractPDFSectionAnchors from './extract-pdf-section-anchors';
import measurePDFContentHeight from './measure-pdf-content-height';
import { registerPDFFonts } from './pdf-fonts-react';
import { PDF_MEASURE_PAGE_HEIGHT, PDF_PAGE_BOTTOM_PADDING } from './pdf-theme';
import { getPDFFilename, triggerDownload } from './pdf-utils';
import { WidgetWithPDF, isActivePDFWidget } from './pdf-widget-eligibility';
import { SECTION_ICONS } from './section-icons';
import DashboardReport from './shared-react-pdf-components/DashboardReport';
import { PDFHeaderSection, PDFReportArea, PDFReportWidget } from './types';
import {
PDFHeaderSection,
PDFReportArea,
PDFReportWidget,
PDFSectionAnchor,
} from './types';

const STAGE_IDLE = 'IDLE' as const;
const STAGE_LOADING = 'LOADING' as const;
Expand All @@ -80,7 +88,7 @@ const VALID_TRANSITIONS: Record< Stage, readonly Stage[] > = {
};

const LOADING_TIMEOUT_MS = 45 * 1000;
const BUILDING_TIMEOUT_MS = 15 * 1000;
const BUILDING_TIMEOUT_MS = 30 * 1000;
const COMPLETE_UNMOUNT_DELAY_MS = 2 * 1000;
const BLOB_REVOKE_DELAY_MS = 30 * 1000;
// Progress budget reserved for the data-loading stage; BUILDING fills the rest.
Expand Down Expand Up @@ -570,24 +578,77 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( {
resolvedDateRange
);

const document = (
const reportProps = {
siteName: reportSiteName,
siteURL: referenceSiteURL || '',
dashboardURL: dashboardURL || '',
dateRange: {
startDate: dates.startDate,
endDate: dates.endDate,
},
sections,
helpCenterURL:
'https://sitekit.withgoogle.com/support/?doc=get-support',
privacyPolicyURL: 'https://policies.google.com/privacy',
areas,
emailReportingSetupURL,
};

/*
* The report renders twice: a discarded measurement pass
* captures the content height and the sections' absolute
* positions via `onRender`, then the final pass renders the
* page bounded to the measured height, with the header chips'
* anchor targets pinned at those positions.
*/
let measuredHeight = 0;
let sectionAnchors: PDFSectionAnchor[] = [];
// `@react-pdf` runs `onRender` inside its own render
// pipeline, so an error thrown there may never leave
// `toBlob()`. Capture it and rethrow it here instead.
let measureError: unknown = null;

await pdf(
<DashboardReport
siteName={ reportSiteName }
siteURL={ referenceSiteURL || '' }
dashboardURL={ dashboardURL || '' }
dateRange={ {
startDate: dates.startDate,
endDate: dates.endDate,
{ ...reportProps }
pageHeight={ PDF_MEASURE_PAGE_HEIGHT }
onRender={ ( layout ) => {
try {
measuredHeight =
measurePDFContentHeight( layout );
sectionAnchors =
extractPDFSectionAnchors( layout );
} catch ( error ) {
measureError = error;
}
} }
sections={ sections }
helpCenterURL="https://sitekit.withgoogle.com/support/?doc=get-support"
privacyPolicyURL="https://policies.google.com/privacy"
areas={ areas }
emailReportingSetupURL={ emailReportingSetupURL }
/>
).toBlob();
Comment thread
zutigrm marked this conversation as resolved.

throwIfAborted( signal );

if ( measureError ) {
throw measureError;
}

if ( measuredHeight <= 0 ) {
throw new Error(
'The PDF measurement pass produced no layout.'
);
}

const finalPageHeight = Math.min(
measuredHeight + PDF_PAGE_BOTTOM_PADDING,
PDF_MEASURE_PAGE_HEIGHT
);

const blob = await pdf( document ).toBlob();
const blob = await pdf(
<DashboardReport
{ ...reportProps }
pageHeight={ finalPageHeight }
sectionAnchors={ sectionAnchors }
/>
).toBlob();

throwIfAborted( signal );

Expand Down
Loading
Loading