diff --git a/build/Gruntfile.coffee b/build/Gruntfile.coffee index 4355fc6a0..de6240965 100644 --- a/build/Gruntfile.coffee +++ b/build/Gruntfile.coffee @@ -356,7 +356,7 @@ module.exports = (grunt) -> grunt.registerTask('compile', ['coffee', 'cjsx', 'babel', 'prebuild-less', 'cson', 'peg']) grunt.registerTask('lint', ['coffeelint', 'csslint', 'lesslint', 'nylaslint', 'eslint']) - grunt.registerTask('test', ['shell:kill-n1', 'run-edgehill-specs']) + grunt.registerTask('test', ['shell:kill-n1', 'run-unit-tests']) grunt.registerTask('docs', ['build-docs', 'render-docs']) ciTasks = ['output-disk-space', 'download-electron', 'build'] diff --git a/build/tasks/run-integration-tests-task.coffee b/build/tasks/run-integration-tests-task.coffee new file mode 100644 index 000000000..307285765 --- /dev/null +++ b/build/tasks/run-integration-tests-task.coffee @@ -0,0 +1,20 @@ +path = require 'path' +childProcess = require 'child_process' + +module.exports = (grunt) -> + desc = "Boots Selenium via Spectron to run integration tests" + grunt.registerTask 'run-integration-tests', desc, -> + done = @async() + + rootPath = path.resolve('.') + npmPath = path.join(rootPath, "build", "node_modules", ".bin", "npm") + + process.chdir('./spec_integration') + testProc = childProcess.spawn(npmPath, + ["test", "NYLAS_ROOT_PATH=#{rootPath}"], + {stdio: "inherit"}) + + testProc.on 'exit', (exitCode, signal) -> + process.chdir('..') + if exitCode is 0 then done() + else done(false) diff --git a/build/tasks/run-unit-tests-task.coffee b/build/tasks/run-unit-tests-task.coffee new file mode 100644 index 000000000..c8226b018 --- /dev/null +++ b/build/tasks/run-unit-tests-task.coffee @@ -0,0 +1,25 @@ +childProcess = require 'child_process' + +module.exports = (grunt) -> + {notifyAPI} = require('./task-helpers')(grunt) + + desc = "Boots N1 in --test mode to run unit tests" + grunt.registerTask 'run-unit-tests', desc, -> + done = @async() + + testProc = childProcess.spawn("./N1.sh", ["--test"]) + + testOutput = "" + testProc.stdout.pipe(process.stdout) + testProc.stderr.pipe(process.stderr) + testProc.stdout.on 'data', (data) -> testOutput += data.toString() + testProc.stderr.on 'data', (data) -> testOutput += data.toString() + + testProc.on 'error', (err) -> grunt.log.error("Process error: #{err}") + + testProc.on 'exit', (exitCode, signal) -> + if exitCode is 0 then done() + else + testOutput = grunt.log.uncolor(testOutput) + msg = "Aghhh somebody broke the build. ```#{testOutput}```" + notifyAPI msg, -> done(false) diff --git a/build/tasks/spec-task.coffee b/build/tasks/spec-task.coffee deleted file mode 100644 index 413b77af2..000000000 --- a/build/tasks/spec-task.coffee +++ /dev/null @@ -1,78 +0,0 @@ -fs = require 'fs' -path = require 'path' -request = require 'request' -childProcess = require 'child_process' - -executeTests = ({cmd, args}, grunt, done) -> - testProc = childProcess.spawn(cmd, args) - - testOutput = "" - testProc.stdout.pipe(process.stdout) - testProc.stderr.pipe(process.stderr) - testProc.stdout.on 'data', (data) -> testOutput += data.toString() - testProc.stderr.on 'data', (data) -> testOutput += data.toString() - - testProc.on 'error', (err) -> grunt.log.error("Process error: #{err}") - - testProc.on 'exit', (exitCode, signal) -> - if exitCode is 0 - done() - else - notifyOfTestError testOutput, grunt, -> - done(false) - -notifyOfTestError = (testOutput, grunt, callback) -> - if (process.env["TEST_ERROR_HOOK_URL"] ? "").length > 0 - testOutput = grunt.log.uncolor(testOutput) - request.post - url: process.env["TEST_ERROR_HOOK_URL"] - json: - username: "Edgehill Builds" - text: "Aghhh somebody broke the build. ```#{testOutput}```" - , callback - else - callback() - - -module.exports = (grunt) -> - - grunt.registerTask 'run-edgehill-specs', 'Run the specs', -> - done = @async() - executeTests({cmd: './N1.sh', args: ['--test']}, grunt, done) - - grunt.registerTask 'run-spectron-specs', 'Run spectron specs', -> - shellAppDir = grunt.config.get('nylasGruntConfig.shellAppDir') - - if process.platform is 'darwin' - executablePath = path.join(shellAppDir, 'Contents', 'MacOS', 'Nylas') - else - executablePath = path.join(shellAppDir, 'nylas') - - done = @async() - npmPath = path.resolve "./build/node_modules/.bin/npm" - - if process.platform is 'win32' - grunt.log.error("run-spectron-specs only works on Mac OS X at the moment.") - done(false) - - if not fs.existsSync(executablePath) - grunt.log.error("run-spectron-specs requires the built version of the app at #{executablePath}") - done(false) - - process.chdir('./spectron') - grunt.log.writeln "Current dir: #{process.cwd()}" - installProc = childProcess.exec "#{npmPath} install", (error) -> - if error? - process.chdir('..') - grunt.log.error('Failed while running npm install in spectron folder') - grunt.fail.warn(error) - done(false) - else - appArgs = [ - 'test' - "APP_PATH=#{executablePath}" - "APP_ARGS=" - ] - executeTests {cmd: npmPath, args: appArgs}, grunt, (succeeded) -> - process.chdir('..') - done(succeeded) diff --git a/build/tasks/task-helpers.coffee b/build/tasks/task-helpers.coffee index e53a260d2..fa0fb6f92 100644 --- a/build/tasks/task-helpers.coffee +++ b/build/tasks/task-helpers.coffee @@ -1,5 +1,6 @@ fs = require 'fs-plus' path = require 'path' +request = require 'request' module.exports = (grunt) -> cp: (source, destination, {filter}={}) -> @@ -66,3 +67,14 @@ module.exports = (grunt) -> engines?.nylas? catch error false + + notifyAPI: (msg, callback) -> + if (process.env("TEST_ERROR_HOOK_URL") ? "").length > 0 + request.post + url: process.env("TEST_ERROR_HOOK_URL") + json: + username: "Edgehill Builds" + text: msg + , callback + else callback() + diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index fd232343a..1f4535f76 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -124,7 +124,7 @@ useFullDraft = -> replyToMessageId: null makeComposer = -> - @composer = ReactTestUtils.renderIntoDocument( + @composer = NylasTestUtils.renderIntoDocument( ) @@ -135,6 +135,7 @@ describe "populated composer", -> afterEach -> DraftStore._cleanupAllSessions() + NylasTestUtils.removeFromDocument(@composer) describe "when sending a new message", -> it 'makes a request with the message contents', -> @@ -366,7 +367,7 @@ describe "populated composer", -> it "ignores focuses to participant fields", -> @composer.setState focusedField: Fields.To expect(@body.focus).not.toHaveBeenCalled() - expect(React.findDOMNode.calls.length).toBe 3 + expect(@composer._applyFieldFocus.calls.length).toBe 1 describe "when participants are added during a draft update", -> it "shows the cc fields and bcc fields to ensure participants are never hidden", -> @@ -542,12 +543,12 @@ describe "populated composer", -> cmdctrl = 'cmd' else cmdctrl = 'ctrl' - NylasTestUtils.keyPress("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) expect(Actions.sendDraft).toHaveBeenCalled() expect(Actions.sendDraft.calls.length).toBe 1 it "does not send the draft on enter if the button isn't in focus", -> - NylasTestUtils.keyPress("enter", React.findDOMNode(@$composer)) + NylasTestUtils.keyDown("enter", React.findDOMNode(@$composer)) expect(Actions.sendDraft).not.toHaveBeenCalled() it "doesn't let you send twice", -> @@ -555,12 +556,12 @@ describe "populated composer", -> cmdctrl = 'cmd' else cmdctrl = 'ctrl' - NylasTestUtils.keyPress("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) expect(Actions.sendDraft).toHaveBeenCalled() expect(Actions.sendDraft.calls.length).toBe 1 @isSending = true DraftStore.trigger() - NylasTestUtils.keyPress("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) expect(Actions.sendDraft).toHaveBeenCalled() expect(Actions.sendDraft.calls.length).toBe 1 diff --git a/package.json b/package.json index 3b38593ed..db5526861 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "immutable": "3.7.5", "inflection": "^1.7", "jasmine-json": "~0.0", - "jasmine-tagged": "^1.1.2", "jasmine-react-helpers": "^0.2", + "jasmine-tagged": "^1.1.2", "jquery": "^2.1.1", "juice": "^1.4", "less-cache": "0.21", @@ -41,13 +41,13 @@ "mkdirp": "^0.5", "moment": "^2.8", "moment-timezone": "^0.3", - "nslog": "^3", - "node-uuid": "^1.4", "nock": "^2", + "node-uuid": "^1.4", + "nslog": "^3", "optimist": "0.4.0", "pathwatcher": "~6.2", - "property-accessors": "^1", "promise-queue": "2.1.1", + "property-accessors": "^1", "proxyquire": "1.3.1", "q": "^1.0.1", "raven": "0.7.2", @@ -63,13 +63,16 @@ "semver": "^4.2", "serializable": "^1", "service-hub": "^0.2.0", - "space-pen": "3.8.2", - "spellchecker": "^3.1.2", "source-map-support": "^0.3.2", + "space-pen": "3.8.2", + "spectron": "0.34.1", + "spellchecker": "^3.1.2", + "sqlite3": "https://github.com/mapbox/node-sqlite3/archive/v3.1.1.tar.gz", "temp": "^0.8", "theorist": "^1.0", "underscore": "^1.8", - "underscore.string": "^3.0" + "underscore.string": "^3.0", + "webdriverio": "3.2.6" }, "packageDependencies": {}, "private": true, diff --git a/script/bootstrap b/script/bootstrap index 25de03c44..e274009bc 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -145,6 +145,12 @@ function bootstrap() { var downloadElectronCmd = gruntPath + " download-electron --gruntfile build/Gruntfile.coffee" m7 += " $ "+downloadElectronCmd + var integrationCommand = npmPath + npmFlags + 'install'; + var integrationOptions = {cwd: path.resolve(__dirname, '..', 'spec_integration')}; + + m8 = "\n\n---> Installing integration test modules\n\n" + m8 += " $ "+integrationCommand+" "+printArgs(integrationOptions)+"\n" + var commands = [ { command: buildInstallCommand, @@ -211,6 +217,11 @@ function bootstrap() { command: downloadElectronCmd, message: m7 }, + { + command: integrationCommand, + options: integrationOptions, + message: m8 + }, { command: sqlite3Command, message: "Building sqlite3 with command: "+sqlite3Command diff --git a/spec/components/contenteditable/automatic-list-manager-spec.coffee b/spec/components/contenteditable/automatic-list-manager-spec.coffee deleted file mode 100644 index 13dc1310f..000000000 --- a/spec/components/contenteditable/automatic-list-manager-spec.coffee +++ /dev/null @@ -1,105 +0,0 @@ - -xdescribe "ListManager", -> - beforeEach -> - @ce = new ContenteditableTestHarness - - it "Creates ordered lists", -> - @ce.type ['1', '.', ' '] - @ce.expectHTML "
" - @ce.expectSelection (dom) -> - dom.querySelectorAll("li")[0] - - it "Undoes ordered list creation with backspace", -> - @ce.type ['1', '.', ' ', 'backspace'] - @ce.expectHTML "1. " - @ce.expectSelection (dom) -> - node: dom.childNodes[0] - offset: 3 - - it "Creates unordered lists with star", -> - @ce.type ['*', ' '] - @ce.expectHTML "" - @ce.expectSelection (dom) -> - dom.querySelectorAll("li")[0] - - it "Undoes unordered list creation with backspace", -> - @ce.type ['*', ' ', 'backspace'] - @ce.expectHTML "* " - @ce.expectSelection (dom) -> - node: dom.childNodes[0] - offset: 2 - - it "Creates unordered lists with dash", -> - @ce.type ['-', ' '] - @ce.expectHTML "" - @ce.expectSelection (dom) -> - dom.querySelectorAll("li")[0] - - it "Undoes unordered list creation with backspace", -> - @ce.type ['-', ' ', 'backspace'] - @ce.expectHTML "- " - @ce.expectSelection (dom) -> - node: dom.childNodes[0] - offset: 2 - - it "create a single item then delete it with backspace", -> - @ce.type ['-', ' ', 'a', 'left', 'backspace'] - @ce.expectHTML "a" - @ce.expectSelection (dom) -> - node: dom.childNodes[0] - offset: 0 - - it "create a single item then delete it with tab", -> - @ce.type ['-', ' ', 'a', 'shift-tab'] - @ce.expectHTML "a" - @ce.expectSelection (dom) -> dom.childNodes[0] - node: dom.childNodes[0] - offset: 1 - - describe "when creating two items in a list", -> - beforeEach -> - @twoItemKeys = ['-', ' ', 'a', 'enter', 'b'] - - it "creates two items with enter at end", -> - @ce.type @twoItemKeys - @ce.expectHTML "" - @ce.expectSelection (dom) -> - node: dom.querySelectorAll('li')[1].childNodes[0] - offset: 1 - - it "backspace from the start of the 1st item outdents", -> - @ce.type @twoItemKeys.concat ['left', 'up', 'backspace'] - - it "backspace from the start of the 2nd item outdents", -> - @ce.type @twoItemKeys.concat ['left', 'backspace'] - - it "shift-tab from the start of the 1st item outdents", -> - @ce.type @twoItemKeys.concat ['left', 'up', 'shift-tab'] - - it "shift-tab from the start of the 2nd item outdents", -> - @ce.type @twoItemKeys.concat ['left', 'shift-tab'] - - it "shift-tab from the end of the 1st item outdents", -> - @ce.type @twoItemKeys.concat ['up', 'shift-tab'] - - it "shift-tab from the end of the 2nd item outdents", -> - @ce.type @twoItemKeys.concat ['shift-tab'] - - it "backspace from the end of the 1st item doesn't outdent", -> - @ce.type @twoItemKeys.concat ['up', 'backspace'] - - it "backspace from the end of the 2nd item doesn't outdent", -> - @ce.type @twoItemKeys.concat ['backspace'] - - describe "multi-depth bullets", -> - it "creates multi level bullet when tabbed in", -> - @ce.type ['-', ' ', 'a', 'tab'] - - it "creates multi level bullet when tabbed in", -> - @ce.type ['-', ' ', 'tab', 'a'] - - it "returns to single level bullet on backspace", -> - @ce.type ['-', ' ', 'a', 'tab', 'left', 'backspace'] - - it "returns to single level bullet on shift-tab", -> - @ce.type ['-', ' ', 'a', 'tab', 'shift-tab'] diff --git a/spec/components/contenteditable/contenteditable-test-harness.cjsx b/spec/components/contenteditable/contenteditable-test-harness.cjsx new file mode 100644 index 000000000..45093211a --- /dev/null +++ b/spec/components/contenteditable/contenteditable-test-harness.cjsx @@ -0,0 +1,110 @@ +_ = require 'underscore' +React = require 'react/addons' +TimeOverride = require '../../time-override' +NylasTestUtils = require '../../nylas-test-utils' + +{Contenteditable} = require 'nylas-component-kit' + +### +Public: Easily test contenteditable interactions + +Create a new instance of this on each test. It will render a new +Contenteditable into the document wrapped around a class that can keep +track of its state. + +For example + +```coffee +beforeEach -> + @ce = new ContenteditableTestHarness + +it "can create an ordered list", -> + @ce.keys ['1', '.', ' '] + @ce.expectHTML "
" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll("li")[0] + +afterEach -> + @ce.cleanup() + +``` + +**Be sure to call `cleanup` after each test** + +### +class ContenteditableTestHarness + constructor: ({@props, @initialValue}={}) -> + @props ?= {} + @initialValue ?= "" + + @wrap = NylasTestUtils.renderIntoDocument( + + ) + + cleanup: -> + NylasTestUtils.removeFromDocument(@wrap) + + # We send keys to spectron one at a time. We also need ot use a "real" + # setTimeout since Spectron is completely outside of the mocked setTimeouts + # that we setup. We still `advanceClock` to clear any components or Promises + # that need to run inbetween keystrokes. + keys: (keyStrokes=[]) -> new Promise (resolve, reject) => + TimeOverride.disableSpies() + @getDOM().focus() + timeout = 0 + KEY_DELAY = 1000 + keyStrokes.forEach (key) -> + window.setTimeout -> + NylasEnv.spectron.client.keys([key]) + advanceClock(KEY_DELAY) + , timeout + timeout += KEY_DELAY + window.setTimeout -> + resolve() + , timeout + KEY_DELAY + + expectHTML: (expectedHTML) -> + expect(@wrap.state.value).toBe expectedHTML + + expectSelection: (callback) -> + expectSel = callback(@getDOM()) + + anchorNode = expectSel.anchorNode ? expectSel.node ? "No anchorNode found" + focusNode = expectSel.focusNode ? expectSel.node ? "No focusNode found" + anchorOffset = expectSel.anchorOffset ? expectSel.offset ? 0 + focusOffset = expectSel.focusOffset ? expectSel.offset ? 0 + + selection = document.getSelection() + + expect(selection.anchorNode).toBe anchorNode + expect(selection.focusNode).toBe focusNode + expect(selection.anchorOffset).toBe anchorOffset + expect(selection.focusOffset).toBe focusOffset + + getDOM: -> + React.findDOMNode(@wrap.refs["ceWrap"].refs["contenteditable"]) + +class Wrap extends React.Component + @displayName: "wrap" + + constructor: (@props) -> + @state = value: @props.initialValue + + render: -> + userOnChange = @props.ceProps.onChange ? -> + props = _.clone(@props.ceProps) + props.onChange = (event) => + userOnChange(event) + @onChange(event) + props.value = @state.value + props.ref = "ceWrap" + + + + onChange: (event) -> + @setState value: event.target.value + + componentDidMount: -> + @refs.ceWrap.focus() + +module.exports = ContenteditableTestHarness diff --git a/spec/components/contenteditable/list-manager-spec.coffee b/spec/components/contenteditable/list-manager-spec.coffee new file mode 100644 index 000000000..e558ff758 --- /dev/null +++ b/spec/components/contenteditable/list-manager-spec.coffee @@ -0,0 +1,122 @@ +ContenteditableTestHarness = require './contenteditable-test-harness' + +return unless NylasEnv.inIntegrationSpecMode() + +fdescribe "ListManager", -> + beforeEach -> + # console.log "--> Before each" + @ce = new ContenteditableTestHarness + # div = document.querySelector("div[contenteditable]") + # console.log div + # console.log div?.innerHTML + # console.log "Done before each" + + afterEach -> + # console.log "<-- After each" + @ce.cleanup() + + it "Creates ordered lists", -> waitsForPromise => + @ce.keys(['1', '.', 'Space']).then => + # console.log "Keys typed" + @ce.expectHTML "
" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll("li")[0] + + ffit "Undoes ordered list creation with backspace", -> waitsForPromise => + @ce.keys(['1', '.', 'Space', 'Back space']).then => + @ce.expectHTML "1. " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 3 + + it "Creates unordered lists with star", -> waitsForPromise => + @ce.keys(['*', 'Space']).then => + @ce.expectHTML "
" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll("li")[0] + + xit "Undoes unordered list creation with backspace", -> + aitsForPromise => + @ce.keys(['*', 'Space', 'Back space']).then => + @ce.expectHTML "* " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 2 + + it "Creates unordered lists with dash", -> waitsForPromise => + @ce.keys(['-', 'Space']).then => + @ce.expectHTML "
" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll("li")[0] + + it "Undoes unordered list creation with backspace", -> + waitsForPromise => + @ce.keys(['-', 'Space', 'Back space']).then => + @ce.expectHTML "- " + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 2 + + it "create a single item then delete it with backspace", -> + waitsForPromise => + @ce.keys(['-', 'Space', 'a', 'Left arrow', 'Back space']).then => + @ce.expectHTML "a" + @ce.expectSelection (dom) -> + node: dom.childNodes[0] + offset: 0 + + it "create a single item then delete it with tab", -> + waitsForPromise => + @ce.keys(['-', 'Space', 'a', 'Shift', 'Tab']).then => + @ce.expectHTML "a" + @ce.expectSelection (dom) -> dom.childNodes[0] + node: dom.childNodes[0] + offset: 1 + + describe "when creating two items in a list", -> + beforeEach -> + @twoItemKeys = ['-', 'Space', 'a', 'Return', 'b'] + + it "creates two items with enter at end", -> waitsForPromise => + @ce.keys(@twoItemKeys).then => + @ce.expectHTML "
  • a
  • b
" + @ce.expectSelection (dom) -> + node: dom.querySelectorAll('li')[1].childNodes[0] + offset: 1 + + xit "backspace from the start of the 1st item outdents", -> + @ce.keys @twoItemKeys.concat ['left', 'up', 'backspace'] + + xit "backspace from the start of the 2nd item outdents", -> + @ce.keys @twoItemKeys.concat ['left', 'backspace'] + + xit "shift-tab from the start of the 1st item outdents", -> + @ce.keys @twoItemKeys.concat ['left', 'up', 'shift-tab'] + + xit "shift-tab from the start of the 2nd item outdents", -> + @ce.keys @twoItemKeys.concat ['left', 'shift-tab'] + + xit "shift-tab from the end of the 1st item outdents", -> + @ce.keys @twoItemKeys.concat ['up', 'shift-tab'] + + xit "shift-tab from the end of the 2nd item outdents", -> + @ce.keys @twoItemKeys.concat ['shift-tab'] + + xit "backspace from the end of the 1st item doesn't outdent", -> + @ce.keys @twoItemKeys.concat ['up', 'backspace'] + + xit "backspace from the end of the 2nd item doesn't outdent", -> + @ce.keys @twoItemKeys.concat ['backspace'] + + xdescribe "multi-depth bullets", -> + it "creates multi level bullet when tabbed in", -> + @ce.keys ['-', ' ', 'a', 'tab'] + + it "creates multi level bullet when tabbed in", -> + @ce.keys ['-', ' ', 'tab', 'a'] + + it "returns to single level bullet on backspace", -> + @ce.keys ['-', ' ', 'a', 'tab', 'left', 'backspace'] + + it "returns to single level bullet on shift-tab", -> + @ce.keys ['-', ' ', 'a', 'tab', 'shift-tab'] diff --git a/spec/jasmine-helper.coffee b/spec/jasmine-helper.coffee index dc26db729..db7271423 100644 --- a/spec/jasmine-helper.coffee +++ b/spec/jasmine-helper.coffee @@ -38,16 +38,24 @@ module.exports.runSpecSuite = (specSuite, logFile, logErrors=true) -> N1SpecReporter = require './n1-spec-reporter' reporter = new N1SpecReporter() - require specSuite + NylasEnv.initialize() - jasmineEnv = jasmine.getEnv() - jasmineEnv.addReporter(reporter) - jasmineEnv.addReporter(timeReporter) - jasmineEnv.setIncludedTags([process.platform]) + # Tests that run under an integration environment need Spectron to be + # asynchronously setup and connected to the Selenium API before proceeding. + # Once setup, one can test `NylasEnv.inIntegrationSpecMode()` + # + # This safely works regardless if Spectron is loaded. + NylasEnv.setupSpectron().finally -> + require specSuite - $('body').append $$ -> @div id: 'jasmine-content' + jasmineEnv = jasmine.getEnv() + jasmineEnv.addReporter(reporter) + jasmineEnv.addReporter(timeReporter) + jasmineEnv.setIncludedTags([process.platform]) - jasmineEnv.execute() + $('body').append $$ -> @div id: 'jasmine-content' + + jasmineEnv.execute() disableFocusMethods = -> ['fdescribe', 'ffdescribe', 'fffdescribe', 'fit', 'ffit', 'fffit'].forEach (methodName) -> diff --git a/spec/n1-spec-reporter.coffee b/spec/n1-spec-reporter.coffee index d06ca72ad..a0598c09e 100644 --- a/spec/n1-spec-reporter.coffee +++ b/spec/n1-spec-reporter.coffee @@ -7,7 +7,7 @@ grim = require 'grim' marked = require 'marked' sourceMaps = {} -formatStackTrace = (spec, message='', stackTrace) -> +formatStackTrace = (spec, message='', stackTrace, indent="") -> return stackTrace unless stackTrace jasminePattern = /^\s*at\s+.*\(?.*[/\\]jasmine(-[^/\\]*)?\.js:\d+:\d+\)?\s*$/ @@ -32,8 +32,8 @@ formatStackTrace = (spec, message='', stackTrace) -> # Relativize locations to spec directory lines[index] = line.replace("at #{spec.specDirectory}#{path.sep}", 'at ') - lines = lines.map (line) -> line.trim() - lines.join('\n').trim() + lines = lines.map (line) -> indent + line.trim() + lines.join('\n') module.exports = class N1SpecReporter extends View @@ -60,6 +60,7 @@ class N1SpecReporter extends View @span outlet: 'deprecationStatus', '0 deprecations' @div class: 'deprecation-toggle' @div outlet: 'deprecationList', class: 'deprecation-list' + @pre outlet: "plainTextOutput", class: 'plain-text-output' startedAt: null runningSpecCount: 0 @@ -92,6 +93,8 @@ class N1SpecReporter extends View else @message.text "#{@failedCount} failures" + @status.addClass("specs-complete") + reportSuiteResults: (suite) -> reportSpecResults: (spec) -> @@ -99,6 +102,45 @@ class N1SpecReporter extends View spec.endedAt = Date.now() @specComplete(spec) @updateStatusView(spec) + @reportPlainTextSpecResult(spec) + + reportPlainTextSpecResult: (spec) -> + str = "" + if spec.results().failedCount > 0 + str += @suiteString(spec) + "\n" + indent = @indentationString(spec.suite, 1) + stackIndent = @indentationString(spec.suite, 2) + + description = spec.description + description = "it #{description}" if description.indexOf('it ') isnt 0 + str += indent + description + "\n" + + for result in spec.results().getItems() + continue if result.passed() + str += indent + result.message + "\n" + stackTrace = formatStackTrace(spec, result.message, result.trace.stack, stackIndent) + str += stackTrace + "\n" + str += "\n\n" + @plainTextOutput.append(str) + + indentationString: (suite, plus=0) -> + rootSuite = suite + indentLevel = 0 + plus + while rootSuite.parentSuite + rootSuite = rootSuite.parentSuite + indentLevel += 1 + return [0...indentLevel].map(-> " ").join("") + + suiteString: (spec) -> + descriptions = [spec.suite.description] + + rootSuite = spec.suite + while rootSuite.parentSuite + indent = @indentationString(rootSuite) + descriptions.unshift(indent + rootSuite.description) + rootSuite = rootSuite.parentSuite + + descriptions.join("\n") reportSpecStarting: (spec) -> @specStarted(spec) diff --git a/spec/nylas-test-utils.coffee b/spec/nylas-test-utils.coffee index 453438a48..fe27afffe 100644 --- a/spec/nylas-test-utils.coffee +++ b/spec/nylas-test-utils.coffee @@ -1,6 +1,8 @@ # Utils for testing. CSON = require 'season' +React = require 'react/addons' KeymapManager = require 'atom-keymap' +ReactTestUtils = React.addons.TestUtils NylasTestUtils = loadKeymap: (keymapPath) -> @@ -12,18 +14,30 @@ NylasTestUtils = keymapPath = CSON.resolve("#{resourcePath}/#{keymapPath}") NylasEnv.keymaps.loadKeymap(keymapPath) - keyPress: (key, target) -> - # React's "renderIntoDocument" does not /actually/ attach the component - # to the document. It's a sham: http://dragon.ak.fbcdn.net/hphotos-ak-xpf1/t39.3284-6/10956909_1423563877937976_838415501_n.js - # The Atom keymap manager doesn't work correctly on elements outside of the - # DOM tree, so we need to attach it. - unless document.contains(target) - parent = target - while parent.parentNode? - parent = parent.parentNode - document.documentElement.appendChild(parent) - + keyDown: (key, target) -> event = KeymapManager.buildKeydownEvent(key, target: target) NylasEnv.keymaps.handleKeyboardEvent(event) + # React's "renderIntoDocument" does not /actually/ attach the component + # to the document. It's a sham: http://dragon.ak.fbcdn.net/hphotos-ak-xpf1/t39.3284-6/10956909_1423563877937976_838415501_n.js + # The Atom keymap manager doesn't work correctly on elements outside of the + # DOM tree, so we need to attach it. + renderIntoDocument: (reactDOM) -> + node = ReactTestUtils.renderIntoDocument(reactDOM) + $node = React.findDOMNode(node) + unless document.body.contains($node) + parent = $node + while parent.parentNode? + parent = parent.parentNode + document.body.appendChild(parent) + return node + + removeFromDocument: (reactElement) -> + $el = React.findDOMNode(reactElement) + if document.body.contains($el) + for child in Array::slice.call(document.body.childNodes) + if child.contains($el) + document.body.removeChild(child) + return + module.exports = NylasTestUtils diff --git a/spec/spec-bootstrap.coffee b/spec/spec-bootstrap.coffee index 391f1e522..70239366d 100644 --- a/spec/spec-bootstrap.coffee +++ b/spec/spec-bootstrap.coffee @@ -18,7 +18,7 @@ try # Show window synchronously so a focusout doesn't fire on input elements # that are focused in the very first spec run. - if NylasEnv.getLoadSettings().showSpecsInWindow + if not NylasEnv.getLoadSettings().exitWhenDone NylasEnv.getCurrentWindow().show() {runSpecSuite} = require './jasmine-helper' diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 81574c617..66e755d5a 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -1,14 +1,16 @@ +_ = require 'underscore' +_str = require 'underscore.string' +fs = require 'fs-plus' +path = require 'path' + require '../src/window' -NylasEnv.initialize() NylasEnv.restoreWindowDimensions() require 'jasmine-json' require './jasmine-jquery' -path = require 'path' -_ = require 'underscore' -_str = require 'underscore.string' -fs = require 'fs-plus' + Grim = require 'grim' +TimeOverride = require './time-override' KeymapManager = require '../src/keymap-manager' # FIXME: Remove jquery from this @@ -50,7 +52,7 @@ jasmine.getEnv().addEqualityTester(_.isEqual) # Use underscore's definition of e if process.env.JANKY_SHA1 and process.platform is 'win32' jasmine.getEnv().defaultTimeoutInterval = 60000 else - jasmine.getEnv().defaultTimeoutInterval = 1000 + jasmine.getEnv().defaultTimeoutInterval = 10000 specPackageName = null specPackagePath = null @@ -98,13 +100,6 @@ ReactTestUtils.unmountAll = -> React.unmountComponentAtNode(container) ReactElementContainers = [] -# Make Bluebird use setTimeout so that it hooks into our stubs, and you can -# advance promises using `advanceClock()`. To avoid breaking any specs that -# `dont` manually call advanceClock, call it automatically on the next tick. -Promise.setScheduler (fn) -> - setTimeout(fn, 0) - process.nextTick -> advanceClock(1) - # So it passes the Utils.isTempId test window.TEST_ACCOUNT_CLIENT_ID = "local-test-account-client-id" window.TEST_ACCOUNT_ID = "test-account-server-id" @@ -129,19 +124,15 @@ beforeEach -> NylasEnv.styles.restoreSnapshot(styleElementsToRestore) NylasEnv.workspaceViewParentSelector = '#jasmine-content' - window.resetTimeouts() - spyOn(_._, "now").andCallFake -> window.now - spyOn(window, "setTimeout").andCallFake window.fakeSetTimeout - spyOn(window, "clearTimeout").andCallFake window.fakeClearTimeout - spyOn(window, "setInterval").andCallFake window.fakeSetInterval - spyOn(window, "clearInterval").andCallFake window.fakeClearInterval - NylasEnv.packages.packageStates = {} serializedWindowState = null spyOn(NylasEnv, 'saveSync') + TimeOverride.resetTime() + TimeOverride.enableSpies() + spy = spyOn(NylasEnv.packages, 'resolvePackagePath').andCallFake (packageName) -> if specPackageName and packageName is specPackageName resolvePackagePath(specPackagePath) @@ -176,7 +167,6 @@ beforeEach -> "package-with-broken-package-json", "package-with-broken-keymap"] config.set "editor.useShadowDOM", true advanceClock(1000) - window.setTimeout.reset() config.load.reset() config.save.reset() @@ -188,6 +178,7 @@ beforeEach -> addCustomMatchers(this) + TimeOverride.resetSpyData() original_log = console.log original_warn = console.warn @@ -266,13 +257,6 @@ jasmine.snapshotDeprecations = -> jasmine.restoreDeprecationsSnapshot = -> Grim.deprecations = deprecationsSnapshot -jasmine.useRealClock = -> - jasmine.unspy(window, 'setTimeout') - jasmine.unspy(window, 'clearTimeout') - jasmine.unspy(window, 'setInterval') - jasmine.unspy(window, 'clearInterval') - jasmine.unspy(_._, 'now') - addCustomMatchers = (spec) -> spec.addMatchers toBeInstanceOf: (expected) -> @@ -379,85 +363,8 @@ window.waitsForPromise = (args...) -> jasmine.getEnv().currentSpec.fail("Expected promise to be resolved, but it was rejected with #{msg}") moveOn() -window.resetTimeouts = -> - window.now = 0 - window.timeoutCount = 0 - window.intervalCount = 0 - window.timeouts = [] - window.intervalTimeouts = {} - -window.fakeSetTimeout = (callback, ms) -> - id = ++window.timeoutCount - window.timeouts.push([id, window.now + ms, callback]) - id - -window.fakeClearTimeout = (idToClear) -> - window.timeouts ?= [] - window.timeouts = window.timeouts.filter ([id]) -> id != idToClear - -window.fakeSetInterval = (callback, ms) -> - id = ++window.intervalCount - action = -> - callback() - window.intervalTimeouts[id] = window.fakeSetTimeout(action, ms) - window.intervalTimeouts[id] = window.fakeSetTimeout(action, ms) - id - -window.fakeClearInterval = (idToClear) -> - window.fakeClearTimeout(@intervalTimeouts[idToClear]) - -window.advanceClock = (delta=1) -> - window.now += delta - callbacks = [] - - window.timeouts ?= [] - window.timeouts = window.timeouts.filter ([id, strikeTime, callback]) -> - if strikeTime <= window.now - callbacks.push(callback) - false - else - true - - callback() for callback in callbacks - window.pagePixelPositionForPoint = (editorView, point) -> point = Point.fromObject point top = editorView.renderedLines.offset().top + point.row * editorView.lineHeight left = editorView.renderedLines.offset().left + point.column * editorView.charWidth - editorView.renderedLines.scrollLeft() { top, left } - -window.tokensText = (tokens) -> - _.pluck(tokens, 'value').join('') - -window.setEditorWidthInChars = (editorView, widthInChars, charWidth=editorView.charWidth) -> - editorView.width(charWidth * widthInChars + editorView.gutter.outerWidth()) - $(window).trigger 'resize' # update width of editor view's on-screen lines - -window.setEditorHeightInLines = (editorView, heightInLines, lineHeight=editorView.lineHeight) -> - editorView.height(editorView.getEditor().getLineHeightInPixels() * heightInLines) - editorView.component?.measureHeightAndWidth() - -$.fn.resultOfTrigger = (type) -> - event = $.Event(type) - this.trigger(event) - event.result - -$.fn.enableKeymap = -> - @on 'keydown', (e) -> - originalEvent = e.originalEvent ? e - Object.defineProperty(originalEvent, 'target', get: -> e.target) unless originalEvent.target? - NylasEnv.keymaps.handleKeyboardEvent(originalEvent) - not e.originalEvent.defaultPrevented - -$.fn.attachToDom = -> - @appendTo($('#jasmine-content')) unless @isOnDom() - -$.fn.simulateDomAttachment = -> - $('').append(this) - -$.fn.textInput = (data) -> - this.each -> - event = document.createEvent('TextEvent') - event.initTextEvent('textInput', true, true, window, data) - event = $.event.fix(event) - $(this).trigger(event) diff --git a/spec/time-override.coffee b/spec/time-override.coffee new file mode 100644 index 000000000..e301c6add --- /dev/null +++ b/spec/time-override.coffee @@ -0,0 +1,100 @@ +_ = require 'underscore' + +# Public: To make specs easier to test, we make all asynchronous behavior +# actually synchronous. We do this by overriding all global timeout and +# Promise functions. +# +# You must now manually call `advanceClock()` in order to move the "clock" +# forward. +class TimeOverride + + @advanceClock = (delta=1) => + @now += delta + callbacks = [] + + @timeouts ?= [] + @timeouts = @timeouts.filter ([id, strikeTime, callback]) => + if strikeTime <= @now + callbacks.push(callback) + false + else + true + + callback() for callback in callbacks + + @resetTime = => + @now = 0 + @timeoutCount = 0 + @intervalCount = 0 + @timeouts = [] + @intervalTimeouts = {} + @originalPromiseScheduler = null + + @enableSpies = => + window.advanceClock = @advanceClock + + window.originalSetInterval = window.setInterval + spyOn(window, "setTimeout").andCallFake @_fakeSetTimeout + spyOn(window, "clearTimeout").andCallFake @_fakeClearTimeout + spyOn(window, "setInterval").andCallFake @_fakeSetInterval + spyOn(window, "clearInterval").andCallFake @_fakeClearInterval + spyOn(_._, "now").andCallFake => @now + + # spyOn(Date, "now").andCallFake => @now + # spyOn(Date.prototype, "getTime").andCallFake => @now + + @_setPromiseScheduler() + + @_setPromiseScheduler: => + + # Make Bluebird use setTimeout so that it hooks into our stubs, and you + # can advance promises using `advanceClock()`. To avoid breaking any + # specs that `dont` manually call advanceClock, call it automatically on + # the next tick. + @originalPromiseScheduler ?= Promise.setScheduler (fn) => + setTimeout(fn, 0) + process.nextTick => + @advanceClock(1) + + @disableSpies = => + window.advanceClock = null + + jasmine.unspy(window, 'setTimeout') + jasmine.unspy(window, 'clearTimeout') + jasmine.unspy(window, 'setInterval') + jasmine.unspy(window, 'clearInterval') + + jasmine.unspy(_._, "now") + + Promise.setScheduler(@originalPromiseScheduler) if @originalPromiseScheduler + @originalPromiseScheduler = null + + @resetSpyData = -> + window.setTimeout.reset?() + window.clearTimeout.reset?() + window.setInterval.reset?() + window.clearInterval.reset?() + Date.now.reset?() + Date.prototype.getTime.reset?() + + @_fakeSetTimeout = (callback, ms) => + id = ++@timeoutCount + @timeouts.push([id, @now + ms, callback]) + id + + @_fakeClearTimeout = (idToClear) => + @timeouts ?= [] + @timeouts = @timeouts.filter ([id]) -> id != idToClear + + @_fakeSetInterval = (callback, ms) => + id = ++@intervalCount + action = -> + callback() + @intervalTimeouts[id] = @_fakeSetTimeout(action, ms) + @intervalTimeouts[id] = @_fakeSetTimeout(action, ms) + id + + @_fakeClearInterval = (idToClear) => + @_fakeClearTimeout(@intervalTimeouts[idToClear]) + +module.exports = TimeOverride diff --git a/spec_integration/app-boot-spec.es6 b/spec_integration/app-boot-spec.es6 new file mode 100644 index 000000000..67e5dd6b3 --- /dev/null +++ b/spec_integration/app-boot-spec.es6 @@ -0,0 +1,53 @@ +import {N1Launcher} from './integration-helper' + +describe('Nylas Prod Bootup Tests', function() { + beforeAll((done)=>{ + // Boot in dev mode with no arguments + this.app = new N1Launcher([]); + this.app.mainWindowReady().finally(done); + }); + + afterAll((done)=> { + if (this.app && this.app.isRunning()) { + this.app.stop().then(done); + } else { + done() + } + }); + + it("has main window visible", (done)=> { + this.app.client.isWindowVisible() + .then((result)=>{ expect(result).toBe(true) }) + .finally(done) + }); + + it("has main window focused", (done)=> { + this.app.client.isWindowFocused() + .then((result)=>{ expect(result).toBe(true) }) + .finally(done) + }); + + it("isn't minimized", (done)=> { + this.app.client.isWindowMinimized() + .then((result)=>{ expect(result).toBe(false) }) + .finally(done) + }); + + it("doesn't have the dev tools open", (done)=> { + this.app.client.isWindowDevToolsOpened() + .then((result)=>{ expect(result).toBe(false) }) + .finally(done) + }); + + it("has width", (done)=> { + this.app.client.getWindowWidth() + .then((result)=>{ expect(result).toBeGreaterThan(0) }) + .finally(done) + }); + + it("has height", (done)=> { + this.app.client.getWindowHeight() + .then((result)=>{ expect(result).toBeGreaterThan(0) }) + .finally(done) + }); +}); diff --git a/spec_integration/bootstrap.js b/spec_integration/bootstrap.js new file mode 100644 index 000000000..73bffb797 --- /dev/null +++ b/spec_integration/bootstrap.js @@ -0,0 +1,12 @@ +// argv[0] = node +// argv[1] = jasmine +// argv[2] = JASMINE_CONFIG_PATH=./config.json +// argv[3] = NYLAS_ROOT_PATH=/path/to/nylas/root + +var babelOptions = require('../static/babelrc.json'); +require('babel-core/register')(babelOptions); + +jasmine.NYLAS_ROOT_PATH = process.argv[3].split("NYLAS_ROOT_PATH=")[1] +jasmine.UNIT_TEST_TIMEOUT = 120*1000; +jasmine.BOOT_TIMEOUT = 30*1000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 30*1000 diff --git a/spectron/config.json b/spec_integration/config.json similarity index 100% rename from spectron/config.json rename to spec_integration/config.json diff --git a/spec_integration/integrated-unit-spec.es6 b/spec_integration/integrated-unit-spec.es6 new file mode 100644 index 000000000..0b8bc45b4 --- /dev/null +++ b/spec_integration/integrated-unit-spec.es6 @@ -0,0 +1,36 @@ +import {N1Launcher} from './integration-helper' + +// Some unit tests, such as the Contenteditable specs need to be run with +// Spectron availble in the environment. +fdescribe('Integrated Unit Tests', function() { + beforeAll((done)=>{ + // Boot in dev mode with no arguments + this.app = new N1Launcher(["--test=window"]); + this.app.start().then(done).catch(done) + this.originalTimeoutInterval = jasmine.DEFAULT_TIMEOUT_INTERVAL + jasmine.DEFAULT_TIMEOUT_INTERVAL = 5*60*1000 // 5 minutes + }); + + afterAll((done)=> { + jasmine.DEFAULT_TIMEOUT_INTERVAL = this.originalTimeoutInterval + if (this.app && this.app.isRunning()) { + this.app.stop().then(done); + } else { + done() + } + }); + + it("Passes all integrated unit tests", (done)=> { + var client = this.app.client + client.waitForExist(".specs-complete", jasmine.UNIT_TEST_TIMEOUT) + .then(()=>{ return client.getHTML(".specs-complete .message") }) + .then((results)=>{ + expect(results).toMatch(/0 failures/) + }).then(()=>{ return client.getHTML(".plain-text-output") }) + .then((errorOutput)=>{ + expect(errorOutput).toBe('
')
+      done()
+    }).catch(done)
+  });
+
+});
diff --git a/spec_integration/integration-helper.es6 b/spec_integration/integration-helper.es6
new file mode 100644
index 000000000..d9c346b5d
--- /dev/null
+++ b/spec_integration/integration-helper.es6
@@ -0,0 +1,83 @@
+import path from 'path'
+import Promise from 'bluebird'
+import {Application} from 'spectron';
+
+class N1Launcher extends Application {
+  constructor(launchArgs = []) {
+    super({
+      path: N1Launcher.electronPath(),
+      args: [jasmine.NYLAS_ROOT_PATH].concat(N1Launcher.defaultNylasArgs()).concat(launchArgs)
+    })
+  }
+
+  mainWindowReady() {
+    // Wrap in a Bluebird promise so we have `.finally on the return`
+    return Promise.resolve(this.start().then(()=>{
+      return N1Launcher.waitUntilMainWindowLoaded(this.client).then((mainWindowId)=>{
+        return this.client.window(mainWindowId)
+      })
+    }));
+  }
+
+  static defaultNylasArgs() {
+    return ["--enable-logging", `--resource-path=${jasmine.NYLAS_ROOT_PATH}`]
+  }
+
+  static electronPath() {
+    nylasRoot = jasmine.NYLAS_ROOT_PATH
+    if (process.platform === "darwin") {
+      return path.join(nylasRoot, "electron", "Electron.app", "Contents", "MacOS", "Electron")
+    } else if (process.platform === "win32") {
+      return path.join(nylasRoot, "electron", "electron.exe")
+    }
+    else if (process.platform === "linux") {
+      return path.join(nylasRoot, "electron", "electron")
+    }
+    else {
+      throw new Error(`Platform ${process.platform} is not supported`)
+    }
+  }
+
+  // We unfortunatley can't just Spectron's `waitUntilWindowLoaded` because
+  // the first window that loads isn't necessarily the main render window (it
+  // could be the work window or others), and once the window is "loaded"
+  // it'll take a while for packages to load, etc. As such we periodically
+  // poll the list of windows to find one that looks like the main loaded
+  // window.
+  //
+  // Returns a promise that resolves with the main window's ID once it's
+  // loaded.
+  static waitUntilMainWindowLoaded(client, lastCheck=0) {
+    var CHECK_EVERY = 1000
+    return new Promise((resolve, reject) => {
+      client.windowHandles().then(({value}) => {
+        return Promise.mapSeries(value, (windowId)=>{
+          return N1Launcher.switchAndCheckForMain(client, windowId)
+        })
+      }).then((mainChecks)=>{
+        for (mainWindowId of mainChecks) {
+          if (mainWindowId) {return resolve(mainWindowId)}
+        }
+
+        var now = Date.now();
+        var delay = Math.max(CHECK_EVERY - (now - lastCheck), 0)
+        setTimeout(()=>{
+          N1Launcher.waitUntilMainWindowLoaded(client, now).then(resolve)
+        }, delay)
+      }).catch((err) => {
+        console.error(err);
+      });
+    });
+  }
+
+  // Returns false or the window ID of the main window
+  static switchAndCheckForMain(client, windowId) {
+    return client.window(windowId).then(()=>{
+      return client.isExisting(".main-window-loaded").then((exists)=>{
+        if (exists) {return windowId} else {return false}
+      })
+    })
+  }
+}
+
+module.exports = {N1Launcher}
diff --git a/spectron/package.json b/spec_integration/package.json
similarity index 92%
rename from spectron/package.json
rename to spec_integration/package.json
index 9fd5e0dce..03e630d5b 100644
--- a/spectron/package.json
+++ b/spec_integration/package.json
@@ -9,6 +9,7 @@
     "test": "jasmine JASMINE_CONFIG_PATH=./config.json"
   },
   "dependencies": {
+    "bluebird": "^3.0.5",
     "babel-core": "^5.8.21",
     "jasmine": "^2.3.2",
     "spectron": "^0.34.1"
diff --git a/spectron/app-spec.es6 b/spectron/app-spec.es6
deleted file mode 100644
index 33d5e93d8..000000000
--- a/spectron/app-spec.es6
+++ /dev/null
@@ -1,25 +0,0 @@
-import {Application} from 'spectron';
-
-describe('Nylas', ()=> {
-  beforeAll((done)=>{
-    this.app = new Application({
-      path: jasmine.APP_PATH,
-      args: jasmine.APP_ARGS,
-    });
-    this.app.start().then(()=> setTimeout(done, jasmine.BOOT_WAIT));
-  });
-
-  afterEach((done)=> {
-    if (this.app && this.app.isRunning()) {
-      this.app.stop().then(done);
-    }
-  });
-
-  it('boots 4 windows on launch', (done)=> {
-    this.app.client.getWindowCount().then((count)=> {
-      expect(count).toEqual(jasmine.any(Number));
-      done();
-    });
-  });
-});
-
diff --git a/spectron/bootstrap.js b/spectron/bootstrap.js
deleted file mode 100644
index c80974eb5..000000000
--- a/spectron/bootstrap.js
+++ /dev/null
@@ -1,6 +0,0 @@
-var babelOptions = require('../static/babelrc.json');
-require('babel-core/register')(babelOptions);
-jasmine.APP_PATH = process.argv.slice(3)[0].split('APP_PATH=')[1];
-jasmine.APP_ARGS = process.argv.slice(4)[0].split('APP_ARGS=')[1].split(',');
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000;
-jasmine.BOOT_WAIT = 15000;
diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee
index fafe01580..64c6599f3 100644
--- a/src/dom-utils.coffee
+++ b/src/dom-utils.coffee
@@ -172,8 +172,11 @@ DOMUtils =
   # https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
   # Only Elements (not Text nodes) have the `closest` method
   closest: (node, selector) ->
-    el = if node instanceof HTMLElement then node else node.parentElement
-    return el.closest(selector)
+    if node instanceof HTMLElement
+      return node.closest(selector)
+    else if node?.parentNode
+      return DOMUtils.closest(node.parentNode, selector)
+    else return null
 
   closestAtCursor: (selector) ->
     selection = document.getSelection()
diff --git a/src/nylas-env.coffee b/src/nylas-env.coffee
index c0a9a8924..837969a4e 100644
--- a/src/nylas-env.coffee
+++ b/src/nylas-env.coffee
@@ -223,6 +223,52 @@ class NylasEnvConstructor extends Model
     window.onbeforeunload = => @_unloading()
     @_unloadCallbacks = []
 
+  # Some unit tests require access to the Selenium web driver APIs as exposed
+  # by Spectron/Chromedriver. The app must be booted by Spectron as is done in the `run-integration-tests` task. Once Spectron boots the app, it will expose a RESTful API
+  #
+  # The Selenium API spec is here: https://code.google.com/p/selenium/wiki/JsonWireProtocol
+  #
+  # The Node wrapper for that API is provided by webdriver: http://webdriver.io/api.html
+  #
+  # Spectron wraps webdriver (in its `client` property) and adds additional methods: https://github.com/kevinsawicki/spectron
+  #
+  # Spectron requests that Selenium use Chromedriver
+  # https://sites.google.com/a/chromium.org/chromedriver/home to interface
+  # with the app, but points the binary at Electron.
+  #
+  # Since this code here is "inside" the booted process, we have no way of
+  # directly accessing the client from the test runner. However, we can still
+  # connect directly to the Selenium server to control ourself.
+  #
+  # We unfortunately can't create a new session with Selenium, because we
+  # won't be connected to the correct process (us!). Instead we need to
+  # inspect the existing sessions and use the existing one instead.
+  #
+  # We then manually setup the WebDriver session, and add in the extra
+  # spectron APIs via `Spectron.Application::addCommands`.
+  #
+  # http://webdriver.io/api/protocol/windowHandles.html
+  # https://code.google.com/p/selenium/wiki/JsonWireProtocol#/session/:sessionId/element/:id/value
+  setupSpectron: ->
+    options =
+      host: "127.0.0.1"
+      port: 9515
+    SpectronApp = require('spectron').Application
+    @spectron = new SpectronApp
+    WebDriver = require('webdriverio')
+    @spectron.client = new WebDriver.remote(options)
+    @spectron.addCommands()
+    @spectron.client.sessions().then ({value}) =>
+      {sessionId, capabilities} = value[0]
+      @spectron.client.requestHandler.sessionID = sessionId
+      # https://github.com/webdriverio/webdriverio/blob/master/lib/protocol/init.js
+      @spectron.client.sessionID = sessionId
+      @spectron.client.capabilities = capabilities
+      @spectron.client.desiredCapabilities = capabilities
+
+  inIntegrationSpecMode: ->
+    @inSpecMode() and @spectron?.client?.sessionID
+
   # Start our error reporting to the backend and attach error handlers
   # to the window and the Bluebird Promise library, converting things
   # back through the sourcemap as necessary.
@@ -239,7 +285,7 @@ class NylasEnvConstructor extends Model
       @lastUncaughtError = Array::slice.call(arguments)
       [message, url, line, column, originalError] = @lastUncaughtError
 
-      # {line, column} = mapSourcePosition({source: url, line, column})
+      {line, column} = mapSourcePosition({source: url, line, column})
 
       eventObject = {message, url, line, column, originalError}
 
@@ -664,6 +710,7 @@ class NylasEnvConstructor extends Model
   showRootWindow: ->
     cover = document.getElementById("application-loading-cover")
     cover.classList.add('visible')
+    document.body.classList.add("main-window-loaded")
     @restoreWindowDimensions()
     @getCurrentWindow().setMinimumSize(875, 500)
 
diff --git a/static/jasmine.less b/static/jasmine.less
index 655834238..593561e3e 100644
--- a/static/jasmine.less
+++ b/static/jasmine.less
@@ -14,6 +14,10 @@ body {
   line-height: 1.6em;
   color: #333;
 
+  .plain-text-output {
+    display: none;
+  }
+
   .list-unstyled {
     list-style: none;
   }