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

tests(static-server): allow hook to modify response body #9872

Merged
merged 10 commits into from
Feb 19, 2020
249 changes: 150 additions & 99 deletions lighthouse-cli/test/fixtures/static-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,136 +21,188 @@ const HEADER_SAFELIST = new Set(['x-robots-tag', 'link']);

const lhRootDirPath = path.join(__dirname, '../../../');

function requestHandler(request, response) {
const requestUrl = parseURL(request.url);
const filePath = requestUrl.pathname;
const queryString = requestUrl.search && parseQueryString(requestUrl.search.slice(1));
let absoluteFilePath = path.join(__dirname, filePath);

// Create an index page that lists the available test pages.
if (filePath === '/') {
const fixturePaths = glob.sync('**/*.html', {cwd: __dirname});
const html = `
<html>
<h1>Smoke test fixtures</h1>
${fixturePaths.map(p => `<a href=${encodeURI(p)}>${escape(p)}</a>`).join('<br>')}
`;
response.writeHead(200, {
'Content-Security-Policy': `default-src 'none';`,
});
sendResponse(200, html);
return;
class Server {
constructor() {
this._server = http.createServer(this._requestHandler.bind(this));
/** @type {(data: string) => string=} */
this._dataTransformer = undefined;
}

if (filePath.startsWith('/dist/viewer')) {
// Rewrite lighthouse-viewer paths to point to that location.
absoluteFilePath = path.join(__dirname, '/../../../', filePath);
getPort() {
return this._server.address().port;
}

// Disallow file requests outside of LH folder
const filePathDir = path.parse(absoluteFilePath).dir;
if (!filePathDir.startsWith(lhRootDirPath)) {
return readFileCallback(new Error('Disallowed path'));
/**
* @param {=number} port
* @param {=string} hostname
*/
listen(port, hostname) {
this._server.listen(port, hostname);
return new Promise((resolve, reject) => {
this._server.on('listening', resolve);
this._server.on('error', reject);
});
}

// Check if the file exists, then read it and serve it.
fs.exists(absoluteFilePath, fsExistsCallback);

function fsExistsCallback(fileExists) {
if (!fileExists) {
return sendResponse(404, `404 - File not found. ${filePath}`);
}
fs.readFile(absoluteFilePath, 'binary', readFileCallback);
close() {
return new Promise((resolve, reject) => {
this._server.close(err => {
if (err) return reject(err);
resolve();
});
});
}

function readFileCallback(err, file) {
if (err) {
console.error(`Unable to read local file ${absoluteFilePath}:`, err);
return sendResponse(500, '500 - Internal Server Error');
}
sendResponse(200, file);
/**
* @param {(data: string) => string=} fn
*/
setDataTransformer(fn) {
this._dataTransformer = fn;
}

function sendResponse(statusCode, data) {
const headers = {'Access-Control-Allow-Origin': '*'};

const contentType = mime.lookup(filePath);
const charset = mime.lookup(contentType);
// `mime.contentType` appends the correct charset too.
// Note: it seems to miss just one case, svg. Doesn't matter much, we'll just allow
// svgs to fallback to binary encoding. `Content-Type: image/svg+xml` is sufficient for our use case.
// see https://github.com/jshttp/mime-types/issues/66
if (contentType) headers['Content-Type'] = mime.contentType(contentType);

let delay = 0;
let useGzip = false;
if (queryString) {
const params = new URLSearchParams(queryString);
// set document status-code
if (params.has('status_code')) {
statusCode = parseInt(params.get('status_code'), 10);
}
_requestHandler(request, response) {
const requestUrl = parseURL(request.url);
const filePath = requestUrl.pathname;
const queryString = requestUrl.search && parseQueryString(requestUrl.search.slice(1));
let absoluteFilePath = path.join(__dirname, filePath);

const sendResponse = (statusCode, data) => {
// Used by Smokerider.
if (this._dataTransformer) data = this._dataTransformer(data);
patrickhulce marked this conversation as resolved.
Show resolved Hide resolved

const headers = {'Access-Control-Allow-Origin': '*'};

const contentType = mime.lookup(filePath);
const charset = mime.lookup(contentType);
// `mime.contentType` appends the correct charset too.
// Note: it seems to miss just one case, svg. Doesn't matter much, we'll just allow
// svgs to fallback to binary encoding. `Content-Type: image/svg+xml` is sufficient for our use case.
// see https://github.com/jshttp/mime-types/issues/66
if (contentType) headers['Content-Type'] = mime.contentType(contentType);

let delay = 0;
let useGzip = false;
if (queryString) {
const params = new URLSearchParams(queryString);
// set document status-code
if (params.has('status_code')) {
statusCode = parseInt(params.get('status_code'), 10);
}

// set delay of request when present
if (params.has('delay')) {
delay = parseInt(params.get('delay'), 10) || 2000;
}
// set delay of request when present
if (params.has('delay')) {
delay = parseInt(params.get('delay'), 10) || 2000;
}

if (params.has('extra_header')) {
const extraHeaders = new URLSearchParams(params.get('extra_header'));
for (const [headerName, headerValue] of extraHeaders) {
if (HEADER_SAFELIST.has(headerName.toLowerCase())) {
headers[headerName] = headers[headerName] || [];
headers[headerName].push(headerValue);
if (params.has('extra_header')) {
const extraHeaders = new URLSearchParams(params.get('extra_header'));
for (const [headerName, headerValue] of extraHeaders) {
if (HEADER_SAFELIST.has(headerName.toLowerCase())) {
headers[headerName] = headers[headerName] || [];
headers[headerName].push(headerValue);
}
}
}

if (params.has('gzip')) {
useGzip = Boolean(params.get('gzip'));
}

// redirect url to new url if present
if (params.has('redirect')) {
return setTimeout(sendRedirect, delay, params.get('redirect'));
}
}

if (params.has('gzip')) {
useGzip = Boolean(params.get('gzip'));
if (useGzip) {
data = zlib.gzipSync(data);
headers['Content-Encoding'] = 'gzip';

// Set special header for Lightrider, needed for Smokerider.
patrickhulce marked this conversation as resolved.
Show resolved Hide resolved
// In production LR, this header is set by the production fetcher. For smokerider,
// a different fetcher is used, so we must set this header here instead, to excercies
// the parts of the LH netcode that expects this header.
// This _should_ be the byte size of the entire response
// (encoded content, headers, chunk overhead, etc.) but - a rough estimate is OK
// because the smoke test byte expectations have some wiggle room.
headers['X-TotalFetchedSize'] = Buffer.byteLength(data) + JSON.stringify(headers).length;
}

// redirect url to new url if present
if (params.has('redirect')) {
return setTimeout(sendRedirect, delay, params.get('redirect'));
response.writeHead(statusCode, headers);

// Delay the response
if (delay > 0) {
return setTimeout(finishResponse, delay, data);
}

const encoding = charset === 'UTF-8' ? 'utf-8' : 'binary';
finishResponse(data, encoding);
};

// Create an index page that lists the available test pages.
if (filePath === '/') {
const fixturePaths = glob.sync('**/*.html', {cwd: __dirname});
const html = `
<html>
<h1>Smoke test fixtures</h1>
${fixturePaths.map(p => `<a href=${encodeURI(p)}>${escape(p)}</a>`).join('<br>')}
`;
response.writeHead(200, {
'Content-Security-Policy': `default-src 'none';`,
});
sendResponse(200, html);
return;
}

if (useGzip) {
data = zlib.gzipSync(data);
headers['Content-Encoding'] = 'gzip';
if (filePath.startsWith('/dist/viewer')) {
// Rewrite lighthouse-viewer paths to point to that location.
absoluteFilePath = path.join(__dirname, '/../../../', filePath);
}

response.writeHead(statusCode, headers);
// Disallow file requests outside of LH folder
const filePathDir = path.parse(absoluteFilePath).dir;
if (!filePathDir.startsWith(lhRootDirPath)) {
return readFileCallback(new Error('Disallowed path'));
}

// Delay the response
if (delay > 0) {
return setTimeout(finishResponse, delay, data);
// Check if the file exists, then read it and serve it.
fs.exists(absoluteFilePath, fsExistsCallback);

function fsExistsCallback(fileExists) {
if (!fileExists) {
return sendResponse(404, `404 - File not found. ${filePath}`);
}
fs.readFile(absoluteFilePath, 'binary', readFileCallback);
}

const encoding = charset === 'UTF-8' ? 'utf-8' : 'binary';
finishResponse(data, encoding);
}
function readFileCallback(err, file) {
if (err) {
console.error(`Unable to read local file ${absoluteFilePath}:`, err);
return sendResponse(500, '500 - Internal Server Error');
}
sendResponse(200, file);
}

function sendRedirect(url) {
const headers = {
Location: url,
};
response.writeHead(302, headers);
response.end();
}
function sendRedirect(url) {
const headers = {
Location: url,
};
response.writeHead(302, headers);
response.end();
}

function finishResponse(data, encoding) {
response.write(data, encoding);
response.end();
function finishResponse(data, encoding) {
response.write(data, encoding);
response.end();
}
}
}

const serverForOnline = http.createServer(requestHandler);
const serverForOffline = http.createServer(requestHandler);
const serverForOnline = new Server();
const serverForOffline = new Server();

serverForOnline.on('error', e => console.error(e.code, e));
serverForOffline.on('error', e => console.error(e.code, e));
serverForOnline._server.on('error', e => console.error(e.code, e));
serverForOffline._server.on('error', e => console.error(e.code, e));

// If called via `node static-server.js` then start listening, otherwise, just expose the servers
if (require.main === module) {
Expand All @@ -167,4 +219,3 @@ if (require.main === module) {
serverForOffline,
};
}

4 changes: 2 additions & 2 deletions lighthouse-cli/test/smokehouse/frontends/smokehouse-bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ async function begin() {
serverForOffline.listen(10503, 'localhost');
isPassing = await runSmokehouse(testDefns, options);
} finally {
await new Promise(resolve => server.close(resolve));
await new Promise(resolve => serverForOffline.close(resolve));
await server.close();
await serverForOffline.close();
}

const exitCode = isPassing ? 0 : 1;
Expand Down
43 changes: 43 additions & 0 deletions lighthouse-cli/test/static-server-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license Copyright 2020 Google Inc. 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.
*/
'use strict';

const fs = require('fs');
const fetch = require('isomorphic-fetch');
const {server} = require('./fixtures/static-server.js');

/* eslint-env jest */

describe('Server', () => {
beforeAll(async () => {
await server.listen(10200, 'localhost');
});

afterAll(async () => {
await server.close();
});

afterEach(() => {
server.setDataTransformer(undefined);
});

it('fetches fixture', async () => {
const res = await fetch(`http://localhost:${server.getPort()}/dobetterweb/dbw_tester.html`);
const data = await res.text();
const expected = fs.readFileSync(`${__dirname}/fixtures/dobetterweb/dbw_tester.html`, 'utf-8');
expect(data).toEqual(expected);
});

it('setDataTransformer', async () => {
server.setDataTransformer(() => {
return 'hello there';
});

const res = await fetch(`http://localhost:${server.getPort()}/dobetterweb/dbw_tester.html`);
const data = await res.text();
expect(data).toEqual('hello there');
});
});
12 changes: 3 additions & 9 deletions lighthouse-core/scripts/update-report-fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,14 @@ const artifactPath = 'lighthouse-core/test/results/artifacts';
const {server} = require('../../lighthouse-cli/test/fixtures/static-server.js');
const budgetedConfig = require('../test/results/sample-config.js');

/** @typedef {import('net').AddressInfo} AddressInfo */

/**
* Update the report artifacts. If artifactName is set only that artifact will be updated.
* @param {keyof LH.Artifacts=} artifactName
*/
async function update(artifactName) {
// get an available port
server.listen(0, 'localhost');
const port = await new Promise(res => server.on('listening', () => {
// Not a pipe or a domain socket, so will not be a string. See https://nodejs.org/api/net.html#net_server_address.
const address = /** @type {AddressInfo} */ (server.address());
res(address.port);
}));
await server.listen(0, 'localhost');
const port = server.getPort();

const oldArtifacts = assetSaver.loadArtifacts(artifactPath);

Expand All @@ -37,7 +31,7 @@ async function update(artifactName) {
].join(' ');
const flags = cliFlags.getFlags(rawFlags);
await cli.runLighthouse(url, flags, budgetedConfig);
await new Promise(res => server.close(res));
await server.close();

if (artifactName) {
// Revert everything except the one artifact
Expand Down