lighthouse/viewer/test/viewer-test-pptr.js

500 строки
17 KiB
JavaScript

/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs';
import assert from 'assert/strict';
import puppeteer from 'puppeteer';
import {expect} from 'expect';
import {getChromePath} from 'chrome-launcher';
import {Server} from '../../cli/test/fixtures/static-server.js';
import defaultConfig from '../../core/config/default-config.js';
import {LH_ROOT} from '../../shared/root.js';
import {getCanonicalLocales} from '../../shared/localization/format.js';
import {getProtoRoundTrip} from '../../core/test/test-utils.js';
const {itIfProtoExists} = getProtoRoundTrip();
const portNumber = 10200;
const viewerUrl = `http://localhost:${portNumber}/dist/gh-pages/viewer/index.html`;
const sampleLhr = LH_ROOT + '/core/test/results/sample_v2.json';
// eslint-disable-next-line max-len
const sampleFlowResult = LH_ROOT + '/core/test/fixtures/user-flows/reports/sample-flow-result.json';
const lighthouseCategories = Object.keys(defaultConfig.categories);
const getAuditsOfCategory = category => defaultConfig.categories[category].auditRefs;
// TODO: should be combined in some way with clients/test/extension/extension-test.js
describe('Lighthouse Viewer', () => {
// eslint-disable-next-line no-console
console.log('\n✨ Be sure to have recently run this: yarn build-viewer');
/** @type {import('puppeteer').Browser} */
let browser;
/** @type {import('puppeteer').Page} */
let viewerPage;
let pageErrors = [];
const selectors = {
audits: '.lh-audit, .lh-metric',
titles: '.lh-audit__title, .lh-metric__title',
};
function getAuditElementsIds({category, selector}) {
return viewerPage.evaluate(
({category, selector}) => {
const elems = document.querySelector(`#${category}`).parentNode.querySelectorAll(selector);
return Array.from(elems).map(el => el.id);
}, {category, selector}
);
}
function getCategoryElementsIds() {
return viewerPage.evaluate(
() => {
return Array.from(document.querySelectorAll(`.lh-category`)).map(el => el.id);
});
}
let server;
before(async () => {
server = new Server(portNumber);
await server.listen(portNumber, 'localhost');
// start puppeteer
browser = await puppeteer.launch({
executablePath: getChromePath(),
});
viewerPage = await browser.newPage();
viewerPage.on('pageerror', e => pageErrors.push(`${e.message} ${e.stack}`));
viewerPage.on('console', (e) => {
if (e.type() === 'error' || e.type() === 'warning') {
// TODO gotta upgrade our own stuff.
if (e.text().includes('Please adopt the new report API')) return;
// Rendering a report from localhost page will attempt to display unreachable resources.
if (e.location().url.includes('lighthouse-480x318.jpg')) return;
const describe = (jsHandle) => {
return jsHandle.executionContext().evaluate((obj) => {
return JSON.stringify(obj, null, 2);
}, jsHandle);
};
const promise = Promise.all(e.args().map(describe)).then(args => {
return `${e.text()} ${args.join(' ')} ${JSON.stringify(e.location(), null, 2)}`;
});
pageErrors.push(promise);
}
});
});
after(async function() {
await Promise.all([
server.close(),
browser && browser.close(),
]);
});
async function claimErrors() {
const theErrors = pageErrors;
pageErrors = [];
return await Promise.all(theErrors);
}
async function ensureNoErrors() {
await viewerPage.bringToFront();
await viewerPage.evaluate(() => new Promise(window.requestAnimationFrame));
const errors = await claimErrors();
if (errors.length) {
assert.fail('errors from page:\n\n' + errors.map(e => e.toString()).join('\n\n'));
}
}
afterEach(async function() {
// Tests should call this themselves so the failure is associated with them in the test report,
// but just in case one is missed it won't hurt to repeat the check here.
await ensureNoErrors();
});
describe('Renders the flow report', () => {
before(async () => {
await viewerPage.goto(viewerUrl, {waitUntil: 'networkidle2', timeout: 30000});
const fileInput = await viewerPage.$('#hidden-file-input');
await fileInput.uploadFile(sampleFlowResult);
await viewerPage.waitForSelector('.App', {timeout: 30000});
});
it('should load with no errors', async () => {
await ensureNoErrors();
});
it('renders the summary page', async () => {
const summary = await viewerPage.evaluate(() => document.querySelector('.Summary'));
assert.ok(summary);
const scores = await viewerPage.evaluate(() =>
Array.from(document.querySelectorAll('.lh-gauge__wrapper, .lh-fraction__wrapper'))
);
assert.equal(scores.length, 14);
await ensureNoErrors();
});
});
describe('Renders the report', () => {
before(async () => {
await viewerPage.goto(viewerUrl, {waitUntil: 'networkidle2', timeout: 30000});
const fileInput = await viewerPage.$('#hidden-file-input');
await fileInput.uploadFile(sampleLhr);
await viewerPage.waitForSelector('.lh-categories', {timeout: 30000});
});
it('should load with no errors', async () => {
await ensureNoErrors();
});
it('should contain all categories', async () => {
const categories = await getCategoryElementsIds();
assert.deepStrictEqual(
categories.sort(),
lighthouseCategories.sort(),
`all categories not found`
);
});
it('should contain audits of all categories', async () => {
const nonNavigationAudits = [
'interaction-to-next-paint',
'uses-responsive-images-snapshot',
'work-during-interaction',
];
for (const category of lighthouseCategories) {
const expectedAuditIds = getAuditsOfCategory(category)
.filter(a => a.group !== 'hidden' && !nonNavigationAudits.includes(a.id))
.map(a => a.id);
const elementIds = await getAuditElementsIds({category, selector: selectors.audits});
assert.deepStrictEqual(
elementIds.sort(),
expectedAuditIds.sort(),
`${category} does not have the identical audits`
);
}
});
it('should contain a filmstrip', async () => {
const filmstrip = await viewerPage.$('.lh-filmstrip');
assert.ok(!!filmstrip, `filmstrip is not available`);
});
it('should not have any unexpected audit errors', async () => {
function getErrors(elems, selectors) {
return elems.map(el => {
const audit = el.closest(selectors.audits);
const auditTitle = audit && audit.querySelector(selectors.titles);
return {
explanation: el.textContent,
title: auditTitle ? auditTitle.textContent : 'Audit title unvailable',
};
});
}
const errorSelectors = '.lh-audit-explanation, .lh-tooltip--error';
const auditErrors = await viewerPage.$$eval(errorSelectors, getErrors, selectors);
const errors = auditErrors.filter(item => item.explanation.includes('Audit error:'));
assert.deepStrictEqual(errors, [], 'Audit errors found within the report');
});
it('should support swapping locales', async () => {
async function queryLocaleState() {
await viewerPage.waitForSelector('.lh-locale-selector');
return viewerPage.$$eval('.lh-locale-selector', (elems) => {
const selectEl = elems[0];
const optionEls = [...selectEl.querySelectorAll('option')];
return {
selectedValue: selectEl.value,
options: optionEls.map(el => {
return el.value;
}),
sampleString: document.querySelector('.lh-report-icon--copy').textContent,
};
});
}
const resultBeforeSwap = await queryLocaleState();
expect(resultBeforeSwap.selectedValue).toBe('en-US');
expect(resultBeforeSwap.options).toEqual(getCanonicalLocales());
expect(resultBeforeSwap.sampleString).toBe('Copy JSON');
await viewerPage.select('.lh-locale-selector', 'es');
await viewerPage.waitForFunction(() => {
return document.querySelector('.lh-report-icon--copy').textContent === 'Copiar JSON';
});
const resultAfterSwap = await queryLocaleState();
expect(resultAfterSwap.selectedValue).toBe('es');
expect(resultAfterSwap.sampleString).toBe('Copiar JSON');
});
it('should support saving as html', async () => {
const tmpDir = `${LH_ROOT}/.tmp/pptr-downloads`;
fs.rmSync(tmpDir, {force: true, recursive: true});
const session = await viewerPage.target().createCDPSession();
await session.send('Browser.setDownloadBehavior', {
behavior: 'allow',
downloadPath: tmpDir,
eventsEnabled: true,
});
await viewerPage.click('.lh-tools__button');
await viewerPage.waitForFunction(() => {
return getComputedStyle(
document.querySelector('.lh-tools__dropdown')).visibility === 'visible';
});
// For some reason, clicking this button doesn't always initiate the download after upgrading to Puppeteer 16.
// As a workaround, we send another click signal 1s after the first to make sure the download starts.
// TODO: Find a more robust fix for this issue.
const timeoutHandle = setTimeout(() => viewerPage.click('a[data-action="save-html"]'), 1000);
const [, filename] = await Promise.all([
viewerPage.click('a[data-action="save-html"]'),
new Promise(resolve => {
session.on('Browser.downloadWillBegin', ({suggestedFilename}) => {
resolve(suggestedFilename);
});
}),
new Promise(resolve => {
session.on('Browser.downloadProgress', ({state}) => {
if (state === 'completed') resolve();
});
}),
]);
clearTimeout(timeoutHandle);
const savedPage = await browser.newPage();
const savedPageErrors = [];
savedPage.on('pageerror', e => savedPageErrors.push(e));
const firstLogPromise =
new Promise(resolve => savedPage.once('console', e => resolve(e.text())));
await savedPage.goto(`file://${tmpDir}/${filename}`);
expect(await firstLogPromise).toEqual('window.__LIGHTHOUSE_JSON__ JSHandle@object');
if (savedPageErrors.length) {
assert.fail('errors from page:\n\n' + savedPageErrors.map(e => e.toString()).join('\n\n'));
}
});
});
async function verifyLhrLoadsWithNoErrors(lhrFilePath) {
await viewerPage.goto(viewerUrl, {waitUntil: 'networkidle2', timeout: 30000});
const fileInput = await viewerPage.$('#hidden-file-input');
const waitForAck = viewerPage.evaluate(() =>
new Promise(resolve =>
document.addEventListener('lh-file-upload-test-ack', resolve, {once: true})));
await fileInput.uploadFile(lhrFilePath);
await Promise.race([
waitForAck,
new Promise((resolve, reject) => setTimeout(reject, 5_000)),
]);
// Give async work some time to happen (ex: SwapLocaleFeature.enable).
await new Promise(resolve => setTimeout(resolve, 3_000));
await ensureNoErrors();
const content = await viewerPage.$eval('main', el => el.textContent);
for (const line of content.split('\n')) {
expect(line).not.toContain('undefined');
}
}
describe('Renders old reports', () => {
[
'lhr-3.0.0.json',
'lhr-4.3.0.json',
'lhr-5.0.0.json',
'lhr-6.0.0.json',
'lhr-8.5.0.json',
'lhr-9.6.8.json',
'lhr-10.4.0.json',
'lhr-11.7.0.json',
].forEach((testFilename) => {
it(`[${testFilename}] should load with no errors`, async () => {
await verifyLhrLoadsWithNoErrors(`${LH_ROOT}/report/test-assets/${testFilename}`);
});
});
});
describe('PSI', () => {
itIfProtoExists('Renders proto roundtrip report', async () => {
await verifyLhrLoadsWithNoErrors(`${LH_ROOT}/.tmp/sample_v2_round_trip.json`);
});
/** @type {Partial<puppeteer.ResponseForRequest>} */
let interceptedRequest;
/** @type {Partial<puppeteer.ResponseForRequest>} */
let psiResponse;
const sampleLhrJson = JSON.parse(fs.readFileSync(sampleLhr, 'utf-8'));
/** @type {Partial<puppeteer.ResponseForRequest>} */
const goodPsiResponse = {
status: 200,
contentType: 'application/json',
body: JSON.stringify({lighthouseResult: sampleLhrJson}),
headers: {
'Access-Control-Allow-Origin': '*',
},
};
/** @type {Partial<puppeteer.ResponseForRequest>} */
const badPsiResponse = {
status: 500,
contentType: 'application/json',
body: JSON.stringify({error: {message: 'badPsiResponse error'}}),
headers: {
'Access-Control-Allow-Origin': '*',
},
};
/**
* Sniffs just the request made to the PSI API. All other requests
* fall through.
* To set the mocked PSI response, assign `psiResponse`.
* To read the intercepted request, use `interceptedRequest`.
* @param {import('puppeteer').HTTPRequest} request
*/
function onRequest(request) {
if (request.url().includes('https://www.googleapis.com')) {
interceptedRequest = request;
request.respond(psiResponse);
} else {
request.continue();
}
}
before(async () => {
await viewerPage.setRequestInterception(true);
viewerPage.on('request', onRequest);
});
after(async () => {
viewerPage.off('request', onRequest);
await viewerPage.setRequestInterception(false);
});
beforeEach(() => {
interceptedRequest = undefined;
psiResponse = undefined;
});
it('should call out to PSI with all categories by default', async () => {
psiResponse = goodPsiResponse;
const url = `${viewerUrl}?psiurl=https://www.example.com`;
await viewerPage.goto(url);
// Wait for report to render.
await viewerPage.waitForSelector('.lh-metrics-container', {timeout: 5000});
const interceptedUrl = new URL(interceptedRequest.url());
expect(interceptedUrl.origin + interceptedUrl.pathname)
.toEqual('https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
const params = {
key: interceptedUrl.searchParams.get('key'),
url: interceptedUrl.searchParams.get('url'),
category: interceptedUrl.searchParams.getAll('category'),
strategy: interceptedUrl.searchParams.get('strategy'),
locale: interceptedUrl.searchParams.get('locale'),
utm_source: interceptedUrl.searchParams.get('utm_source'),
};
expect(params).toEqual({
key: 'AIzaSyAjcDRNN9CX9dCazhqI4lGR7yyQbkd_oYE',
url: 'https://www.example.com',
// Order in the api call is important to PSI!
category: [
'performance',
'accessibility',
'seo',
'best-practices',
],
strategy: 'mobile',
// These values aren't set by default.
locale: null,
utm_source: null,
});
// Confirm that all default categories are used.
const defaultCategories = Object.keys(defaultConfig.categories).sort();
expect(interceptedUrl.searchParams.getAll('category').sort()).toEqual(defaultCategories);
// No errors.
await ensureNoErrors();
// All categories.
const categoryElementIds = await getCategoryElementsIds();
assert.deepStrictEqual(
categoryElementIds.sort(),
lighthouseCategories.sort(),
`all categories not found`
);
// Should not clear the query string.
expect(await viewerPage.url()).toEqual(url);
});
it('should call out to PSI with specified categories', async () => {
psiResponse = goodPsiResponse;
const url = `${viewerUrl}?psiurl=https://www.example.com&category=seo&category=accessibility&utm_source=utm&locale=es`;
await viewerPage.goto(url);
// Wait for report to render.call out to PSI with specified categories
await viewerPage.waitForSelector('.lh-metrics-container');
const interceptedUrl = new URL(interceptedRequest.url());
expect(interceptedUrl.origin + interceptedUrl.pathname)
.toEqual('https://www.googleapis.com/pagespeedonline/v5/runPagespeed');
const params = {
url: interceptedUrl.searchParams.get('url'),
category: interceptedUrl.searchParams.getAll('category'),
locale: interceptedUrl.searchParams.get('locale'),
utm_source: interceptedUrl.searchParams.get('utm_source'),
};
expect(params).toEqual({
url: 'https://www.example.com',
category: [
'seo',
'accessibility',
],
locale: 'es',
utm_source: 'utm',
});
// No errors.
await ensureNoErrors();
});
it('should handle errors from the API', async () => {
psiResponse = badPsiResponse;
const url = `${viewerUrl}?psiurl=https://www.example.com`;
await viewerPage.goto(url);
// Wait for error.
const errorEl = await viewerPage.waitForSelector('#lh-log.lh-show');
const errorMessage = await viewerPage.evaluate(errorEl => errorEl.textContent, errorEl);
expect(errorMessage).toBe('badPsiResponse error');
// Expected errors.
const errors = await claimErrors();
expect(errors).toHaveLength(2);
expect(errors[0]).toContain('500 (Internal Server Error)');
expect(errors[1]).toContain('badPsiResponse error');
});
});
});