diff --git a/.gitignore b/.gitignore index 17c10b93c..f9a8e3d50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ /node_modules /lib /examples/*/build +/tests/output .DS_Store npm-debug.log key.pem cert.pem +lighthouse.*/ +tmp-*.json diff --git a/.travis.yml b/.travis.yml index 73af2c19b..5542d50d4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,22 @@ +dist: trusty +sudo: required +addons: + chrome: stable + apt: + packages: libnss3-tools + language: node_js node_js: -- "7.0" \ No newline at end of file + - "6" + - "7" + - "8" +cache: + npm: true + +before_script: +- export LIGHTHOUSE_CHROMIUM_PATH=`which google-chrome-stable` +- google-chrome-stable --headless --disable-gpu --remote-debugging-port=9222 http://localhost & + +script: +- npm run lint +- npm run test diff --git a/package.json b/package.json index 977253323..7155ca814 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "build": "babel src -d lib -D", "prepublish": "npm run -s build", "dev": "babel-node src", - "lint": "eslint src", - "test": "npm run -s lint", + "lint": "eslint src tests", + "pretest": "npm run -s build", + "test": "tape -r babel-register tests/*.test.js", "test:build": "babel-node src build --cwd examples/root", "test:serve": "npm run -s test:build && babel-node src serve --port 3000 --cwd examples/root", "test:serve:config": "npm run -s test:build && babel-node src serve --server config --cwd examples/root", @@ -72,8 +73,14 @@ "devDependencies": { "babel-cli": "^6.24.0", "babel-eslint": "^7.2.1", + "chrome-launcher": "^0.1.1", + "chrome-remote-interface": "^0.23.2", "eslint": "^3.19.0", - "eslint-plugin-react": "^7.0.1" + "eslint-plugin-react": "^7.0.1", + "html-looks-like": "^1.0.2", + "lodash": "^4.17.4", + "tape": "^4.6.3", + "uuid": "^3.0.1" }, "dependencies": { "@webpack-blocks/css-modules": "^0.4.0", @@ -98,7 +105,6 @@ "devcert-san": "^0.3.3", "ejs-loader": "^0.3.0", "es6-promisify": "^5.0.0", - "eslint": "^3.19.0", "file-loader": "^0.11.1", "fs.promised": "^3.0.0", "html-webpack-plugin": "^2.28.0", diff --git a/src/commands/create.js b/src/commands/create.js index 7925d637e..99da66db3 100644 --- a/src/commands/create.js +++ b/src/commands/create.js @@ -47,6 +47,11 @@ export default asyncCommand({ description: 'Pre-install SASS/SCSS support', type: 'boolean', default: false + }, + install: { + description: 'Install dependencies', + type: 'boolean', + default: true } }, @@ -110,38 +115,40 @@ export default asyncCommand({ await fs.writeFile(path.resolve(target, 'package.json'), JSON.stringify(pkg, null, 2)); - spinner.text = 'Installing dev dependencies'; - - await npm(target, [ - 'install', '--save-dev', - 'preact-cli', - 'if-env', - 'eslint', - 'eslint-config-synacor', - - // install sass setup if --sass - ...(argv.sass ? [ - 'node-sass', - 'sass-loader' - ] : []), - - // install less setup if --less - ...(argv.less ? [ - 'less', - 'less-loader' - ] : []) - ].filter(Boolean)); - - spinner.text = 'Installing dependencies'; - - await npm(target, [ - 'install', '--save', - 'preact', - 'preact-compat', - 'preact-router' - ]); - - spinner.succeed('Done!\n'); + if (argv.install) { + spinner.text = 'Installing dev dependencies'; + + await npm(target, [ + 'install', '--save-dev', + 'preact-cli', + 'if-env', + 'eslint', + 'eslint-config-synacor', + + // install sass setup if --sass + ...(argv.sass ? [ + 'node-sass', + 'sass-loader' + ] : []), + + // install less setup if --less + ...(argv.less ? [ + 'less', + 'less-loader' + ] : []) + ].filter(Boolean)); + + spinner.text = 'Installing dependencies'; + + await npm(target, [ + 'install', '--save', + 'preact', + 'preact-compat', + 'preact-router' + ]); + + spinner.succeed('Done!\n'); + } return ` To get started, cd into the new directory: diff --git a/tests/async-test.js b/tests/async-test.js new file mode 100644 index 000000000..583f5e299 --- /dev/null +++ b/tests/async-test.js @@ -0,0 +1,39 @@ +import test from 'tape'; + +const defaultTestTimeout = 10 * 1000; + +const asyncTest = (...args) => { + let { fn, tapeArgs, hasTimeout } = parseArgs(args); + + test(...tapeArgs, (t) => { + if (!hasTimeout) t.timeoutAfter(defaultTestTimeout); + fn(t) + .then(() => t.end()) + .catch(e => t.end(e)); + }); +}; + +asyncTest.only = (...args) => { + let { fn, tapeArgs, hasTimeout } = parseArgs(args); + + test.only(...tapeArgs, t => { + if (!hasTimeout) t.timeoutAfter(defaultTestTimeout); + fn(t) + .then(() => t.end()) + .catch(e => t.end(e)); + }); +}; + +export default asyncTest; + +const parseArgs = args => { + let fn = args[args.length - 1]; + let tapeArgs = args.slice(0, args.length - 1); + let opts = tapeArgs[1]; + + return { + fn, + tapeArgs, + hasTimeout: opts && opts.timeout !== undefined + }; +}; diff --git a/tests/build.snapshot.js b/tests/build.snapshot.js new file mode 100644 index 000000000..127516520 --- /dev/null +++ b/tests/build.snapshot.js @@ -0,0 +1,79 @@ +export const normalize = obj => { + let keys = Object.keys(obj); + + if (keys.length === 1 && keys[0] === 'size' && typeof keys[0] === 'number') { + return { size: Math.round(obj.size / 10) * 10 }; + } + + return keys.reduce((agg, key) => { + let newKey = key.replace(/\.chunk\.\w+\./, '.chunk.*.'); + agg[newKey] = normalize(obj[key]); + return agg; + }, {}); +}; + +const smallBuildCommons = { + assets: { + 'favicon.ico': { size: 15086 }, + 'icon.png': { size: 51484 } + }, + 'polyfills.chunk.*.js': { size: 4068 }, + 'favicon.ico': { size: 15086 }, + 'sw.js': { size: 3378 }, + 'manifest.json': { size: 298 }, + 'push-manifest.json': { size: 2 }, +}; + +const fullBuildCommons = { + assets: { + 'favicon.ico': { size: 15086 }, + icons: { + 'android-chrome-192x192.png': { size: 14058 }, + 'android-chrome-512x512.png': { size: 51484 }, + 'apple-touch-icon.png': { size: 12746 }, + 'favicon-16x16.png': { size: 626 }, + 'favicon-32x32.png': { size: 1487 }, + 'mstile-150x150.png': { size: 9050 } + } + }, + 'polyfills.chunk.*.js': { size: 4066 }, + 'push-manifest.json': { size: 303 }, + 'favicon.ico': { size: 15086 }, + 'manifest.json': { size: 426 }, + 'sw.js': { size: 3905 } +}; + +export const expectedOutputs = normalize({ + empty: { + ...smallBuildCommons, + 'bundle.js': { size: 10694 }, + 'index.html': { size: 534 }, + 'style.css': { size: 131 }, + 'style.css.map': { size: 359 }, + }, + simple: { + ...smallBuildCommons, + 'bundle.js': { size: 11336 }, + 'index.html': { size: 548 }, + 'style.css': { size: 296}, + 'style.css.map': { size: 621 }, + }, + root: { + ...fullBuildCommons, + 'bundle.js': { size: 18739 }, + 'route-home.chunk.*.js': { size: 959 }, + 'route-profile.chunk.*.js': { size: 1595 }, + 'index.html': { size: 775 }, + 'style.css': { size: 1065 }, + 'style.css.map': { size: 2246 }, + }, + 'default': { + ...fullBuildCommons, + 'bundle.js': { size: 19661 }, + 'route-home.chunk.*.js': { size: 961 }, + 'route-profile.chunk.*.js': { size: 1597 }, + 'index.html': { size: 775 }, + 'style.css': { size: 1065 }, + 'style.css.map': { size: 2345 }, + } +}); diff --git a/tests/build.test.js b/tests/build.test.js new file mode 100644 index 000000000..fa7eb008e --- /dev/null +++ b/tests/build.test.js @@ -0,0 +1,27 @@ +import test from './async-test'; +import { resolve } from 'path'; +import { create, build } from './lib/cli'; +import lsr from './lib/lsr'; +import { setup, clean } from './lib/output'; +import { normalize, expectedOutputs } from './build.snapshot'; + +const options = { timeout: 45 * 1000 }; + +test('preact build - before', async () => { + await setup(); +}); + +['empty', 'simple', 'root', 'default'].forEach(template => + test(`preact build - should produce output. Veryfing ${template}`, options, async t => { + let app = await create('app', template); + await build(app); + + let output = await lsr(resolve(app, 'build')); + + t.isEquivalent(normalize(output), expectedOutputs[template]); + }) +); + +test('preact build - after', async () => { + await clean(); +}); diff --git a/tests/create.test.js b/tests/create.test.js new file mode 100644 index 000000000..46a4f7512 --- /dev/null +++ b/tests/create.test.js @@ -0,0 +1,34 @@ +import test from './async-test'; +import { resolve } from 'path'; +import { create } from './lib/cli'; +import lsr from './lib/lsr'; +import { setup, clean } from './lib/output'; + +const listTemplate = async dir => await lsr(resolve(__dirname, '../examples', dir), ['.gitkeep', 'package.json']); +const listOutput = async dir => await lsr(dir, ['.gitkeep', 'package.json']); + +test('preact create - before', async () => { + await setup(); +}); + +test('preact create - should create project using full template by default.', async t => { + let fullExample = await listTemplate('full'); + let app = await create('app'); + let generated = await listOutput(app); + + t.isEquivalent(generated, fullExample); +}); + +['root', 'simple', 'empty'].forEach(template => + test(`preact create - should create project using provided template. Verifying ${template}`, async t => { + let example = await listTemplate(template); + let app = await create('app', template); + let generated = await listOutput(app); + + t.isEquivalent(generated, example); + }) +); + +test('preact build - after', async () => { + await clean(); +}); diff --git a/tests/lib/chrome.js b/tests/lib/chrome.js new file mode 100644 index 000000000..c0f795c0f --- /dev/null +++ b/tests/lib/chrome.js @@ -0,0 +1,104 @@ +import { Launcher } from 'chrome-launcher'; +import chrome from 'chrome-remote-interface'; + +export default async () => { + let launcher = new Launcher({ + port: 9222, + autoSelectChrome: true, + additionalFlags: [ + '--window-size=1024,768', + '--disable-gpu', + '--headless' + ] + }); + launcher.pollInterval = 1000; + await launcher.launch(); + let protocol = await setup(); + return { launcher, protocol }; +}; + +export const delay = time => new Promise(r => setTimeout(() => r(), time)); + +export const getElementHtml = async (Runtime, selector) => { + let { result } = await Runtime.evaluate({ expression: `document.querySelector("${selector}").outerHTML` }); + return result.value; +}; + +export const waitUntil = async (Runtime, expression, retryCount = 10, retryInterval = 500) => { + if (retryCount < 0) { + throw new Error(`Wait until: '${expression}' timed out.`); + } + + let { result } = await Runtime.evaluate({ expression }); + if (result && result.subtype === 'promise') { + let message = await Runtime.awaitPromise({ + promiseObjectId: result.objectId, + returnByValue: true + }); + result = message.result; + } + + if (!result || !result.value) { + await delay(retryInterval); + await waitUntil(Runtime, expression, retryCount - 1, retryInterval); + } +}; + +export const loadPage = async (chrome, url, retryCount = 10, retryInterval = 5000) => { + let result = await openPage(chrome, url, retryCount, retryInterval); + await chrome.Page.loadEventFired(); + return result; +}; + +const openPage = async (chrome, url, retryCount, retryInterval) => { + if (retryCount < 0) { + throw new Error('Page could not be loaded!'); + } + + let result; + try { + result = await navigateToPage(chrome, url, retryInterval); + } catch (e) { + result = await openPage(chrome, url, retryCount - 1, retryInterval); + } + + return result; +}; + +const setup = () => new Promise((resolve, reject) => { + chrome(protocol => { + const { Page, Runtime, Network, DOM, ServiceWorker } = protocol; + + Promise.all([ + Page.enable(), + Runtime.enable(), + Network.enable(), + DOM.enable(), + ServiceWorker.enable() + ]).then(() => { + resolve(protocol); + }) + .catch(reject); + }).on('error', err => reject(new Error('Cannot connect to Chrome:' + err))); +}); + +const navigateToPage = (chrome, url, timeout) => new Promise(async (resolve, reject) => { + let timer; + + let listener = (result) => { + let { status, url: responseUrl } = result.response; + if (responseUrl === url) { + chrome.removeListener('Network.responseReceived', listener); + clearTimeout(timer); + return status < 400 ? resolve(result) : reject(status); + } + }; + + timer = setTimeout(() => { + chrome.removeListener('Network.responseReceived', listener); + reject(); + }, timeout); + + chrome.on('Network.responseReceived', listener); + await chrome.Page.navigate({ url }); +}); diff --git a/tests/lib/cli.js b/tests/lib/cli.js new file mode 100644 index 000000000..9003fe99e --- /dev/null +++ b/tests/lib/cli.js @@ -0,0 +1,80 @@ +import crossSpawn from 'cross-spawn-promise'; +import { spawn as spawnChild } from 'child_process'; +import { resolve } from 'path'; +import mkdirp from 'mkdirp'; +import uuid from 'uuid/v4'; +import { outputPath } from './output'; + +const cliPath = resolve(__dirname, '../../lib/index.js'); + +export const create = async (appName, template) => { + let workDir = resolve(outputPath, uuid()); + await mkdirp(workDir); + await run(['create', appName, '--no-install', template ? `--type=${template}` : undefined], workDir); + return resolve(workDir, appName); +}; + +export const build = async (appDir) => { + await run(['build'], appDir); +}; + +export const serve = async (appDir, port) => { + return await spawn(['serve', port ? `-p=${port}` : undefined], appDir); +}; + +export const watch = async (appDir, port) => { + return await spawn(['watch', port ? `-p=${port}` : undefined], appDir); +}; + +export const getSpawnedProcesses = () => { + return spawnedProcesses; +}; + +const run = async (args, cwd) => { + try { + await crossSpawn('node', [cliPath, ...args.filter(Boolean)], { cwd }); + } catch (err) { + if (err.stderr) { + console.error(err.stderr.toString()); //eslint-disable-line no-console + } + + throw err.toString(); + } +}; + +let spawnedProcesses = []; + +const spawn = (args, cwd) => new Promise((resolve, reject) => { + let child = spawnChild('node', [cliPath, ...args.filter(Boolean)], { cwd }); + + let errListener = err => { + reject(err); + }; + + child.on('error', errListener); + + let origKill = child.kill.bind(child); + child.kill = () => new Promise((resolve) => { + let index = spawnedProcesses.findIndex(p => p.pid === child.pid); + if (index > -1) { + spawnedProcesses.splice(index, 1); + } + + child.stdout.unpipe(process.stdout); + child.stderr.unpipe(process.stderr); + child.on('exit', code => { + resolve(code); + }); + origKill(); + }); + + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + + spawnedProcesses.push(child); + + setTimeout(() => { + child.removeListener('error', errListener); + resolve(child); + }, 500); +}); diff --git a/tests/lib/lsr.js b/tests/lib/lsr.js new file mode 100644 index 000000000..fbf6dcbb9 --- /dev/null +++ b/tests/lib/lsr.js @@ -0,0 +1,27 @@ +import fs from 'fs.promised'; +import { difference } from 'lodash'; +import { resolve } from 'path'; + +const lsr = async (path, excludes = []) => { + let contents = difference(await fs.readdir(path), excludes); + let stats = contents.reduce((agg, p) => Object.assign(agg, { [p]: {} }), {}); + + for (let content of contents) { + let contentPath = resolve(path, content); + let contentStats = await fs.stat(contentPath); + + if (contentStats.isDirectory()) { + stats[content] = { + ...(await lsr(contentPath, excludes)) + }; + } else { + stats[content] = { + size: contentStats.size + }; + } + } + + return stats; +}; + +export default lsr; diff --git a/tests/lib/output.js b/tests/lib/output.js new file mode 100644 index 000000000..423471f76 --- /dev/null +++ b/tests/lib/output.js @@ -0,0 +1,11 @@ +import { resolve } from 'path'; +import rimraf from 'rimraf'; +import promisify from 'es6-promisify'; +import mkdirp from 'mkdirp'; + +const rm = promisify(rimraf); + +export const outputPath = resolve(__dirname, '../output'); + +export const setup = () => mkdirp(outputPath); +export const clean = () => rm(outputPath); diff --git a/tests/serve.snapshot.js b/tests/serve.snapshot.js new file mode 100644 index 000000000..0e635c0e5 --- /dev/null +++ b/tests/serve.snapshot.js @@ -0,0 +1,41 @@ +export const homePageHTML = ` +
+This is the Home component.
+This is the user profile for a user named me.
+Clicked 10 times.
+