Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

report(flow): embedded lighthouse report #12989

Merged
merged 13 commits into from
Sep 2, 2021
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
4 changes: 4 additions & 0 deletions flow-report/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
font-size: var(--app-font-size);
}

.Content {
overflow-y: auto;
}

.FlowStepIcon {
height: 100%;
display: flex;
Expand Down
5 changes: 4 additions & 1 deletion flow-report/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
*/

module.exports = {
testEnvironment: 'jsdom',
testEnvironment: 'node',
preset: 'ts-jest',
globalSetup: './test/setup/global-setup.ts',
setupFilesAfterEnv: [
'./test/setup/env-setup.ts',
],
testMatch: [
'**/test/**/*-test.ts',
'**/test/**/*-test.tsx',
Expand Down
20 changes: 8 additions & 12 deletions flow-report/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,22 @@ import {ReportRendererProvider} from './wrappers/report-renderer';
import {Sidebar} from './sidebar/sidebar';
import {Summary} from './summary/summary';
import {FlowResultContext, useCurrentLhr} from './util';
import {Report} from './wrappers/report';

const Content: FunctionComponent = () => {
const currentLhr = useCurrentLhr();

const Report: FunctionComponent<{lhr: LH.Result}> = ({lhr}) => {
// TODO(FR-COMPAT): Render an actual report here.
return (
<div data-testid="Report">
<h1>{lhr.finalUrl}</h1>
<div className="Content">
{
Object.values(lhr.categories).map((category) =>
<h2 key={category.id}>{category.id}: {category.score}</h2>
)
currentLhr ?
<Report/> :
<Summary/>
}
</div>
);
};

const Content: FunctionComponent = () => {
const currentLhr = useCurrentLhr();
return currentLhr ? <Report lhr={currentLhr.value}/> : <Summary/>;
};

export const App: FunctionComponent<{flowResult: LH.FlowResult}> = ({flowResult}) => {
return (
<FlowResultContext.Provider value={flowResult}>
Expand Down
51 changes: 30 additions & 21 deletions flow-report/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import {createContext} from 'preact';
import {useContext, useEffect, useState} from 'preact/hooks';
import {useContext, useEffect, useMemo, useState} from 'preact/hooks';

export const FlowResultContext = createContext<LH.FlowResult|undefined>(undefined);

Expand Down Expand Up @@ -59,37 +59,46 @@ export function useLocale(): LH.Locale {
return flowResult.lhrs[0].configSettings.locale;
}

export function useCurrentLhr(): {value: LH.Result, index: number}|null {
const flowResult = useFlowResult();
const [indexString, setIndexString] = useState(getHashParam('index'));
export function useHashParam(param: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

const [paramValue, setParamValue] = useState(getHashParam(param));

// Use two-way-binding on the URL hash.
// Triggers a re-render if the LHR index changes.
// Triggers a re-render if the param changes.
useEffect(() => {
function hashListener() {
const newIndexString = getHashParam('index');
if (newIndexString === indexString) return;
setIndexString(newIndexString);
const newIndexString = getHashParam(param);
if (newIndexString === paramValue) return;
setParamValue(newIndexString);
}
window.addEventListener('hashchange', hashListener);
return () => window.removeEventListener('hashchange', hashListener);
}, [indexString]);
}, [paramValue]);

if (!indexString) return null;
return paramValue;
}

const index = Number(indexString);
if (!Number.isFinite(index)) {
console.warn(`Invalid hash index: ${indexString}`);
return null;
}
export function useCurrentLhr(): {value: LH.Result, index: number}|null {
const flowResult = useFlowResult();
const indexString = useHashParam('index');

const value = flowResult.lhrs[index];
if (!value) {
console.warn(`No LHR at index ${index}`);
return null;
}
// Memoize result so a new object is not created on every call.
return useMemo(() => {
if (!indexString) return null;

const index = Number(indexString);
if (!Number.isFinite(index)) {
console.warn(`Invalid hash index: ${indexString}`);
return null;
}

const value = flowResult.lhrs[index];
if (!value) {
console.warn(`No LHR at index ${index}`);
return null;
}

return {value, index};
return {value, index};
}, [indexString, flowResult]);
}

export function useDerivedStepNames() {
Expand Down
4 changes: 4 additions & 0 deletions flow-report/src/wrappers/report-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {useContext, useMemo} from 'preact/hooks';
import {CategoryRenderer} from '../../../report/renderer/category-renderer';
import {DetailsRenderer} from '../../../report/renderer/details-renderer';
import {DOM} from '../../../report/renderer/dom';
import {ReportRenderer} from '../../../report/renderer/report-renderer';

interface ReportRendererGlobals {
dom: DOM,
detailsRenderer: DetailsRenderer,
categoryRenderer: CategoryRenderer,
reportRenderer: ReportRenderer,
}

const ReportRendererContext = createContext<ReportRendererGlobals|undefined>(undefined);
Expand All @@ -29,10 +31,12 @@ export const ReportRendererProvider: FunctionComponent = ({children}) => {
const dom = new DOM(document);
const detailsRenderer = new DetailsRenderer(dom);
const categoryRenderer = new CategoryRenderer(dom, detailsRenderer);
const reportRenderer = new ReportRenderer(dom);
return {
dom,
detailsRenderer,
categoryRenderer,
reportRenderer,
};
}, []);
return (
Expand Down
70 changes: 70 additions & 0 deletions flow-report/src/wrappers/report.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

import {FunctionComponent} from 'preact';
import {useEffect, useLayoutEffect, useRef} from 'preact/hooks';
import {useCurrentLhr, useHashParam} from '../util';
import {useReportRenderer} from './report-renderer';

/**
* The default behavior of anchor links is not compatible with the flow report's hash navigation.
* This function converts any anchor links under the provided element to a flow report link.
* e.g. <a href="#link"> -> <a href="#index=0&anchor=link">
*/
export function convertChildAnchors(element: HTMLElement, index: number) {
const links = element.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>;
for (const link of links) {
// Check if the link destination is in the report.
const currentUrl = new URL(location.href);
currentUrl.hash = '';
currentUrl.search = '';
const linkUrl = new URL(link.href);
linkUrl.hash = '';
linkUrl.search = '';
if (currentUrl.href !== linkUrl.href || !link.hash) continue;

const nodeId = link.hash.substr(1);
link.hash = `#index=${index}&anchor=${nodeId}`;
}
}

export const Report: FunctionComponent = () => {
const {dom, reportRenderer} = useReportRenderer();
const ref = useRef<HTMLDivElement>(null);
const anchor = useHashParam('anchor');
const currentLhr = useCurrentLhr();

useLayoutEffect(() => {
if (!currentLhr) return;

if (ref.current) {
dom.clearComponentCache();
reportRenderer.renderReport(currentLhr.value, ref.current);
convertChildAnchors(ref.current, currentLhr.index);
}

return () => {
if (ref.current) ref.current.textContent = '';
adamraine marked this conversation as resolved.
Show resolved Hide resolved
};
}, [reportRenderer, currentLhr]);

useEffect(() => {
if (anchor) {
const el = document.getElementById(anchor);
if (el) {
el.scrollIntoView({behavior: 'smooth'});
return;
}
}

// Scroll to top no anchor is found.
if (ref.current) ref.current.scrollIntoView();
}, [anchor, currentLhr]);

return (
<div ref={ref} className="lh-root" data-testid="Report"/>
);
};
58 changes: 7 additions & 51 deletions flow-report/test/app-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,73 +18,29 @@ const flowResult = JSON.parse(
)
);

let mockLocation: URL;

beforeEach(() => {
mockLocation = new URL('file:///Users/example/report.html');
Object.defineProperty(window, 'location', {
get: () => mockLocation,
});
});

it('renders a standalone report with summary', async () => {
const root = render(<App flowResult={flowResult}/>);

await expect(root.findByTestId('Summary')).resolves.toBeTruthy();
expect(root.getByTestId('Summary')).toBeTruthy();
});

it('renders the navigation step', async () => {
mockLocation.hash = '#index=0';
global.location.hash = '#index=0';
const root = render(<App flowResult={flowResult}/>);

await expect(root.findByTestId('Report')).resolves.toBeTruthy();

const link = await root.findByText(/https:/);
expect(link.textContent).toEqual('https://www.mikescerealshack.co/');

const scores = await root.findAllByText(/^\S+: [0-9.]+/);
expect(scores.map(s => s.textContent)).toEqual([
'performance: 0.98',
'accessibility: 1',
'best-practices: 1',
'seo: 1',
'pwa: 0.3',
]);
expect(root.getByTestId('Report')).toBeTruthy();
});

it('renders the timespan step', async () => {
mockLocation.hash = '#index=1';
global.location.hash = '#index=1';
const root = render(<App flowResult={flowResult}/>);

await expect(root.findByTestId('Report')).resolves.toBeTruthy();

const link = await root.findByText(/https:/);
expect(link.textContent).toEqual('https://www.mikescerealshack.co/search?q=call+of+duty');

const scores = await root.findAllByText(/^\S+: [0-9.]+/);
expect(scores.map(s => s.textContent)).toEqual([
'performance: 1',
'best-practices: 0.71',
'seo: 0',
'pwa: 1',
]);
expect(root.getByTestId('Report')).toBeTruthy();
});

it('renders the snapshot step', async () => {
mockLocation.hash = '#index=2';
global.location.hash = '#index=2';
const root = render(<App flowResult={flowResult}/>);

await expect(root.findByTestId('Report')).resolves.toBeTruthy();

const link = await root.findByText(/https:/);
expect(link.textContent).toEqual('https://www.mikescerealshack.co/search?q=call+of+duty');

const scores = await root.findAllByText(/^\S+: [0-9.]+/);
expect(scores.map(s => s.textContent)).toEqual([
'performance: 0',
'accessibility: 0.9',
'best-practices: 0.88',
'seo: 0.86',
'pwa: 1',
]);
expect(root.getByTestId('Report')).toBeTruthy();
});
10 changes: 5 additions & 5 deletions flow-report/test/common-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('CategoryRatio', () => {
category={category}
href="index=0&achor=seo"
/>);
const link = await root.findByRole('link');
const link = root.getByRole('link');

expect(link.className).toEqual('CategoryRatio CategoryRatio--average');
expect(link.textContent).toEqual('2/3');
Expand All @@ -52,7 +52,7 @@ describe('CategoryRatio', () => {
category={category}
href="index=0&achor=seo"
/>);
const link = await root.findByRole('link');
const link = root.getByRole('link');

expect(link.className).toEqual('CategoryRatio CategoryRatio--fail');
expect(link.textContent).toEqual('0/3');
Expand All @@ -77,7 +77,7 @@ describe('CategoryRatio', () => {
category={category}
href="index=0&achor=seo"
/>);
const link = await root.findByRole('link');
const link = root.getByRole('link');

expect(link.className).toEqual('CategoryRatio CategoryRatio--pass');
expect(link.textContent).toEqual('3/3');
Expand All @@ -100,7 +100,7 @@ describe('CategoryRatio', () => {
category={category}
href="index=0&achor=seo"
/>);
const link = await root.findByRole('link');
const link = root.getByRole('link');

expect(link.className).toEqual('CategoryRatio CategoryRatio--null');
expect(link.textContent).toEqual('1/2');
Expand All @@ -122,7 +122,7 @@ describe('CategoryRatio', () => {
category={category}
href="index=0&achor=seo"
/>);
const link = await root.findByRole('link');
const link = root.getByRole('link');

expect(link.className).toEqual('CategoryRatio CategoryRatio--average');
expect(link.textContent).toEqual('1/2');
Expand Down
25 changes: 25 additions & 0 deletions flow-report/test/setup/env-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @license Copyright 2021 The Lighthouse Authors. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*/

import {jest} from '@jest/globals';
import {JSDOM} from 'jsdom';

/**
* The jest environment "jsdom" does not work when preact is combined with the report renderer.
*/
export function setupJsDom() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default jsdom test environment was not cooperating with the report renderer. These changes basically setup our own environment for testing with JSDOM.

The only problem I've encountered is that findBy* queries don't work, but we can just replace those with equivalent getBy* ones.

const {window} = new JSDOM(undefined, {
url: 'file:///Users/example/report.html/',
});
global.window = window as any;
global.document = window.document;
global.location = window.location;

// Function not implemented in JSDOM.
window.Element.prototype.scrollIntoView = jest.fn();
}

global.beforeEach(setupJsDom);
Loading