web-component-tester
Version:
web-component-tester makes testing your web components a breeze!
647 lines (592 loc) • 20.2 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 findup from 'findup-sync';
import * as fs from 'fs';
import * as _ from 'lodash';
import * as nomnom from 'nomnom';
import * as path from 'path';
import * as resolve from 'resolve';
import {Capabilities} from 'wd';
import {BrowserDef} from './browserrunner';
import {Context} from './context';
import * as paths from './paths';
import {Plugin} from './plugin';
const HOME_DIR = path.resolve(
process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE);
const JSON_MATCHER = 'wct.conf.json';
const CONFIG_MATCHER = 'wct.conf.*';
export type Browser = string|{browserName: string, platform: string};
export interface Config {
suites?: string[];
output?: NodeJS.WritableStream;
ttyOutput?: boolean;
verbose?: boolean;
quiet?: boolean;
expanded?: boolean;
root?: string;
testTimeout?: number;
persistent?: boolean;
extraScripts?: string[];
wctPackageName?: string;
clientOptions?:
{root?: string; verbose?: boolean; environmentScripts?: string[]};
activeBrowsers?: BrowserDef[];
browserOptions?: {[name: string]: Capabilities};
plugins?: (string|boolean)[]|{[key: string]: ({disabled: boolean} | boolean)};
registerHooks?: (wct: Context) => void;
enforceJsonConf?: boolean;
webserver?: {
// The port that the main webserver should run on. A port will be
// determined at runtime if none is provided.
port: number;
// The hostname used when generating URLs for the webdriver client.
hostname: string;
_generatedIndexContent?: string;
_servers?: {variant: string, url: string}[];
};
npm?: boolean;
moduleResolution?: 'none'|'node';
packageName?: string;
skipPlugins?: string[];
sauce?: {};
remote?: {};
origSuites?: string[];
compile?: 'auto'|'always'|'never';
skipCleanup?: boolean;
simpleOutput?: boolean;
skipUpdateCheck?: boolean;
configFile?: string;
proxy?: {
// Top-level path that should be redirected to the proxy-target. E.g.
// `api/v1` when you want to redirect all requests of
// `https://localhost/api/v1/`.
path: string;
// Host URL to proxy to, for example `https://myredirect:8080/foo`.
target: string;
};
/** A deprecated option */
browsers?: Browser[]|Browser;
}
/**
* config helper: A basic function to synchronously read JSON,
* log any errors, and return null if no file or invalid JSON
* was found.
*/
function readJsonSync(filename: string, dir?: string): any|null {
const configPath = path.resolve(dir || '', filename);
let config: any;
try {
config = fs.readFileSync(configPath, 'utf-8');
} catch (e) {
return null;
}
try {
return JSON.parse(config);
} catch (e) {
console.error(`Could not parse ${configPath} as JSON`);
console.error(e);
}
return null;
}
/**
* Determines the package name by reading from the following sources:
*
* 1. `options.packageName`
* 2. bower.json or package.json, depending on options.npm
*/
export function getPackageName(options: Config): string|undefined {
if (options.packageName) {
return options.packageName;
}
const manifestName = (options.npm ? 'package.json' : 'bower.json');
const manifest = readJsonSync(manifestName, options.root);
if (manifest !== null) {
return manifest.name;
}
const basename = path.basename(options.root);
console.warn(
`no ${manifestName} found, defaulting to packageName=${basename}`);
return basename;
}
/**
* Return the root package directory of the given NPM package.
*/
function resolvePackageDir(
packageName: string, opts?: resolve.SyncOpts): string {
// package.json files are always in the root directory of a package, so we can
// resolve that and then drop the filename.
return path.dirname(
resolve.sync(path.join(packageName, 'package.json'), opts));
}
// The full set of options, as a reference.
export function defaults(): Config {
return {
// The test suites that should be run.
suites: ['test/'],
// Output stream to write log messages to.
output: process.stdout,
// Whether the output stream should be treated as a TTY (and be given more
// complex output formatting). Defaults to `output.isTTY`.
ttyOutput: undefined,
// Spew all sorts of debugging messages.
verbose: false,
// Silence output
quiet: false,
// Display test results in expanded form. Verbose implies expanded.
expanded: false,
// The on-disk path where tests & static files should be served from. Paths
// (such as `suites`) are evaluated relative to this.
//
// Defaults to the project directory.
root: undefined,
// Idle timeout for tests.
testTimeout: 90 * 1000,
// Whether the browser should be closed after the tests run.
persistent: false,
// Additional .js files to include in *generated* test indexes.
extraScripts: [],
// Configuration options passed to the browser client.
clientOptions: {
root: '/components/',
},
compile: 'auto',
// Webdriver capabilities objects for each browser that should be run.
//
// Capabilities can also contain a `url` value which is either a string URL
// for the webdriver endpoint, or {hostname:, port:, user:, pwd:}.
//
// Most of the time you will want to rely on the WCT browser plugins to fill
// this in for you (e.g. via `--local`, `--sauce`, etc).
activeBrowsers: [],
// Default capabilities to use when constructing webdriver connections (for
// each browser specified in `activeBrowsers`). A handy place to hang common
// configuration.
//
// Selenium: https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities
// Sauce: https://docs.saucelabs.com/reference/test-configuration/
browserOptions: {},
// The plugins that should be loaded, and their configuration.
//
// When an array, the named plugins will be loaded with their default
// configuration. When an object, each key maps to a plugin, and values are
// configuration values to be merged.
//
// plugins: {
// local: {browsers: ['firefox', 'chrome']},
// }
//
plugins: ['local', 'sauce'],
// Callback that allows you to perform advanced configuration of the WCT
// runner.
//
// The hook is given the WCT context, and can generally be written like a
// plugin. For example, to serve custom content via the internal webserver:
//
// registerHooks: function(wct) {
// wct.hook('prepare:webserver', function(app) {
// app.use(...);
// return Promise.resolve();
// });
// }
//
registerHooks: function(_wct) {},
// Whether `wct.conf.*` is allowed, or only `wct.conf.json`.
//
// Handy for CI suites that want to be locked down.
enforceJsonConf: false,
// Configuration options for the webserver that serves up your test files
// and dependencies.
//
// Typically, you will not need to modify these values.
webserver: {
// The port that the webserver should run on. A port will be determined at
// runtime if none is provided.
port: undefined,
hostname: 'localhost',
},
// The name of the NPM package that is vending wct's browser.js will be
// determined automatically if no name is specified.
wctPackageName: undefined,
moduleResolution: 'node'
};
}
/**
* nomnom configuration for command line arguments.
*
* This might feel like duplication with `defaults()`, and out of place (why not
* in `cli.js`?). But, not every option matches a configurable value, and it is
* best to keep the configuration for these together to help keep them in sync.
*/
const ARG_CONFIG = {
persistent: {
help: 'Keep browsers active (refresh to rerun tests).',
abbr: 'p',
flag: true,
},
root: {
help: 'The root directory to serve tests from.',
transform: path.resolve,
},
plugins: {
help: 'Plugins that should be loaded.',
metavar: 'NAME',
full: 'plugin',
list: true,
},
skipPlugins: {
help: 'Configured plugins that should _not_ be loaded.',
metavar: 'NAME',
full: 'skip-plugin',
list: true,
},
expanded: {
help: 'Log a status line for each test run.',
flag: true,
},
verbose: {
help: 'Turn on debugging output.',
flag: true,
},
quiet: {
help: 'Silence output.',
flag: true,
},
simpleOutput: {
help: 'Avoid fancy terminal output.',
full: 'simple-output',
flag: true,
},
skipUpdateCheck: {
help: 'Don\'t check for updates.',
full: 'skip-update-check',
flag: true,
},
configFile: {
help: 'Config file that needs to be used by wct. ie: wct.config-sauce.js',
full: 'config-file',
},
npm: {
help: 'Use node_modules instead of bower_components for all browser ' +
'components and packages. Uses polyserve with `--npm` flag.',
flag: true,
},
moduleResolution: {
// kebab case to match the polyserve flag
full: 'module-resolution',
help: 'Algorithm to use for resolving module specifiers in import ' +
'and export statements when rewriting them to be web-compatible. ' +
'Valid values are "none" and "node". "none" disables module ' +
'specifier rewriting. "node" uses Node.js resolution to find modules.',
// type: 'string',
choices: ['none', 'node'],
},
version: {
help: 'Display the current version of web-component-tester. Ends ' +
'execution immediately (not useable with other options.)',
abbr: 'V',
flag: true,
},
'webserver.port': {
help: 'A port to use for the test webserver.',
full: 'webserver-port',
},
'webserver.hostname': {
full: 'webserver-hostname',
hidden: true,
},
// Managed by supports-color; let's not freak out if we see it.
color: {flag: true},
compile: {
help: 'Whether to compile ES2015 down to ES5. ' +
'Options: "always", "never", "auto". Auto means that we will ' +
'selectively compile based on the requesting user agent.'
},
wctPackageName: {
full: 'wct-package-name',
help: 'NPM package name that contains web-component-tester\'s browser ' +
'code. By default, WCT will detect the installed package, but ' +
'this flag allows explicitly naming the package. ' +
'This is only to be used with --npm option.'
},
// Deprecated
browsers: {
abbr: 'b',
hidden: true,
list: true,
},
remote: {
abbr: 'r',
hidden: true,
flag: true,
},
};
// Values that should be extracted when pre-parsing args.
const PREPARSE_ARGS =
['plugins', 'skipPlugins', 'simpleOutput', 'skipUpdateCheck', 'configFile'];
export interface PreparsedArgs {
plugins?: string[];
skipPlugins?: string[];
simpleOutput?: boolean;
skipUpdateCheck?: boolean;
}
/**
* Discovers appropriate config files (global, and for the project), merging
* them, and returning them.
*
* @param {string} matcher
* @param {string} root
* @return {!Object} The merged configuration.
*/
export function fromDisk(matcher: string, root?: string): Config {
const globalFile = path.join(HOME_DIR, matcher);
const projectFile = findup(matcher, {nocase: true, cwd: root});
// Load a shared config from the user's home dir, if they have one, and then
// try the project-specific path (starting at the current working directory).
const paths = _.union([globalFile, projectFile]);
const configs =
_.filter(paths, fs.existsSync).map((f) => loadProjectFile(f as string));
const options: Config = merge.apply(null, configs);
if (!options.root && projectFile && projectFile !== globalFile) {
options.root = path.dirname(projectFile);
}
return options;
}
/**
* @param {string} file
* @return {Object?}
*/
function loadProjectFile(file: string) {
// If there are _multiple_ configs at this path, prefer `json`
if (path.extname(file) === '.js' && fs.existsSync(file + 'on')) {
file = file + 'on';
}
try {
if (path.extname(file) === '.json') {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} else {
return require(file);
}
} catch (error) {
throw new Error(`Failed to load WCT config "${file}": ${error.message}`);
}
}
/**
* Runs a simplified options parse over the command line arguments, extracting
* any values that are necessary for a full parse.
*
* See const: PREPARSE_ARGS for the values that are extracted.
*
* @param {!Array<string>} args
* @return {!Object}
*/
export function preparseArgs(args: string[]): PreparsedArgs {
// Don't let it short circuit on help.
args = _.difference(args, ['--help', '-h']);
const parser = nomnom();
parser.options(<any>ARG_CONFIG);
parser.printer(function() {}); // No-op output & errors.
const options = parser.parse(args);
return _expandOptionPaths(_.pick(options, PREPARSE_ARGS));
}
/**
* Runs a complete options parse over the args, respecting plugin options.
*
* @param {!Context} context The context, containing plugin state and any base
* options to merge into.
* @param {!Array<string>} args The args to parse.
*/
export async function parseArgs(
context: Context, args: string[]): Promise<void> {
const parser = nomnom();
parser.script('wct');
parser.options(<any>ARG_CONFIG);
const plugins = await context.plugins();
plugins.forEach(_configurePluginOptions.bind(null, parser));
const options = <any>_expandOptionPaths(normalize(parser.parse(args)));
if (options._ && options._.length > 0) {
options.suites = options._;
}
context.options = merge(context.options, options);
}
function _configurePluginOptions(
parser: NomnomInternal.Parser, plugin: Plugin) {
/** HACK(rictic): this looks wrong, cliConfig shouldn't have a length. */
if (!plugin.cliConfig || (<any>plugin.cliConfig).length === 0) {
return;
}
// Group options per plugin. It'd be nice to also have a header, but that ends
// up shifting all the options over.
parser.option('plugins.' + plugin.name + '.', {string: ' '});
_.each(plugin.cliConfig, function(config, key) {
// Make sure that we don't expose the name prefixes.
if (!config['full']) {
config['full'] = key;
}
parser.option(
'plugins.' + plugin.name + '.' + key,
config as NomnomInternal.Parser.Option);
});
}
function _expandOptionPaths(options: {[key: string]: any}): any {
const result = {};
_.each(options, function(value, key) {
let target = result;
const parts = key.split('.');
for (const part of parts.slice(0, -1)) {
target = target[part] = target[part] || {};
}
target[_.last(parts)] = value;
});
return result;
}
/**
* @param {!Object...} configs Configuration objects to merge.
* @return {!Object} The merged configuration, where configuration objects
* specified later in the arguments list are given precedence.
*/
export function merge(...configs: Config[]): Config;
export function merge(): Config {
let configs: Config[] = Array.prototype.slice.call(arguments);
const result = <Config>{};
configs = configs.map(normalize);
_.merge.apply(_, [result, ...configs]);
// false plugin configs are preserved.
configs.forEach(function(config) {
_.each(config.plugins, function(value, key) {
if (typeof value === 'boolean' && value === false) {
result.plugins[key] = false;
}
});
});
return result;
}
export function normalize(config: Config): Config {
if (_.isArray(config.plugins)) {
const pluginConfigs = <{[key: string]: {disabled: boolean}}>{};
for (let i = 0, name: string; name = <string>config.plugins[i]; i++) {
// A named plugin is explicitly enabled (e.g. --plugin foo).
pluginConfigs[name] = {disabled: false};
}
config.plugins = pluginConfigs;
}
// Always wins.
if (config.skipPlugins) {
config.plugins = config.plugins || {};
for (let i = 0, name: string; name = config.skipPlugins[i]; i++) {
config.plugins[name] = false;
}
}
return config;
}
/**
* Expands values within the configuration based on the current environment.
*
* @param {!Context} context The context for the current run.
*/
export async function expand(context: Context): Promise<void> {
const options = context.options;
let root = context.options.root || process.cwd();
context.options.root = root = path.resolve(root);
options.origSuites = _.clone(options.suites);
expandDeprecated(context);
options.suites = await paths.expand(root, options.suites);
}
/**
* Expands any options that have been deprecated, and warns about it.
*
* @param {!Context} context The context for the current run.
*/
function expandDeprecated(context: Context) {
const options = context.options;
// We collect configuration fragments to be merged into the options object.
const fragments = [];
let browsers: Browser[] = <any>(
_.isArray(options.browsers) ? options.browsers : [options.browsers]);
browsers = <any>_.compact(<any>browsers);
if (browsers.length > 0) {
context.emit(
'log:warn',
'The --browsers flag/option is deprecated. Please use ' +
'--local and --sauce instead, or configure via plugins.' +
'[local|sauce].browsers.');
const fragment: {
plugins: {[name: string]: {browsers?: Browser[]}}
} = {plugins: {sauce: {}, local: {}}};
fragments.push(fragment);
for (const browser of browsers) {
const name = (<any>browser).browserName || browser;
const plugin = (<any>browser).platform || name.indexOf('/') !== -1 ?
'sauce' :
'local';
fragment.plugins[plugin].browsers =
fragment.plugins[plugin].browsers || [];
fragment.plugins[plugin].browsers.push(browser);
}
delete options.browsers;
}
if (options.sauce) {
context.emit(
'log:warn',
'The sauce configuration key is deprecated. Please use ' +
'plugins.sauce instead.');
fragments.push({
plugins: {sauce: options.sauce},
});
delete options.sauce;
}
if (options.remote) {
context.emit(
'log:warn',
'The --remote flag is deprecated. Please use ' +
'--sauce default instead.');
fragments.push({
plugins: {sauce: {browsers: ['default']}},
});
delete options.remote;
}
if (fragments.length > 0) {
// We are careful to modify context.options in place.
_.merge(context.options, merge.apply(null, fragments as any));
}
}
/**
* @param {!Object} options The configuration to validate.
*/
export async function validate(options: Config): Promise<void> {
if (options['webRunner']) {
throw new Error(
'webRunner is no longer a supported configuration option. ' +
'Please list the files you wish to test as arguments, ' +
'or as `suites` in a configuration object.');
}
if (options['component']) {
throw new Error(
'component is no longer a supported configuration option. ' +
'Please list the files you wish to test as arguments, ' +
'or as `suites` in a configuration object.');
}
if (options.activeBrowsers.length === 0) {
throw new Error('No browsers configured to run');
}
if (options.suites.length === 0) {
const root = options.root || process.cwd();
const globs = options.origSuites.join(', ');
throw new Error(
'No test suites were found matching your configuration\n' +
'\n' +
' WCT searched for .js and .html files matching: ' + globs + '\n' +
'\n' +
' Relative paths were resolved against: ' + root);
}
}