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 ""
+ @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;
}