diff --git a/CHANGELOG.md b/CHANGELOG.md index f263e7c8..5a4e8b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ + + +# 3.0.0 (2017-12-01) + +### Features +* **cli:** Added a boat-load of new CLI configuration options. Every option available + in the `angular-playground.json` file is now also available as a CLI argument + (except @angular/cli arguments). [Read more about it in our docs](http://angularplayground.it/docs/api/configuration). + ([9dc1066](https://github.com/SoCreate/angular-playground/commit/9dc1066)) +* **new docs:** Speaking of docs, check out our newly-designed + [docs page](http://angularplayground.it/docs/getting-started/introduction). +* **new error checking utility:** A new CLI option has been introduced that will run and visit + all sandbox scenarios in headless chrome, surfacing any errors that appear in the + console. [Never forget to mock a dependency again!](http://angularplayground.it/docs/how-to/run-the-test-suite) + ([6074586](https://github.com/SoCreate/angular-playground/commit/6074586)) +* **report formats for builds:** Used in conjunction with the checking utility, you can now + generate a JSON report that your build system can read for error reporting. Read all + about it [here](http://angularplayground.it/docs/api/reporter-formats). + ([7e0f5a8](https://github.com/SoCreate/angular-playground/commit/7e0f5a8)) + +* **command bar shows all components as default:** Got the Playground running but don't know where + to begin? We'll help you out by showing all of your available scenarios. + ([51680fd](https://github.com/SoCreate/angular-playground/commit/51680fd) + +### Breaking Changes +* **no default configuration argument**: The CLI no longer supports a default configuration file argument. + **Note:** `angular-playground` with no arguments will still default to using the + `angular-playground.json` file as expected. + ([9dc1066](https://github.com/SoCreate/angular-playground/commit/9dc1066)) + + Before: + ``` + angular-playground my-configuration-file.json + ``` + After: + ``` + angular-playground --config my-configuration-file.json + ``` + +* **new cli argument style**: CLI arguments now match typical npm style: `--argument` for full name, `-A` for abbreviation. + + Before: + ``` + -no-watch -no-serve + ``` + + After: + ``` + --no-watch --no-serve + ``` + # 2.3.0 (2017-11-13) diff --git a/package-lock.json b/package-lock.json index 2ff8ea78..5f81808a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "angular-playground", - "version": "2.3.0", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -186,10 +186,9 @@ "dev": true }, "async": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz", - "integrity": "sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==", - "dev": true, + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", + "integrity": "sha512-xAfGg1/NTLBBKlHFmnd7PlmUW9KhVQIUuSrYem9xzFUZy13ScvtyGGejaae9iAVRiRq9+Cx7DPFaAAhCpyxyPw==", "requires": { "lodash": "4.17.4" }, @@ -197,8 +196,7 @@ "lodash": { "version": "4.17.4", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", - "dev": true + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=" } } }, @@ -998,7 +996,7 @@ "integrity": "sha1-oUXyFveaDabJxrF7zkfhmQGM2Dg=", "dev": true, "requires": { - "async": "2.5.0", + "async": "2.6.0", "clone": "1.0.2", "es6-templates": "0.2.3", "extend": "3.0.1", diff --git a/package.json b/package.json index a795bfed..4d948821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-playground", - "version": "2.3.0", + "version": "3.0.0", "description": "A drop in app module for working on Angular components in isolation (aka Scenario Driven Development).", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -45,7 +45,10 @@ "zone.js": ">=0.8.14" }, "dependencies": { - "node-watch": "^0.4.1" + "async": "^2.6.0", + "node-watch": "^0.4.1", + "ts-node": "^3.3.0", + "puppeteer": "^0.13.0" }, "devDependencies": { "@angular/common": "^5.0.0", @@ -63,9 +66,7 @@ "glob": "^7.1.2", "gulp": "^3.9.1", "gulp-inline-ng2-template": "^4.0.0", - "puppeteer": "^0.13.0", "rxjs": "^5.5.2", - "ts-node": "^3.3.0", "tslint": "5.3.2", "typescript": "2.4.2", "zone.js": "0.8.4" diff --git a/src/cli/cli.ts b/src/cli/cli.ts index cbb54877..514184e3 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -5,6 +5,7 @@ import { startWatch } from './start-watch'; import { runAngularCli } from './run-angular-cli'; import { Configuration } from './shared/configuration'; import { verifySandboxes } from './verify-sandboxes'; +import { findFirstFreePort } from './shared/find-port'; (async () => { await run(); @@ -13,9 +14,9 @@ import { verifySandboxes } from './verify-sandboxes'; async function run() { const rawArgs = process.argv.slice(2); const config = new Configuration(rawArgs); + let sandboxPort, playgroundConfig; - let configFile = path.resolve(config.configFilePath); - let playgroundConfig; + let configFile = path.resolve(config.flags.config.value); try { playgroundConfig = require(configFile.replace(/.json$/, '')); } catch (e) { @@ -23,18 +24,26 @@ async function run() { process.exit(1); } + // Parity between command line arguments and configuration file + config.applyConfigurationFile(playgroundConfig); const sandboxesPath = await build(playgroundConfig.sourceRoot); - config.port = playgroundConfig.angularCli.port ? playgroundConfig.angularCli.port : 4201; - if (config.runWatch) { - startWatch(playgroundConfig, () => build(playgroundConfig.sourceRoot)); + if (config.flags.checkErrors.value) { + // get port dynamically + const port = await findFirstFreePort('127.0.0.1', 7000, 9000); + sandboxPort = port; + config.flags.angularCli.port.value = port; } - if (config.runAngularCliServe && playgroundConfig.angularCli) { - runAngularCli(playgroundConfig.angularCli); + if (!config.flags.noWatch.value) { + startWatch(config.flags.sourceRoot.value, () => build(config.flags.sourceRoot.value)); } - if (config.runCheckErrors) { - verifySandboxes(config, sandboxesPath); + if (!config.flags.noServe.value && playgroundConfig.angularCli) { + runAngularCli(config, playgroundConfig.angularCli); + } + + if (config.flags.checkErrors.value) { + verifySandboxes(config, sandboxesPath, sandboxPort); } } diff --git a/src/cli/reporters/json-reporter.ts b/src/cli/reporters/json-reporter.ts new file mode 100644 index 00000000..25b98e6c --- /dev/null +++ b/src/cli/reporters/json-reporter.ts @@ -0,0 +1,43 @@ +class JSONStats { + suites = 1; + passes: number; + pending = 0; + duration = 0; + time = 0; + + constructor( + public tests: number, + public failures: number, + public start = 0, + public end = 0 + ) { + this.passes = this.tests - this.failures; + } + +} + +export class JSONReporter { + constructor ( + public errors: any[], + public scenarioNames: string[] + ) {} + + getJson() { + return JSON.stringify({ + stats: new JSONStats(this.scenarioNames.length, this.errors.length), + failures: this.errors.map(failure => { + if (!failure) return; + return { + title: failure.scenario, + err: { + message: failure.descriptions[0] + } + }; + }), + passes: this.scenarioNames.map(pass => { + return { title: pass }; + }), + skips: [] + }, null, 2); + } +} diff --git a/src/cli/run-angular-cli.ts b/src/cli/run-angular-cli.ts index 34113a47..1387d27d 100644 --- a/src/cli/run-angular-cli.ts +++ b/src/cli/run-angular-cli.ts @@ -1,24 +1,19 @@ +import { Configuration } from './shared/configuration'; + const fs = require('fs'); const path = require('path'); const childProcess = require('child_process'); -export const runAngularCli = (angularCliConfig) => { - let port = angularCliConfig.port ? angularCliConfig.port : 4201; - let cliName = '@angular/cli'; - try { - fs.accessSync(path.resolve('node_modules/@angular/cli/bin/ng')); - } catch (e) { - cliName = 'angular-cl'; - } - let cliPath = `node_modules/${cliName}/bin/ng`; - let args = [cliPath, 'serve', '-no-progress']; +export const runAngularCli = (config: Configuration, angularCliConfig: any) => { + const cliConfig = config.flags.angularCli; + let args = [cliConfig.cmdPath.value, 'serve', '-no-progress']; args.push('--port'); - args.push(port.toString()); - if (angularCliConfig.appName) { - args.push(`-a=${angularCliConfig.appName}`); + args.push(cliConfig.port.value.toString()); + if (cliConfig.appName.value) { + args.push(`-a=${cliConfig.appName.value}`); } - if (angularCliConfig.environment) { - args.push(`-e=${angularCliConfig.environment}`); + if (cliConfig.environment.value) { + args.push(`-e=${cliConfig.environment.value}`); } if (angularCliConfig.args) { args = args.concat(angularCliConfig.args); diff --git a/src/cli/shared/configuration.ts b/src/cli/shared/configuration.ts index d05d64fc..b8c36a1e 100644 --- a/src/cli/shared/configuration.ts +++ b/src/cli/shared/configuration.ts @@ -1,82 +1,106 @@ +import { REPORT_TYPE } from './error-reporter'; + +class Flag { + constructor( + public aliases: string[], + public value: any, + public required = false + ) {} +} + /** * Configuration object used to parse and assign command line arguments */ export class Configuration { - private supportedFlags = { - noWatch: '--no-watch', - noServe: '--no-serve', - checkErrs: '--check-errors', - randomScenario: '--random-scenario' - }; - - private supportedArguments = { - config: '--config' + flags: any = { + noWatch: new Flag(['--no-watch'], false), + noServe: new Flag(['--no-serve'], false), + checkErrors: new Flag(['--check-errors'], false), + randomScenario: new Flag(['--random-scenario'], false), + sourceRoot: new Flag(['--src', '-S'], './src', true), + config: new Flag(['--config', '-C'], 'angular-playground.json'), + timeout: new Flag(['--timeout'], 90), + reportPath: new Flag(['--report-path', '-R'], './sandbox.report.json'), + reportType: new Flag(['--report-type'], REPORT_TYPE.LOG), + angularCli: { + appName: new Flag(['--ng-cli-app'], 'playground'), + environment: new Flag(['--ng-cli-env'], null), + port: new Flag(['--ng-cli-port'], 4201), + cmdPath: new Flag(['--ng-cli-cmd'], 'node_modules/@angular/cli/bin/ng') + } }; - runWatch: boolean; - runAngularCliServe: boolean; - runCheckErrors: boolean; - randomScenario: boolean; - configFilePath: string; - port: number; - timeoutAttempts = 20; + // Used to tailor the version of headless chromium ran by puppeteer chromeArguments = [ '--disable-gpu', '--no-sandbox' ]; constructor(rawArgv: string[]) { - const { flags, args } = this.getParsedArguments(rawArgv); - this.configureFlags(flags); - this.configureArguments(args); - } + // Apply command line arguments + rawArgv.forEach((argv, i) => { + const matchingFlag = this.findFlag(argv, this.flags); + if (!matchingFlag) return; - get baseUrl(): string { - return `http://localhost:${this.port}`; + if (typeof matchingFlag.value === 'boolean') { + matchingFlag.value = true; + } else { + matchingFlag.value = rawArgv[i + 1]; + } + }); } - // Boolean flags - private configureFlags(flags: string[]) { - this.runWatch = flags.indexOf(this.supportedFlags.noWatch) === -1; - this.runAngularCliServe = flags.indexOf(this.supportedFlags.noServe) === -1; - this.runCheckErrors = flags.indexOf(this.supportedFlags.checkErrs) !== -1; - this.randomScenario = flags.indexOf(this.supportedFlags.randomScenario) !== -1; - } + /** + * Override flags and switches with angular playground configuration JSON file + * @param playgroundConfig + */ + applyConfigurationFile(playgroundConfig: any) { + const applyToFlags = (config: any, flagGroup: any) => { + Object.keys(config).forEach(key => { + if (!flagGroup.hasOwnProperty(key)) return; + const flag = flagGroup[key]; - // Arguments that may have values attached - private configureArguments(args: string[]) { - const configIndex = args.indexOf(this.supportedArguments.config); - if (configIndex !== -1) { - this.configFilePath = this.getArgValue(configIndex, args); - } else if (args.length > 0) { - this.configFilePath = args[0]; - } else { - this.configFilePath = 'angular-playground.json'; - } + if (this.instanceOfFlagGroup(flag)) { + applyToFlags(config[key], flag); + } + + flag.value = config[key]; + }); + }; + + applyToFlags(playgroundConfig, this.flags); } /** - * Separates accepted command line arguments from other ts-node arguments - * @param supportedFlags - Accepted command line flags - * @param args - Process arguments + * Return a flag that contains the provided alias or undefined if none found + * @param alias - Alias provided by argv. e.g. --config + * @param flagGroup - Grouping of flags to check */ - private getParsedArguments(args: string[]): { flags: string[], args: string[] } { - const flags: string[] = []; + private findFlag(alias: string, flagGroup: any): Flag { + return this.getValues(flagGroup).find(flag => { + if (this.instanceOfFlagGroup(flag)) { + return this.findFlag(alias, flag); + } - args = args.reduce((accr, value) => { - Object.keys(this.supportedFlags) - .map(key => this.supportedFlags[key]) - .indexOf(value) > -1 ? flags.push(value) : accr.push(value); - return accr; - }, []); + return flag.aliases.includes(alias); + }); + } + + // Shim for Object.values() + private getValues(obj: any): any[] { + const vals = []; + + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + vals.push(obj[key]); + } + } - return { flags, args }; + return vals; } /** - * Gets the value of an argument from list of args (next consecutive argument) - * e.g. --config ./src/ - * @param startingIndex - Index of argument - * @param args - list of args + * Determines if provided item is a Flag or Flag-group + * @param item - Flag or Flag-group */ - private getArgValue(startingIndex: number, args: string[]): string { - return args[startingIndex + 1]; + private instanceOfFlagGroup(item: any) { + return !item.hasOwnProperty('value'); } } diff --git a/src/cli/shared/error-reporter.ts b/src/cli/shared/error-reporter.ts index 3b350b8c..752bc4c9 100644 --- a/src/cli/shared/error-reporter.ts +++ b/src/cli/shared/error-reporter.ts @@ -1,25 +1,47 @@ -export enum ReportType { - Log -} +import * as fs from 'fs'; +import { JSONReporter } from '../reporters/json-reporter'; +import { ScenarioSummary } from '../verify-sandboxes'; + +export const REPORT_TYPE = { + LOG: 'log', + JSON: 'json' +}; export class ErrorReporter { - private _errors: { error: any, scenario: string }[] = []; + private _errors: { descriptions: any, scenario: string }[] = []; - constructor(public type = ReportType.Log) {} + constructor( + public scenarios: ScenarioSummary[], + public filename: string, + public type: string + ) {} get errors() { return this._errors; } - addError(error: any, scenario: string) { - this._errors.push({ error, scenario }); + redWrap(msg: string): string { + return `\x1b[31m${msg}\x1b[0m`; + } + + addError(descriptions: any, scenario: string) { + this._errors.push({ descriptions, scenario }); } compileReport() { switch (this.type) { - case ReportType.Log: - console.log('Found errors in the following scenarios:'); - this._errors.forEach(e => console.log(e.scenario)); + case REPORT_TYPE.LOG: + console.error(`${this.redWrap('ERROR:')} in the following scenarios`); + this._errors.forEach(e => { + console.log(e.scenario); + console.log(e.descriptions); + }); + break; + case REPORT_TYPE.JSON: + const scenarioNames = this.scenarios.map(s => `${s.name}: ${s.description}`); + const results = new JSONReporter(this.errors, scenarioNames); + fs.writeFileSync(this.filename, results.getJson()); + break; } } diff --git a/src/cli/shared/find-port.ts b/src/cli/shared/find-port.ts new file mode 100644 index 00000000..36cb914e --- /dev/null +++ b/src/cli/shared/find-port.ts @@ -0,0 +1,63 @@ +import * as net from 'net'; +// Legacy import +const detect = require('async/detect'); + +/** + * Function that detects the first port not in use in a given range + * e.g. + * findFirstFreePort('127.0.0.1', 8000, 8030, (port) => { + * console.log(port) + * }); + * + * @param host - Host to check ports + * @param start - Starting point for range + * @param end - Ending point for range + * @param callback - Callback on result + */ +export async function findFirstFreePort(host: string, start: number, end: number) { + const ports = []; + for (let i = start; i < end; i++) { + ports.push(i); + } + + const probe = (port: number, cb: Function) => { + let calledOnce = false; + let connected = false; + + const server = net.createServer().listen(port, host); + const timeoutRef = setTimeout(() => { + calledOnce = true; + cb(port, false); + }, 2000); + + // Active timeout won't require node event loop to remain active + timeoutRef.unref(); + + server.on('listening', () => { + clearTimeout(timeoutRef); + if (server) server.close(); + if (!calledOnce) { + calledOnce = true; + cb(port, true); + } + }); + + server.on('error', (err: any) => { + clearTimeout(timeoutRef); + let result = true; + if (err.code === 'EADDRINUSE' || err.code === 'EACCES') { + result = false; + } + if (!calledOnce) { + calledOnce = true; + cb(port, result); + } + }); + }; + + return new Promise(resolve => { + detect(ports, probe, (port: number) => { + resolve(port); + }); + }); +} diff --git a/src/cli/start-watch.ts b/src/cli/start-watch.ts index 94b16a88..c4088f94 100644 --- a/src/cli/start-watch.ts +++ b/src/cli/start-watch.ts @@ -1,7 +1,7 @@ const path = require('path'); const watch = require('node-watch'); -export const startWatch = (config, cb) => { +export const startWatch = (sourceRoot, cb) => { let filter = (fn) => { return (filename) => { if (!/node_modules/.test(filename) && /\.sandbox.ts$/.test(filename)) { @@ -9,5 +9,5 @@ export const startWatch = (config, cb) => { } }; }; - watch([path.resolve(config.sourceRoot)], filter(cb)); + watch([path.resolve(sourceRoot)], filter(cb)); }; diff --git a/src/cli/verify-sandboxes.ts b/src/cli/verify-sandboxes.ts index 399a7f5d..7aeb10c9 100644 --- a/src/cli/verify-sandboxes.ts +++ b/src/cli/verify-sandboxes.ts @@ -1,13 +1,15 @@ import * as puppeteer from 'puppeteer'; import * as process from 'process'; import * as path from 'path'; -import { ErrorReporter, ReportType } from './shared/error-reporter'; +import { ErrorReporter, REPORT_TYPE } from './shared/error-reporter'; import { Configuration } from './shared/configuration'; // ts-node required for runtime typescript compilation of sandboxes.ts require('ts-node/register'); +// Legacy import +const asyncMap = require('async/map'); -interface ScenarioSummary { +export interface ScenarioSummary { url: string; name: string; description: string; @@ -15,31 +17,35 @@ interface ScenarioSummary { let browser: any; let currentScenario = ''; -const reporter = new ErrorReporter(); +let reporter: ErrorReporter; +let hostUrl = ''; // Ensure Chromium instances are destroyed on err process.on('unhandledRejection', () => { if (browser) browser.close(); }); -export async function verifySandboxes(configuration: Configuration, sandboxesPath: string) { - await main(configuration, sandboxesPath); +export async function verifySandboxes(configuration: Configuration, sandboxesPath: string, port: number) { + hostUrl = `http://localhost:${port}`; + await main(configuration, sandboxesPath, port); } ///////////////////////////////// -async function main (configuration: Configuration, sandboxesPath: string) { - let timeoutAttempts = configuration.timeoutAttempts; +async function main (configuration: Configuration, sandboxesPath: string, port: number) { + const timeoutAttempts = configuration.flags.timeout.value; browser = await puppeteer.launch({ headless: true, handleSIGINT: false, args: configuration.chromeArguments }); - const scenarios = getSandboxMetadata(configuration.baseUrl, configuration.randomScenario, sandboxesPath); + const scenarios = getSandboxMetadata(hostUrl, configuration.flags.randomScenario.value, sandboxesPath); + reporter = new ErrorReporter(scenarios, configuration.flags.reportPath.value, configuration.flags.reportType.value); console.log(`Retrieved ${scenarios.length} scenarios.\n`); for (let i = 0; i < scenarios.length; i++) { - await openScenarioInNewPage(scenarios[i], configuration.timeoutAttempts); + console.log(`Checking: ${scenarios[i].name}: ${scenarios[i].description}`); + await openScenarioInNewPage(scenarios[i], timeoutAttempts); } browser.close(); @@ -61,7 +67,7 @@ async function main (configuration: Configuration, sandboxesPath: string) { async function openScenarioInNewPage(scenario: ScenarioSummary, timeoutAttempts: number) { if (timeoutAttempts === 0) { await browser.close(); - process.exit(1); + throw new Error('Unable to connect to Playground.'); } const page = await browser.newPage(); @@ -69,12 +75,10 @@ async function openScenarioInNewPage(scenario: ScenarioSummary, timeoutAttempts: currentScenario = scenario.name; try { - console.log(`Checking: ${currentScenario}: ${scenario.description}`); await page.goto(scenario.url); } catch (e) { await page.close(); - await delay(5000); - console.log(`Attempting to connect. (Attempts Remaining: ${timeoutAttempts})`); + await delay(1000); await openScenarioInNewPage(scenario, timeoutAttempts - 1); } } @@ -130,10 +134,15 @@ function loadSandboxMenuItems(path: string): any[] { * Callback when Chromium page encounters a console error * @param msg - Error message */ -function onConsoleErr(msg: any) { +async function onConsoleErr(msg: any) { if (msg.type === 'error') { - console.error(`ERROR Found in ${currentScenario}`); - reporter.addError(msg, currentScenario); + console.error(`${reporter.redWrap('ERROR:')} in ${currentScenario}`); + const descriptions = msg.args + .map(a => a._remoteObject) + .filter(o => o.type === 'object') + .map(o => o.description); + descriptions.map(d => console.error(d)); + reporter.addError(descriptions, currentScenario); } }