web-component-tester
Version:
web-component-tester makes testing your web components a breeze!
376 lines (320 loc) • 13.5 kB
text/typescript
/**
* @license
* Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
import * as bowerConfig from 'bower-config';
import * as cleankill from 'cleankill';
import * as express from 'express';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as path from 'path';
import {MainlineServer, PolyserveServer, RequestHandler, ServerOptions, startServers, VariantServer} from 'polyserve';
import * as resolve from 'resolve';
import * as semver from 'semver';
import * as send from 'send';
import * as serverDestroy from 'server-destroy';
import {getPackageName} from './config';
import {Context} from './context';
// Template for generated indexes.
const INDEX_TEMPLATE = _.template(fs.readFileSync(
path.resolve(__dirname, '../data/index.html'), {encoding: 'utf-8'}));
const DEFAULT_HEADERS = {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
};
function relativeFrom(fromPath: string, toPath: string): string {
return path.relative(fromPath, toPath).replace(/\\/g, '/');
}
function resolveFrom(fromPath: string, moduleId: string): string {
try {
return resolve.sync(moduleId, {basedir: fromPath, preserveSymlinks: true});
} catch (e) {
return '';
}
}
/**
* The webserver module is a quasi-plugin. This ensures that it is hooked in a
* sane way (for other plugins), and just follows the same flow.
*
* It provides a static HTTP server for serving the desired tests and WCT's
* `browser.js`/`environment.js`.
*/
export function webserver(wct: Context): void {
const options = wct.options;
wct.hook('configure', async function() {
// For now, you should treat all these options as an implementation detail
// of WCT. They may be opened up for public configuration, but we need to
// spend some time rationalizing interactions with external webservers.
options.webserver = _.merge(options.webserver, {});
if (options.verbose) {
options.clientOptions.verbose = true;
}
// Hacky workaround for Firefox + Windows issue where FF screws up pathing.
// Bug: https://github.com/Polymer/web-component-tester/issues/194
options.suites = options.suites.map((cv) => cv.replace(/\\/g, '/'));
// The generated index needs the correct "browser.js" script. When using
// npm, the wct-browser-legacy package may be used, so we test for that
// package and will use its "browser.js" if present.
let browserScript = 'web-component-tester/browser.js';
const scripts: string[] = [], extraScripts: string[] = [];
const modules: string[] = [], extraModules: string[] = [];
if (options.npm) {
options.clientOptions = options.clientOptions || {};
options.clientOptions.environmentScripts =
options.clientOptions.environmentScripts || [];
browserScript = '';
const fromPath = path.resolve(options.root || process.cwd());
options.wctPackageName = options.wctPackageName ||
['wct-mocha', 'wct-browser-legacy', 'web-component-tester'].find(
(p) => !!resolveFrom(fromPath, p));
const npmPackageRootPath = path.dirname(
resolveFrom(fromPath, options.wctPackageName + '/package.json'));
if (npmPackageRootPath) {
const wctPackageScriptName =
['web-component-tester', 'wct-browser-legacy'].includes(
options.wctPackageName) ?
'browser.js' :
`${options.wctPackageName}.js`;
browserScript = `${npmPackageRootPath}/${wctPackageScriptName}`.slice(
npmPackageRootPath.length - options.wctPackageName.length);
}
const packageName = getPackageName(options);
const isPackageScoped = packageName && packageName[0] === '@';
const rootNodeModules =
path.resolve(path.join(options.root, 'node_modules'));
// WCT used to try to bundle a lot of packages for end-users, but
// because of `node_modules` layout, these need to actually be resolved
// from the package as installed, to ensure the desired version is
// loaded. Here we list the legacy packages and attempt to resolve them
// from the WCT package.
if (['web-component-tester', 'wct-browser-legacy'].includes(
options.wctPackageName)) {
const legacyNpmSupportPackageScripts: string[] = [
'stacky/browser.js',
'async/lib/async.js',
'lodash/index.js',
'mocha/mocha.js',
'chai/chai.js',
'@polymer/sinonjs/sinon.js',
'sinon-chai/lib/sinon-chai.js',
'accessibility-developer-tools/dist/js/axs_testing.js',
'@polymer/test-fixture/test-fixture.js',
];
const resolvedLegacyNpmSupportPackageScripts: string[] =
legacyNpmSupportPackageScripts
.map((script) => resolveFrom(npmPackageRootPath, script))
.filter((script) => script !== '');
options.clientOptions.environmentScripts.push(
...resolvedLegacyNpmSupportPackageScripts.map(
(script) => relativeFrom(rootNodeModules, script)));
} else {
// We need to load Mocha in the generated index.
const resolvedMochaScript =
resolveFrom(npmPackageRootPath, 'mocha/mocha.js');
if (resolvedMochaScript) {
options.clientOptions.environmentScripts.push(
relativeFrom(rootNodeModules, resolvedMochaScript));
}
}
if (browserScript && isPackageScoped) {
browserScript = `../${browserScript}`;
}
}
if (browserScript) {
scripts.push(`../${browserScript}`);
}
if (!options.npm) {
scripts.push('web-component-tester/data/a11ysuite.js');
}
options.webserver._generatedIndexContent =
INDEX_TEMPLATE({scripts, extraScripts: [], modules, ...options});
});
wct.hook('prepare', async function() {
const wsOptions = options.webserver;
const additionalRoutes = new Map<string, RequestHandler>();
const packageName = getPackageName(options);
let componentDir;
// Check for client-side compatibility.
// Non-npm case.
if (!options.npm) {
componentDir = bowerConfig.read(options.root).directory;
const pathToLocalWct =
path.join(options.root, componentDir, 'web-component-tester');
let version: string|undefined = undefined;
const mdFilenames = ['package.json', 'bower.json', '.bower.json'];
for (const mdFilename of mdFilenames) {
const pathToMetadata = path.join(pathToLocalWct, mdFilename);
try {
if (!version) {
version = require(pathToMetadata).version;
}
} catch (e) {
// Handled below, where we check if we found a version.
}
}
if (!version) {
throw new Error(`
The web-component-tester Bower package is not installed as a dependency of this project (${
packageName}).
Please run this command to install:
bower install --save-dev web-component-tester
Web Component Tester >=6.0 requires that support files needed in the browser are installed as part of the project's dependencies or dev-dependencies. This is to give projects greater control over the versions that are served, while also making Web Component Tester's behavior easier to understand.
Expected to find a ${mdFilenames.join(' or ')} at: ${pathToLocalWct}/
`);
}
const allowedRange =
require(path.join(
__dirname,
'..',
'package.json'))['--private-wct--']['client-side-version-range'] as
string;
if (!semver.satisfies(version, allowedRange)) {
throw new Error(`
The web-component-tester Bower package installed is incompatible with the
wct node package you're using.
The test runner expects a version that satisfies ${allowedRange} but the
bower package you have installed is ${version}.
`);
}
let hasWarnedBrowserJs = false;
additionalRoutes.set('/browser.js', function(request, response) {
if (!hasWarnedBrowserJs) {
console.warn(`
WARNING:
Loading WCT's browser.js from /browser.js is deprecated.
Instead load it from ../web-component-tester/browser.js
(or with the absolute url /components/web-component-tester/browser.js)
`);
hasWarnedBrowserJs = true;
}
const browserJsPath = path.join(pathToLocalWct, 'browser.js');
send(request, browserJsPath).pipe(response);
});
}
const pathToGeneratedIndex =
`/components/${packageName}/generated-index.html`;
additionalRoutes.set(pathToGeneratedIndex, (_request, response) => {
response.set(DEFAULT_HEADERS);
response.send(options.webserver._generatedIndexContent);
});
const appMapper = async (app: express.Express, options: ServerOptions) => {
// Using the define:webserver hook to provide a mapper function that
// allows user to substitute their own app for the generated polyserve
// app.
await wct.emitHook(
'define:webserver', app, (substitution: express.Express) => {
app = substitution;
}, options);
return app;
};
// Serve up project & dependencies via polyserve
const polyserveResult = await startServers(
{
root: options.root,
componentDir,
compile: options.compile,
hostname: options.webserver.hostname,
port: options.webserver.port,
headers: DEFAULT_HEADERS,
packageName,
additionalRoutes,
npm: !!options.npm,
moduleResolution: options.moduleResolution,
proxy: options.proxy,
},
appMapper);
let servers: Array<MainlineServer|VariantServer>;
const onDestroyHandlers: Array<() => Promise<void>> = [];
const registerServerTeardown = (serverInfo: PolyserveServer) => {
const destroyableServer = serverInfo.server as any;
serverDestroy(destroyableServer);
onDestroyHandlers.push(() => {
destroyableServer.destroy();
return new Promise<void>(
(resolve) => serverInfo.server.on('close', () => resolve()));
});
};
if (polyserveResult.kind === 'mainline') {
servers = [polyserveResult];
registerServerTeardown(polyserveResult);
const address = polyserveResult.server.address();
if (typeof address !== 'string') {
wsOptions.port = address.port;
}
} else if (polyserveResult.kind === 'MultipleServers') {
servers = [polyserveResult.mainline];
servers = servers.concat(polyserveResult.variants);
const address = polyserveResult.mainline.server.address();
if (typeof address !== 'string') {
wsOptions.port = address.port;
}
for (const server of polyserveResult.servers) {
registerServerTeardown(server);
}
} else {
const never: never = polyserveResult;
throw new Error(
'Internal error: Got unknown response from polyserve.startServers: ' +
`${never}`);
}
wct._httpServers = servers.map((s) => s.server);
// At this point, we allow other plugins to hook and configure the
// webservers as they please.
for (const server of servers) {
await wct.emitHook('prepare:webserver', server.app);
}
options.webserver._servers = servers.map((s) => {
const address = s.server.address();
const port = typeof address === 'string' ? '' : `:${address.port}`;
const hostname = s.options.hostname;
const url = `http://${hostname}${port}${pathToGeneratedIndex}`;
return {url, variant: s.kind === 'mainline' ? '' : s.variantName};
});
// TODO(rictic): re-enable this stuff. need to either move this code
// into polyserve or let the polyserve API expose this stuff.
// app.use('/httpbin', httpbin.httpbin);
// app.get('/favicon.ico', function(request, response) {
// response.end();
// });
// app.use(function(request, response, next) {
// wct.emit('log:warn', '404', chalk.magenta(request.method),
// request.url);
// next();
// });
async function interruptHandler() {
// close the socket IO server directly if it is spun up
for (const io of (wct._socketIOServers || [])) {
// we will close the underlying server ourselves
(<any>io).httpServer = null;
io.close();
}
await Promise.all(onDestroyHandlers.map((f) => f()));
}
cleankill.onInterrupt(() => {
return new Promise((resolve) => {
interruptHandler().then(() => resolve(), resolve);
});
});
});
}
function exists(path: string): boolean {
try {
fs.statSync(path);
return true;
} catch (_err) {
return false;
}
}
// HACK(rictic): remove this ES6-compat hack and export webserver itself
webserver['webserver'] = webserver;
module.exports = webserver;