diff --git a/keymaps/ava.json b/keymaps/ava.json new file mode 100644 index 0000000..9fcb2b8 --- /dev/null +++ b/keymaps/ava.json @@ -0,0 +1,6 @@ +{ + "atom-workspace": { + "ctrl-alt-r": "ava:run", + "ctrl-alt-a": "ava:toggle" + } +} diff --git a/lib/html-renderer-helper.js b/lib/html-renderer-helper.js new file mode 100644 index 0000000..14f3f35 --- /dev/null +++ b/lib/html-renderer-helper.js @@ -0,0 +1,23 @@ +/** @babel */ + +class HtmlRendererHelper { + createContainer(cssClass = null, textContent = null) { + const element = document.createElement('div'); + if (cssClass) { + element.classList.add(cssClass); + } + if (textContent) { + element.textContent = textContent; + } + return element; + } + + createImage(src, cssClass = null) { + const img = document.createElement('img'); + img.src = src; + img.classList.add(cssClass); + return img; + } +} + +module.exports = HtmlRendererHelper; diff --git a/lib/main.js b/lib/main.js new file mode 100644 index 0000000..682aa29 --- /dev/null +++ b/lib/main.js @@ -0,0 +1,68 @@ +/** @babel */ + +import path from 'path'; +import {CompositeDisposable} from 'atom'; +import Panel from './panel.js'; +import TestRunnerProcess from './test-runner-process'; + +module.exports = { + activate(state) { + this.initRunnerProcess(); + this.initUI(state); + + this.subscriptions = new CompositeDisposable(); + + this.subscriptions.add( + atom.commands.add('atom-workspace', 'ava:toggle', () => this.toggle())); + + this.subscriptions.add( + atom.commands.add('atom-workspace', 'ava:run', () => this.run())); + }, + initRunnerProcess() { + this.testRunnerProcess = new TestRunnerProcess(); + this.testRunnerProcess.on('assert', result => this.panel.renderAssert(result)); + this.testRunnerProcess.on('complete', results => this.panel.renderFinalReport(results)); + }, + initUI() { + this.panel = new Panel(this); + this.panel.renderBase(); + this.atomPanel = atom.workspace.addRightPanel({item: this.panel, visible: false}); + }, + canRun() { + return (atom.workspace.getActiveTextEditor() && this.testRunnerProcess.canRun()); + }, + run() { + if (!this.atomPanel.isVisible()) { + this.toggle(); + } + if (!this.canRun()) { + return; + } + + const editor = atom.workspace.getActiveTextEditor(); + const currentFileName = editor.buffer.file.path; + const folder = path.dirname(currentFileName); + const file = path.basename(currentFileName); + + this.panel.renderStartProcess(file); + this.testRunnerProcess.run(folder, file); + }, + toggle() { + if (this.atomPanel.isVisible()) { + this.atomPanel.hide(); + } else { + this.atomPanel.show(); + this.run(); + } + }, + closePanel() { + this.atomPanel.hide(); + }, + deactivate() { + this.subscriptions.dispose(); + this.panel.destroy(); + }, + serialize() { + this.atomAva = this.panel.serialize(); + } +}; diff --git a/lib/panel.js b/lib/panel.js new file mode 100644 index 0000000..64d553a --- /dev/null +++ b/lib/panel.js @@ -0,0 +1,96 @@ +/** @babel */ +/* global __dirname */ + +import path from 'path'; +import fs from 'fs'; +import HtmlRendererHelper from './html-renderer-helper'; + +class Panel { + constructor(pluginInstance, htmlRendererHelper = new HtmlRendererHelper()) { + this.htmlRendererHelper = htmlRendererHelper; + this.pluginInstance = pluginInstance; + this.loadingSelector = 'sk-three-bounce'; + } + + renderBase() { + this.element = document.createElement('div'); + this.element.classList.add('ava'); + + const resolvedPath = path.resolve(__dirname, '../views/panel.html'); + this.element.innerHTML = fs.readFileSync(resolvedPath); + + this.testsContainer = this.element.getElementsByClassName('tests-container')[0]; + const closeIcon = this.element.getElementsByClassName('close-icon')[0]; + closeIcon.addEventListener('click', e => this.pluginInstance.closePanel(e), false); + } + + renderAssert(assertResult) { + const assert = assertResult.assert; + + const newTest = this.htmlRendererHelper.createContainer('test'); + newTest.classList.add(this._getCssClassForAssert(assert)); + newTest.textContent = `${assert.name}`; + this.testsContainer.appendChild(newTest); + + this._updateTestStatisticSection(assertResult); + } + + _getCssClassForAssert(assert) { + if (assert.ok) { + return (assert.skip) ? 'skipped' : 'ok'; + } + return (assert.todo) ? 'todo' : 'ko'; + } + + renderFinalReport(results) { + this.hideExecutingIndicator(); + + const summary = this.htmlRendererHelper.createContainer('summary'); + const passed = results.pass - (results.skip ? results.skip : 0); + const percentage = Math.round((passed / results.count) * 100); + summary.textContent = `${results.count} total - ${percentage}% passed`; + + this.testsContainer.appendChild(summary); + } + + cleanTestsContainer() { + this.testsContainer.innerHTML = ''; + } + + renderStartProcess(fileName) { + const fileHeader = document.getElementById('file-header'); + fileHeader.textContent = fileName; + + this.displayExecutingIndicator(); + this.cleanTestsContainer(); + } + + displayExecutingIndicator() { + const executing = document.getElementById(this.loadingSelector); + if (executing) { + executing.style.display = 'block'; + } + } + + hideExecutingIndicator() { + const executing = document.getElementById(this.loadingSelector); + if (executing) { + executing.style.display = 'none'; + } + } + + _updateTestStatisticSection(assertResult) { + const passedContainer = document.getElementById('passed'); + const failedContainer = document.getElementById('failed'); + passedContainer.textContent = assertResult.currentExecution.passed; + failedContainer.textContent = assertResult.currentExecution.failed; + } + + serialize() { } + + destroy() { + this.element.remove(); + } +} + +module.exports = Panel; diff --git a/lib/parser-factory.js b/lib/parser-factory.js new file mode 100644 index 0000000..a687514 --- /dev/null +++ b/lib/parser-factory.js @@ -0,0 +1,11 @@ +/** @babel */ + +import parser from 'tap-parser'; + +class ParserFactory { + getParser() { + return parser(); + } +} + +module.exports = ParserFactory; diff --git a/lib/terminal-command-executor.js b/lib/terminal-command-executor.js new file mode 100644 index 0000000..28ac644 --- /dev/null +++ b/lib/terminal-command-executor.js @@ -0,0 +1,47 @@ +/** @babel */ + +import EventEmitter from 'events'; +import ChildProcess from 'child_process'; + +class TerminalCommandExecutor extends EventEmitter { + constructor() { + super(); + EventEmitter.call(this); + + this.dataReceivedEventName = 'dataReceived'; + this.dataFinishedEventName = 'dataFinished'; + } + + run(command, destinyFolder = null) { + this.command = command; + this.destinyFolder = destinyFolder; + + const spawn = ChildProcess.spawn; + + this.terminal = spawn('bash', ['-l']); + this.terminal.on('close', statusCode => this._streamClosed(statusCode)); + this.terminal.stdout.on('data', data => this._stdOutDataReceived(data)); + this.terminal.stderr.on('data', data => this._stdErrDataReceived(data)); + + const terminalCommand = this.destinyFolder ? + `cd \"${this.destinyFolder}\" && ${this.command}\n` : + `${this.command}\n`; + + this.terminal.stdin.write(terminalCommand); + this.terminal.stdin.write('exit\n'); + } + + _stdOutDataReceived(newData) { + this.emit(this.dataReceivedEventName, newData.toString()); + } + + _stdErrDataReceived(newData) { + this.emit(this.dataReceivedEventName, newData.toString()); + } + + _streamClosed(code) { + this.emit(this.dataFinishedEventName, code); + } +} + +module.exports = TerminalCommandExecutor; diff --git a/lib/test-runner-process.js b/lib/test-runner-process.js new file mode 100644 index 0000000..1d9a10c --- /dev/null +++ b/lib/test-runner-process.js @@ -0,0 +1,81 @@ +/** @babel */ + +import EventEmitter from 'events'; +import TerminalCommandExecutor from './terminal-command-executor'; +import ParserFactory from './parser-factory'; + +class TestRunnerProcess extends EventEmitter { + constructor( + executor = new TerminalCommandExecutor(), + parserFactory = new ParserFactory()) { + super(); + + this.eventHandlers = {}; + this.terminalCommandExecutor = executor; + this.parserFactory = parserFactory; + + this.terminalCommandExecutor.on('dataReceived', data => this._addAvaOutput(data)); + this.terminalCommandExecutor.on('dataFinished', () => this._endAvaOutput()); + + EventEmitter.call(this); + } + + canRun() { + return !this.isRunning; + } + + run(folder, file) { + if (!this.canRun()) { + return; + } + + this.isRunning = true; + this.currentExecution = {passed: 0, failed: 0}; + + this.parser = this.parserFactory.getParser(); + this._setHandlersOnParser(this.parser); + + const command = `ava ${file} --tap`; + + this.terminalCommandExecutor.run(command, folder); + } + + _setHandlersOnParser(parser) { + const instance = this; + parser.on('assert', assert => { + instance._updateCurrentExecution(assert); + const result = { + currentExecution: this.currentExecution, assert + }; + instance.emit('assert', result); + }); + + parser.on('complete', results => this.emit('complete', results)); + } + + _updateCurrentExecution(assert) { + if (assert.ok) { + if (!assert.skip) { + this.currentExecution.passed++; + } + } else if (!assert.todo) { + this.currentExecution.failed++; + } + } + + _addAvaOutput(data) { + this.parser.write(data); + } + + _endAvaOutput() { + this.parser.end(); + this.isRunning = false; + } + + destroy() { + this.isRunning = false; + this.terminalCommandExecutor.destroy(); + } +} + +module.exports = TestRunnerProcess; diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000..a74de1d Binary files /dev/null and b/media/logo.png differ diff --git a/package.json b/package.json index d8e5777..00ea8f5 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "ava", "version": "0.2.0", "description": "Snippets for AVA", + "main": "./lib/main", "license": "MIT", "repository": "sindresorhus/atom-ava", "private": true, @@ -28,6 +29,12 @@ "scripts": { "test": "xo" }, + "activationCommands": { + "atom-workspace": [ + "ava:toggle", + "ava:run" + ] + }, "keywords": [ "snippets", "test", @@ -35,10 +42,20 @@ "ava", "mocha" ], + "dependencies": { + "tap-parser": "^1.2.2", + "ava": "^0.13.0" + }, "devDependencies": { "xo": "*" }, "xo": { + "envs": [ + "browser", + "node", + "jasmine", + "atomtest" + ], "esnext": true, "globals": [ "atom" diff --git a/spec/fake-spawn.js b/spec/fake-spawn.js new file mode 100644 index 0000000..d70d79d --- /dev/null +++ b/spec/fake-spawn.js @@ -0,0 +1,36 @@ +/** @babel */ + +class FakeSpawn { + constructor() { + this.commandsReceived = []; + const self = this; + + this.stdout = { + write: data => self.stdOutCallBack(data), + on: (event, callback) => { + self.stdOutCallBack = callback; + } + }; + this.stderr = { + write: data => self.stdErrCallBack(data), + on: (event, callback) => { + self.stdErrCallBack = callback; + } + }; + this.stdin = { + write: command => self.commandsReceived.push(command) + }; + } + + kill() { } + + on(event, callback) { + this.mainCallBack = callback; + } + + emulateClose() { + this.mainCallBack(1); + } +} + +module.exports = FakeSpawn; diff --git a/spec/main-spec.js b/spec/main-spec.js new file mode 100644 index 0000000..1cbe7b7 --- /dev/null +++ b/spec/main-spec.js @@ -0,0 +1,37 @@ +/** @babel */ + +describe('TestingForAva', () => { + const packageName = 'ava'; + const mainSelector = '.ava'; + const toggleCommand = 'ava:toggle'; + let workspaceElement = []; + let activationPromise = []; + + beforeEach(() => { + workspaceElement = atom.views.getView(atom.workspace); + activationPromise = atom.packages.activatePackage(packageName); + + const editor = {buffer: {file: {path: '/this/is/a/path/file.js'}}}; + + spyOn(atom.workspace, 'getActiveTextEditor').andReturn(editor); + }); + + describe('when the ava:toggle event is triggered', () => { + it('hides and shows the view', () => { + jasmine.attachToDOM(workspaceElement); + + expect(workspaceElement.querySelector(mainSelector)).not.toExist(); + + atom.commands.dispatch(workspaceElement, toggleCommand); + + waitsForPromise(() => activationPromise); + + runs(() => { + const mainElement = workspaceElement.querySelector(mainSelector); + expect(mainElement).toBeVisible(); + atom.commands.dispatch(workspaceElement, toggleCommand); + expect(mainElement).not.toBeVisible(); + }); + }); + }); +}); diff --git a/spec/terminal-command-executor-spec.js b/spec/terminal-command-executor-spec.js new file mode 100644 index 0000000..fe5e5fb --- /dev/null +++ b/spec/terminal-command-executor-spec.js @@ -0,0 +1,53 @@ +/** @babel */ + +import ChildProcess from 'child_process'; +import TerminalCommandExecutor from '../lib/terminal-command-executor'; +import FakeSpawn from './fake-spawn'; + +describe('TerminalCommandExecutor', () => { + let executor = {}; + let fake = {}; + let stdOutData = {}; + let exitCode = {}; + + beforeEach(() => { + stdOutData = ''; + exitCode = -1; + fake = new FakeSpawn(); + executor = new TerminalCommandExecutor(); + spyOn(ChildProcess, 'spawn').andReturn(fake); + spyOn(fake, 'kill'); + }); + + it('can be created', () => expect(executor).not.toBeNull()); + + it('writes the command and exits if not destination folder is provided', () => { + executor.run('command'); + expect(fake.commandsReceived[0]).toBe('command\n'); + expect(fake.commandsReceived[1]).toBe('exit\n'); + }); + + it('writes the folder, command and exits if folder is provided', () => { + executor.run('command', 'dir'); + expect(fake.commandsReceived[0]).toBe('cd "dir" && command\n'); + expect(fake.commandsReceived[1]).toBe('exit\n'); + }); + + it('calls the callback when new data appears in stdout', () => { + executor.run('command'); + executor.on('dataReceived', data => { + stdOutData = data; + }); + fake.stdout.write('some data'); + expect(stdOutData).toBe('some data'); + }); + + it('calls the callback when the stream is closed', () => { + executor.run('command'); + executor.on('dataFinished', code => { + exitCode = code; + }); + fake.emulateClose(); + expect(exitCode).toBe(1); + }); +}); diff --git a/spec/test-runner-process-spec.js b/spec/test-runner-process-spec.js new file mode 100644 index 0000000..748dcfa --- /dev/null +++ b/spec/test-runner-process-spec.js @@ -0,0 +1,128 @@ +/** @babel */ + +import EventEmitter from 'events'; +import TestRunnerProcess from '../lib/test-runner-process'; +import TerminalCommandExecutor from '../lib/terminal-command-executor'; +import ParserFactory from '../lib/parser-factory'; + +class FakeParser extends EventEmitter { + constructor() { + super(); + EventEmitter.call(this); + } + + emitAssert(assert) { + this.emit('assert', assert); + } + + write() { } + end() { } +} + +describe('TestRunnerProcess', () => { + let runner = {}; + let executor = {}; + let parser = {}; + let parserFactory = {}; + + class TerminalCommandExecutorDouble extends TerminalCommandExecutor { + emulateDataWrittenStdOut(data) { + this.emit(this.dataReceivedEventName, data); + } + + emulateDataFinished(statusCode) { + this.emit(this.dataFinishedEventName, statusCode); + } + } + + beforeEach(() => { + parser = new FakeParser(); + executor = new TerminalCommandExecutorDouble(); + parserFactory = new ParserFactory(); + spyOn(parserFactory, 'getParser').andReturn(parser); + + runner = new TestRunnerProcess(executor, parserFactory); + }); + + it('can be created', () => expect(runner).not.toBeNull()); + + it('runs the executor with the appropriate parameters', () => { + spyOn(atom.project, 'getPaths').andReturn(['path']); + spyOn(executor, 'run'); + runner.run('/somefolder/', 'filename'); + expect(executor.run).toHaveBeenCalledWith('ava filename --tap', '/somefolder/'); + }); + + it('redirects the output for the parser when is received', () => { + spyOn(parser, 'write'); + runner.run('/somefolder/', 'filename'); + executor.emulateDataWrittenStdOut('newdata'); + expect(parser.write).toHaveBeenCalledWith('newdata'); + }); + + it('closes the parser stream when the output is over', () => { + spyOn(parser, 'end'); + runner.run('/somefolder/', 'filename'); + executor.emulateDataFinished(0); + expect(parser.end).toHaveBeenCalled(); + }); + + it('prevents multiple executions', () => { + spyOn(executor, 'run'); + runner.run('/somefolder/', 'filename'); + runner.run('/somefolder/', 'filename'); + expect(executor.run.callCount).toBe(1); + }); + + it('informs about the state of the execution', () => { + spyOn(executor, 'run'); + expect(runner.canRun()).toBe(true); + runner.run('/somefolder/', 'filename'); + expect(runner.canRun()).toBe(false); + executor.emulateDataFinished(0); + expect(runner.canRun()).toBe(true); + }); + + it('emits assertion with the correct format', () => { + const receivedAssertResults = []; + const okAssertResult = {ok: true}; + const notOkAssertResult = {ok: false}; + + runner.run('/somefolder/', 'filename'); + runner.on('assert', result => receivedAssertResults.push(result)); + + parser.emitAssert(okAssertResult); + parser.emitAssert(notOkAssertResult); + + expect(receivedAssertResults[0].assert).toBe(okAssertResult); + expect(receivedAssertResults[0].currentExecution.passed).toBe(1); + expect(receivedAssertResults[1].assert).toBe(notOkAssertResult); + expect(receivedAssertResults[1].currentExecution.passed).toBe(1); + }); + + it('does not count skipped tests as success', () => { + const receivedAssertResults = []; + const assertResult = {ok: true, skip: true}; + runner.run('/somefolder/', 'filename'); + runner.on('assert', result => receivedAssertResults.push(result)); + + parser.emitAssert(assertResult); + + expect(receivedAssertResults[0].assert).toBe(assertResult); + expect(receivedAssertResults[0].currentExecution.passed).toBe(0); + expect(receivedAssertResults[0].currentExecution.failed).toBe(0); + }); + + it('does not count todo tests as failed', () => { + const receivedAssertResults = []; + const assertResult = {ok: false, todo: true}; + runner.run('/somefolder/', 'filename'); + runner.on('assert', result => receivedAssertResults.push(result)); + + parser.emitAssert(assertResult); + + expect(receivedAssertResults[0].assert).toBe(assertResult); + expect(receivedAssertResults[0].currentExecution.passed).toBe(0); + expect(receivedAssertResults[0].currentExecution.failed).toBe(0); + }); +}); diff --git a/styles/ava.less b/styles/ava.less new file mode 100644 index 0000000..1aed8b5 --- /dev/null +++ b/styles/ava.less @@ -0,0 +1,93 @@ +@import 'ui-variables'; +@import "loading-indicator.less"; + +@horizontal-dividers-color: #DDD; + +.ava { + width: 350px; + font-size: 15px; + overflow: scroll; + + .close-icon { + text-align: right; + margin-right: 10px; + margin-top: 10px; + font-size: 16px; + cursor: pointer; + } + + #file-header { + background-color: @tab-bar-background-color; + color: #888; + padding: 7px; + font-size: 12px; + font-weight: bold; + border-top: 1px solid @tab-bar-border-color; + border-bottom: 1px solid @tab-bar-border-color; + color: @text-color; + opacity: 0.5; + } + + .ava-logo-container { + text-align: center; + .ava-logo-image { + width: 110px; + } + } + + .test-statistics { + display: table; + margin: 0 auto; + text-align: center; + padding-bottom: 5px; + + .number { + font-family: "Helvetica Neue", Helvetica; + font-weight: lighter; + font-size: 42px; + text-align: center; + } + .text { + font-size: 13px; + letter-spacing: -0.5px; + text-align: center; + margin-top: -10px; + } + + .test-statistics-passed-container { + float:left; + padding-right: 20px; + } + .test-statistics-failed-container { + float:left; + } + } + + .tests-container { + padding: 10px 30px; + + .test { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + } + + .ok { color: #6D9669; } + .ko { color: #B03952; } + .skipped { color: @text-color-subtle; } + .todo { color: @text-color-subtle; } + } + + .message { padding-bottom: 10px; } + + .summary { + text-align: center; + padding: 10px; + margin-top: 15px; + border-top: 1px solid @horizontal-dividers-color; + font-size: 12px; + } + + #executing { height: 35px; } +} diff --git a/styles/loading-indicator.less b/styles/loading-indicator.less new file mode 100644 index 0000000..d4ebf40 --- /dev/null +++ b/styles/loading-indicator.less @@ -0,0 +1,34 @@ +#sk-three-bounce { + margin: 0px auto; + width: 80px; + text-align: center; + display:none; +} + #sk-three-bounce .sk-child { + width: 6px; + height: 6px; + background-color: #555; + border-radius: 100%; + display: inline-block; + -webkit-animation: sk-three-bounce 1.4s ease-in-out 0s infinite both; + animation: sk-three-bounce 1.4s ease-in-out 0s infinite both; } + #sk-three-bounce .sk-bounce1 { + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; } + #sk-three-bounce .sk-bounce2 { + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; } +@-webkit-keyframes sk-three-bounce { + 0%, 80%, 100% { + -webkit-transform: scale(0); + transform: scale(0); } + 40% { + -webkit-transform: scale(1); + transform: scale(1); } } +@keyframes sk-three-bounce { + 0%, 80%, 100% { + -webkit-transform: scale(0); + transform: scale(0); } + 40% { + -webkit-transform: scale(1); + transform: scale(1); } } diff --git a/views/panel.html b/views/panel.html new file mode 100644 index 0000000..7bf8042 --- /dev/null +++ b/views/panel.html @@ -0,0 +1,23 @@ +
+