From 8b0368f70569913532e13f1730dfcd3a8afcb161 Mon Sep 17 00:00:00 2001
From: Chris Breiding <chrisbreiding@gmail.com>
Date: Thu, 5 Jul 2018 14:59:09 -0400
Subject: [PATCH 1/2] refactor desktop-gui spec path handling

- differentiate folders vs spec files
- simplify specs store, remove mutations
- fix spec changing via browser url
- normalize paths for windows on server
---
 .../cypress/fixtures/specs_windows.json       |  34 ----
 .../integration/specs_list_spec.coffee        | 156 +++++++++---------
 .../desktop-gui/src/projects/projects-api.js  |   6 +-
 .../desktop-gui/src/specs/folder-model.js     |  21 +++
 packages/desktop-gui/src/specs/spec-model.js  |  37 ++---
 packages/desktop-gui/src/specs/specs-list.jsx |  34 ++--
 packages/desktop-gui/src/specs/specs-store.js | 142 ++++++----------
 packages/server/lib/util/specs.coffee         |   7 +
 8 files changed, 178 insertions(+), 259 deletions(-)
 delete mode 100644 packages/desktop-gui/cypress/fixtures/specs_windows.json
 create mode 100644 packages/desktop-gui/src/specs/folder-model.js

diff --git a/packages/desktop-gui/cypress/fixtures/specs_windows.json b/packages/desktop-gui/cypress/fixtures/specs_windows.json
deleted file mode 100644
index 093c9a06d879..000000000000
--- a/packages/desktop-gui/cypress/fixtures/specs_windows.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
-  "integration": [
-    {
-      "name": "app_spec.coffee",
-      "relative": "cypress\\integration\\app_spec.coffee"
-    },
-    {
-      "name": "accounts\\account_new_spec.coffee",
-      "relative": "cypress\\integration\\accounts\\account_new_spec.coffee"
-    },
-    {
-      "name": "accounts\\accounts_list_spec.coffee",
-      "relative": "cypress\\integration\\accounts\\accounts_list_spec.coffee"
-    },
-    {
-      "name": "admin_users\\admin_users_list_spec.coffee",
-      "relative": "cypress\\integration\\admin_users\\admin_users_list_spec.coffee"
-    },
-    {
-      "name": "admin_users\\admin\\foo_list_spec.coffee",
-      "relative": "cypress\\integration\\admin_users\\admin\\foo_list_spec.coffee"
-    }
-  ],
-  "unit": [
-    {
-      "name": "admin_users\\admin\\users\\bar_list_spec.coffee",
-      "relative": "cypress\\unit\\admin_users\\admin\\users\\bar_list_spec.coffee"
-    },
-    {
-      "name": "admin_users\\admin\\users\\all\\admin\\baz_list_spec.coffee",
-      "relative": "cypress\\unit\\admin_users\\admin\\users\\all\\admin\\baz_list_spec.coffee"
-    }
-  ]
-}
diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
index 35788a83270c..9b5b9b165c40 100644
--- a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
+++ b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
@@ -3,7 +3,6 @@ describe "Specs List", ->
     cy.fixture("user").as("user")
     cy.fixture("config").as("config")
     cy.fixture("specs").as("specs")
-    cy.fixture("specs_windows").as("specsWindows")
 
     cy.visitIndex().then (win) ->
       { start, @ipc } = win.App
@@ -92,115 +91,109 @@ describe "Specs List", ->
         expect(@ipc.openFinder).to.be.calledWith(@config.integrationFolder)
 
   describe "lists specs", ->
-    context "Windows paths", ->
+    context "run all specs", ->
       beforeEach ->
-        @ipc.getSpecs.yields(null, @specsWindows)
+        @ipc.getSpecs.yields(null, @specs)
         @openProject.resolve(@config)
 
-      context "displays list of specs", ->
-        it "lists nested folders", ->
-          cy.get(".folder .folder").contains("accounts")
-
-        it "lists test specs", ->
-          cy.get(".file a").last().should("contain", "baz_list_spec.coffee")
-          cy.get(".file a").last().should("not.contain", "admin_users")
+      it "displays run all specs button", ->
+        cy.contains(".btn", "Run all specs")
 
-    context "Linux paths", ->
-      beforeEach ->
-        @ipc.getSpecs.yields(null, @specs)
-        @openProject.resolve(@config)
+      it "has play icon", ->
+        cy
+          .contains(".btn", "Run all specs")
+          .find("i").should("have.class", "fa-play")
 
-      context "run all specs", ->
-        it "displays run all specs button", ->
-          cy.contains(".btn", "Run all specs")
+      it "triggers browser launch on click of button", ->
+        cy
+          .contains(".btn", "Run all specs").click()
+          .then ->
+            launchArgs = @ipc.launchBrowser.lastCall.args
 
-        it "has play icon", ->
-          cy
-            .contains(".btn", "Run all specs")
-            .find("i").should("have.class", "fa-play")
+            expect(launchArgs[0].browser.name).to.eq "chrome"
+            expect(launchArgs[0].spec.name).to.eq "All Specs"
 
-        it "triggers browser launch on click of button", ->
-          cy
-            .contains(".btn", "Run all specs").click()
-            .then ->
-              launchArgs = @ipc.launchBrowser.lastCall.args
+      describe "all specs running in browser", ->
+        beforeEach ->
+          cy.contains(".btn", "Run all specs").as("allSpecs").click()
 
-              expect(launchArgs[0].browser.name).to.eq "chrome"
-              expect(launchArgs[0].spec.name).to.eq "All Specs"
+        it "updates spec icon", ->
+          cy.get("@allSpecs").find("i").should("have.class", "fa-dot-circle-o")
+          cy.get("@allSpecs").find("i").should("not.have.class", "fa-play")
 
-        describe "all specs running in browser", ->
-          beforeEach ->
-            cy.contains(".btn", "Run all specs").as("allSpecs").click()
+        it "sets spec as active", ->
+          cy.get("@allSpecs").should("have.class", "active")
 
-          it "updates spec icon", ->
-            cy.get("@allSpecs").find("i").should("have.class", "fa-dot-circle-o")
-            cy.get("@allSpecs").find("i").should("not.have.class", "fa-play")
+    context "displays list of specs", ->
+      beforeEach ->
+        @ipc.getSpecs.yields(null, @specs)
+        @openProject.resolve(@config)
 
-          it "sets spec as active", ->
-            cy.get("@allSpecs").should("have.class", "active")
+      it "lists main folders of specs", ->
+        cy.contains(".folder", "integration")
+        cy.contains(".folder", "unit")
 
-      context "displays list of specs", ->
-        it "lists main folders of specs", ->
-          cy.contains(".folder", "integration")
-          cy.contains(".folder", "unit")
+      it "lists nested folders", ->
+        cy.get(".folder .folder").contains("accounts")
 
-        it "lists nested folders", ->
-          cy.get(".folder .folder").contains("accounts")
+      it "lists test specs", ->
+        cy.get(".file a").contains("app_spec.coffee")
 
-        it "lists test specs", ->
-          cy.get(".file a").contains("app_spec.coffee")
+    context "collapsing specs", ->
+      beforeEach ->
+        @ipc.getSpecs.yields(null, @specs)
+        @openProject.resolve(@config)
 
-      context "collapsing specs", ->
-        it "sets folder collapsed when clicked", ->
-          cy.get(".folder:first").should("have.class", "folder-expanded")
-          cy.get(".folder .folder-display-name:first").click()
-          cy.get(".folder:first").should("have.class", "folder-collapsed")
+      it "sets folder collapsed when clicked", ->
+        cy.get(".folder:first").should("have.class", "folder-expanded")
+        cy.get(".folder .folder-display-name:first").click()
+        cy.get(".folder:first").should("have.class", "folder-collapsed")
 
-        it "hides children when folder clicked", ->
-          cy.get(".file").should("have.length", 7)
-          cy.get(".folder .folder-display-name:first").click()
-          cy.get(".file").should("have.length", 2)
+      it "hides children when folder clicked", ->
+        cy.get(".file").should("have.length", 7)
+        cy.get(".folder .folder-display-name:first").click()
+        cy.get(".file").should("have.length", 2)
 
-        it "sets folder expanded when clicked twice", ->
-          cy.get(".folder .folder-display-name:first").click()
-          cy.get(".folder:first").should("have.class", "folder-collapsed")
-          cy.get(".folder .folder-display-name:first").click()
-          cy.get(".folder:first").should("have.class", "folder-expanded")
+      it "sets folder expanded when clicked twice", ->
+        cy.get(".folder .folder-display-name:first").click()
+        cy.get(".folder:first").should("have.class", "folder-collapsed")
+        cy.get(".folder .folder-display-name:first").click()
+        cy.get(".folder:first").should("have.class", "folder-expanded")
 
-        it "hides children for every folder collapsed", ->
-          lastExpandedFolderSelector = ".folder-expanded:last > div > div > .folder-display-name:last"
+      it "hides children for every folder collapsed", ->
+        lastExpandedFolderSelector = ".folder-expanded:last > div > div > .folder-display-name:last"
 
-          cy.get(".file").should("have.length", 7)
+        cy.get(".file").should("have.length", 7)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 6)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 6)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 6)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 6)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 5)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 5)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 5)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 5)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 5)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 5)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 5)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 5)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 4)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 4)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 3)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 3)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 1)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 1)
 
-          cy.get(lastExpandedFolderSelector).click()
-          cy.get(".file").should("have.length", 0)
+        cy.get(lastExpandedFolderSelector).click()
+        cy.get(".file").should("have.length", 0)
 
     context "filtering specs", ->
       describe "typing the filter", ->
@@ -256,7 +249,6 @@ describe "Specs List", ->
           @openProject.resolve(@config)
           cy.get(".filter").should("have.value", "")
 
-
     context "click on spec", ->
       beforeEach ->
         @ipc.getSpecs.yields(null, @specs)
diff --git a/packages/desktop-gui/src/projects/projects-api.js b/packages/desktop-gui/src/projects/projects-api.js
index a264b0a583cf..37f37931aae1 100644
--- a/packages/desktop-gui/src/projects/projects-api.js
+++ b/packages/desktop-gui/src/projects/projects-api.js
@@ -68,7 +68,7 @@ const runSpec = (project, spec, browser) => {
   const launchBrowser = () => {
     project.browserOpening()
 
-    ipc.launchBrowser({ browser, spec: spec.obj }, (err, data = {}) => {
+    ipc.launchBrowser({ browser, spec: spec.file }, (err, data = {}) => {
       if (data.browserOpened) {
         project.browserOpened()
       }
@@ -149,8 +149,8 @@ const openProject = (project) => {
     viewStore.showProjectSpecs(project)
   })
 
-  ipc.onSpecChanged((__, spec) => {
-    specsStore.setChosenSpecByRelativePath(spec)
+  ipc.onSpecChanged((__, relativeSpecPath) => {
+    specsStore.setChosenSpecByRelativePath(relativeSpecPath)
   })
 
   ipc.onConfigChanged(() => {
diff --git a/packages/desktop-gui/src/specs/folder-model.js b/packages/desktop-gui/src/specs/folder-model.js
new file mode 100644
index 000000000000..ddeb48d6486c
--- /dev/null
+++ b/packages/desktop-gui/src/specs/folder-model.js
@@ -0,0 +1,21 @@
+import { action, computed, observable } from 'mobx'
+
+export default class Directory {
+  @observable path
+  @observable displayName
+  @observable isExpanded = true
+  @observable children = []
+
+  constructor ({ path, displayName }) {
+    this.path = path
+    this.displayName = displayName
+  }
+
+  @computed get hasChildren () {
+    return this.children.length
+  }
+
+  @action setExpanded (isExpanded) {
+    this.isExpanded = isExpanded
+  }
+}
diff --git a/packages/desktop-gui/src/specs/spec-model.js b/packages/desktop-gui/src/specs/spec-model.js
index 95531bd80ba5..f87d307bdd92 100644
--- a/packages/desktop-gui/src/specs/spec-model.js
+++ b/packages/desktop-gui/src/specs/spec-model.js
@@ -1,39 +1,28 @@
 import _ from 'lodash'
-import { action, observable } from 'mobx'
+import { computed, observable } from 'mobx'
 
 export default class Spec {
-  @observable name
   @observable path
+  @observable name
+  @observable absolute
   @observable displayName
+  @observable type
   @observable isChosen = false
-  @observable isExpanded = false
-  @observable children = []
 
-  constructor ({ obj, name, displayName, path }) {
-    this.obj = obj
-    this.name = name
+  constructor ({ path, name, absolute, relative, displayName, type }) {
     this.path = path
-    this.isExpanded = true
+    this.name = name
+    this.absolute = absolute
+    this.relative = relative
     this.displayName = displayName
+    this.type = type
   }
 
-  getStateProps () {
-    return _.pick(this, 'isChosen', 'isExpanded')
-  }
-
-  hasChildren () {
-    return this.children && this.children.length
-  }
-
-  @action merge (other) {
-    _.extend(this, other.getStateProps())
-  }
-
-  @action setChosen (isChosen) {
-    this.isChosen = isChosen
+  @computed get hasChildren () {
+    return false
   }
 
-  @action setExpanded (isExpanded) {
-    this.isExpanded = isExpanded
+  @computed get file () {
+    return _.pick(this, 'name', 'absolute', 'relative')
   }
 }
diff --git a/packages/desktop-gui/src/specs/specs-list.jsx b/packages/desktop-gui/src/specs/specs-list.jsx
index b9aa6b5bbb44..e763a5747d62 100644
--- a/packages/desktop-gui/src/specs/specs-list.jsx
+++ b/packages/desktop-gui/src/specs/specs-list.jsx
@@ -6,17 +6,15 @@ import Loader from 'react-loader'
 
 import ipc from '../lib/ipc'
 import projectsApi from '../projects/projects-api'
-import specsStore from './specs-store'
+import specsStore, { allSpecsSpec } from './specs-store'
 
 @observer
-class Specs extends Component {
+class SpecsList extends Component {
   render () {
     if (specsStore.isLoading) return <Loader color='#888' scale={0.5}/>
 
     if (!specsStore.filter && !specsStore.specs.length) return this._empty()
 
-    const allSpecsSpec = specsStore.getAllSpecsSpec()
-
     return (
       <div id='tests-list-page'>
         <header>
@@ -36,8 +34,8 @@ class Specs extends Component {
             />
             <a className='clear-filter fa fa-times' onClick={this._clearFilter} />
           </div>
-          <a onClick={this._selectSpec.bind(this, allSpecsSpec)} className={cs('all-tests btn btn-default', { active: allSpecsSpec.isChosen })}>
-            <i className={`fa fa-fw ${this._allSpecsIcon(allSpecsSpec.isChosen)}`}></i>{' '}
+          <a onClick={this._selectSpec.bind(this, allSpecsSpec)} className={cs('all-tests btn btn-default', { active: specsStore.isChosen(allSpecsSpec) })}>
+            <i className={`fa fa-fw ${this._allSpecsIcon(specsStore.isChosen(allSpecsSpec))}`}></i>{' '}
             {allSpecsSpec.displayName}
           </a>
         </header>
@@ -63,27 +61,15 @@ class Specs extends Component {
   }
 
   _specItem (spec) {
-    if (spec.hasChildren()) {
-      return this._folderContent(spec)
-    } else {
-      return this._specContent(spec)
-    }
+    return spec.hasChildren ? this._folderContent(spec) : this._specContent(spec)
   }
 
   _allSpecsIcon (allSpecsChosen) {
-    if (allSpecsChosen) {
-      return 'fa-dot-circle-o green'
-    } else {
-      return 'fa-play'
-    }
+    return allSpecsChosen ? 'fa-dot-circle-o green' : 'fa-play'
   }
 
   _specIcon (isChosen) {
-    if (isChosen) {
-      return 'fa-dot-circle-o green'
-    } else {
-      return 'fa-file-code-o'
-    }
+    return isChosen ? 'fa-dot-circle-o green' : 'fa-file-code-o'
   }
 
   _clearFilter = () => {
@@ -142,10 +128,10 @@ class Specs extends Component {
   _specContent (spec) {
     return (
       <li key={spec.path} className='file'>
-        <a href='#' onClick={this._selectSpec.bind(this, spec)} className={cs({ active: spec.isChosen })}>
+        <a href='#' onClick={this._selectSpec.bind(this, spec)} className={cs({ active: specsStore.isChosen(spec) })}>
           <div>
             <div>
-              <i className={`fa fa-fw ${this._specIcon(spec.isChosen)}`}></i>
+              <i className={`fa fa-fw ${this._specIcon(specsStore.isChosen(spec))}`}></i>
               {spec.displayName}
             </div>
           </div>
@@ -186,4 +172,4 @@ class Specs extends Component {
   }
 }
 
-export default Specs
+export default SpecsList
diff --git a/packages/desktop-gui/src/specs/specs-store.js b/packages/desktop-gui/src/specs/specs-store.js
index 3bdc0b3e8c1a..23ad1518a7db 100644
--- a/packages/desktop-gui/src/specs/specs-store.js
+++ b/packages/desktop-gui/src/specs/specs-store.js
@@ -3,55 +3,53 @@ import { action, computed, observable } from 'mobx'
 
 import localData from '../lib/local-data'
 import Spec from './spec-model'
+import Folder from './folder-model'
 
-const ALL_SPECS = '__all'
+const extRegex = /.*\.\w+$/
+const isFile = (maybeFile) => extRegex.test(maybeFile)
+
+export const allSpecsSpec = new Spec({
+  name: 'All Specs',
+  absolute: '__all',
+  relative: '__all',
+  displayName: 'Run all specs',
+})
+
+const formRelativePath = (spec) => {
+  return spec === allSpecsSpec ? spec.relative : `${spec.type}/${spec.name}`
+}
 
 export class SpecsStore {
-  @observable _specs = []
-  @observable error = null
+  @observable _files = []
+  @observable chosenSpecPath
+  @observable error
   @observable isLoading = false
-  @observable filter = null
-
-  constructor () {
-    this.models = []
-
-    this.allSpecsSpec = new Spec({
-      name: null,
-      path: ALL_SPECS,
-      displayName: 'Run all specs',
-      obj: {
-        name: 'All Specs',
-        relative: null,
-        absolute: null,
-      },
-    })
-  }
+  @observable filter
 
   @computed get specs () {
-    return this._tree(this._specs)
+    return this._tree(this._files)
   }
 
   @action loading (bool) {
     this.isLoading = bool
   }
 
-  @action setSpecs (specs) {
-    this._specs = specs
+  @action setSpecs (specsByType) {
+    this._files = _.flatten(_.map(specsByType, (specs, type) => {
+      return _.map(specs, (spec) => {
+        return _.extend({}, spec, { type })
+      })
+    }))
 
     this.isLoading = false
   }
 
   @action setChosenSpec (spec) {
-    // set all the models to false
-    _
-    .chain(this.models)
-    .concat(this.allSpecsSpec)
-    .invokeMap('setChosen', false)
-    .value()
-
-    if (spec) {
-      spec.setChosen(true)
-    }
+    this.chosenSpecPath = spec ? formRelativePath(spec) : null
+  }
+
+  @action setChosenSpecByRelativePath (relativePath) {
+    this.chosenSpecPath = relativePath
   }
 
   @action setExpandSpecFolder (spec) {
@@ -70,85 +68,45 @@ export class SpecsStore {
     this.filter = null
   }
 
-  setChosenSpecByRelativePath (relativePath) {
-    // TODO: currently this will always find nothing
-    // because this data is sent from the driver when
-    // a spec first opens. it passes the normalized url
-    // which will no longer match any spec. we need to
-    // change the logic to do this. it's barely worth it though.
-    const found = this.findSpecModelByPath(relativePath)
-
-    if (found) {
-      this.setChosenSpec(found)
-    }
+  isChosen (spec) {
+    return this.chosenSpecPath === formRelativePath(spec)
   }
 
-  findOrCreateSpec (file, segment) {
-    const spec = new Spec({
-      obj: file, // store the original obj
-      name: file.name,
-      path: file.relative,
-      displayName: segment,
-    })
-
-    const found = this.findSpecModelByPath(file.relative)
-
-    if (found) {
-      spec.merge(found)
-    }
-
-    return spec
-  }
-
-  findSpecModelByPath (path) {
-    return _.find(this.models, { path })
-  }
-
-  getAllSpecsSpec () {
-    return this.allSpecsSpec
-  }
-
-  _tree (specsByType) {
-    let specs = _.flatten(_.map(specsByType, (specs, type) => {
-      return _.map(specs, (spec) => {
-        // add type (unit, integration, etc) to beginning
-        // and  change \\ to / for Windows
-        return _.extend({}, spec, {
-          name: `${type}/${spec.name.replace(/\\/g, '/')}`,
-        })
-      })
-    }))
-
+  _tree (files) {
     if (this.filter) {
-      specs = _.filter(specs, (spec) => {
+      files = _.filter(files, (spec) => {
         return spec.name.toLowerCase().includes(this.filter.toLowerCase())
       })
     }
 
-    const specModels = []
+    const tree = _.reduce(files, (root, file) => {
+      const segments = [file.type].concat(file.name.split('/'))
+      const segmentsPassed = []
 
-    const tree = _.reduce(specs, (root, file) => {
       let placeholder = root
 
-      const segments = file.name.split('/')
-
       _.each(segments, (segment) => {
-        let spec = _.find(placeholder, { displayName: segment })
-        if (!spec) {
-          spec = this.findOrCreateSpec(file, segment)
+        segmentsPassed.push(segment)
+        const path = segmentsPassed.join('/')
+        const isCurrentAFile = isFile(path)
+        const props = { path, displayName: segment }
+
+        let existing = _.find(placeholder, { path })
 
-          specModels.push(spec)
-          placeholder.push(spec)
+        if (!existing) {
+          existing = isCurrentAFile ? new Spec(_.extend(file, props)) : new Folder(props)
+
+          placeholder.push(existing)
         }
 
-        placeholder = spec.children
+        if (!isCurrentAFile) {
+          placeholder = existing.children
+        }
       })
 
       return root
     }, [])
 
-    this.models = specModels
-
     return tree
   }
 }
diff --git a/packages/server/lib/util/specs.coffee b/packages/server/lib/util/specs.coffee
index fe0dc1613265..2f1a10e84fbf 100644
--- a/packages/server/lib/util/specs.coffee
+++ b/packages/server/lib/util/specs.coffee
@@ -7,6 +7,7 @@ minimatch = require("minimatch")
 glob = require("./glob")
 
 MINIMATCH_OPTIONS = { dot: true, matchBase: true }
+backslashRe = /\\/g
 
 getPatternRelativeToProjectRoot = (specPattern, projectRoot) ->
   _.map specPattern, (p) ->
@@ -60,6 +61,10 @@ find = (config, specPattern) ->
   ## relativePathFromIntegrationFolder = foo.coffee
   ## relativePathFromProjectRoot       = cypress/integration/foo.coffee
 
+  ## normalize windows \ into /
+  normalizePath = (file) ->
+    file.replace(backslashRe, '/')
+
   relativePathFromIntegrationFolder = (file) ->
     path.relative(integrationFolderPath, file)
 
@@ -72,6 +77,8 @@ find = (config, specPattern) ->
     if not path.isAbsolute(file)
       throw new Error("Cannot set parts of file from non-absolute path #{file}")
 
+    file = normalizePath(file)
+
     {
       name: relativePathFromIntegrationFolder(file)
       relative: relativePathFromProjectRoot(file)

From 9ac63fb2edfd23cfd47dccc88b153897b9276572 Mon Sep 17 00:00:00 2001
From: Chris Breiding <chrisbreiding@gmail.com>
Date: Fri, 6 Jul 2018 10:15:47 -0400
Subject: [PATCH 2/2] move windows paths logic back to desktop-gui
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

using any method from node’s path util converts / back to \ on windows, so trying to normalize the paths to use / is futile. instead, properly split and compare paths in the desktop gui as needed
---
 .../cypress/fixtures/specs_windows.json       |  34 ++++
 .../integration/specs_list_spec.coffee        | 160 +++++++++---------
 packages/desktop-gui/src/specs/specs-store.js |  22 ++-
 packages/server/lib/util/specs.coffee         |   7 -
 4 files changed, 132 insertions(+), 91 deletions(-)
 create mode 100644 packages/desktop-gui/cypress/fixtures/specs_windows.json

diff --git a/packages/desktop-gui/cypress/fixtures/specs_windows.json b/packages/desktop-gui/cypress/fixtures/specs_windows.json
new file mode 100644
index 000000000000..093c9a06d879
--- /dev/null
+++ b/packages/desktop-gui/cypress/fixtures/specs_windows.json
@@ -0,0 +1,34 @@
+{
+  "integration": [
+    {
+      "name": "app_spec.coffee",
+      "relative": "cypress\\integration\\app_spec.coffee"
+    },
+    {
+      "name": "accounts\\account_new_spec.coffee",
+      "relative": "cypress\\integration\\accounts\\account_new_spec.coffee"
+    },
+    {
+      "name": "accounts\\accounts_list_spec.coffee",
+      "relative": "cypress\\integration\\accounts\\accounts_list_spec.coffee"
+    },
+    {
+      "name": "admin_users\\admin_users_list_spec.coffee",
+      "relative": "cypress\\integration\\admin_users\\admin_users_list_spec.coffee"
+    },
+    {
+      "name": "admin_users\\admin\\foo_list_spec.coffee",
+      "relative": "cypress\\integration\\admin_users\\admin\\foo_list_spec.coffee"
+    }
+  ],
+  "unit": [
+    {
+      "name": "admin_users\\admin\\users\\bar_list_spec.coffee",
+      "relative": "cypress\\unit\\admin_users\\admin\\users\\bar_list_spec.coffee"
+    },
+    {
+      "name": "admin_users\\admin\\users\\all\\admin\\baz_list_spec.coffee",
+      "relative": "cypress\\unit\\admin_users\\admin\\users\\all\\admin\\baz_list_spec.coffee"
+    }
+  ]
+}
diff --git a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
index 9b5b9b165c40..340688daa679 100644
--- a/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
+++ b/packages/desktop-gui/cypress/integration/specs_list_spec.coffee
@@ -3,6 +3,7 @@ describe "Specs List", ->
     cy.fixture("user").as("user")
     cy.fixture("config").as("config")
     cy.fixture("specs").as("specs")
+    cy.fixture("specs_windows").as("specsWindows")
 
     cy.visitIndex().then (win) ->
       { start, @ipc } = win.App
@@ -91,109 +92,115 @@ describe "Specs List", ->
         expect(@ipc.openFinder).to.be.calledWith(@config.integrationFolder)
 
   describe "lists specs", ->
-    context "run all specs", ->
+    context "Windows paths", ->
       beforeEach ->
-        @ipc.getSpecs.yields(null, @specs)
+        @ipc.getSpecs.yields(null, @specsWindows)
         @openProject.resolve(@config)
 
-      it "displays run all specs button", ->
-        cy.contains(".btn", "Run all specs")
+      context "displays list of specs", ->
+        it "lists nested folders", ->
+          cy.get(".folder .folder").contains("accounts")
 
-      it "has play icon", ->
-        cy
-          .contains(".btn", "Run all specs")
-          .find("i").should("have.class", "fa-play")
+        it "lists test specs", ->
+          cy.get(".file a").last().should("contain", "baz_list_spec.coffee")
+          cy.get(".file a").last().should("not.contain", "admin_users")
 
-      it "triggers browser launch on click of button", ->
-        cy
-          .contains(".btn", "Run all specs").click()
-          .then ->
-            launchArgs = @ipc.launchBrowser.lastCall.args
+    context "Linux paths", ->
+      beforeEach ->
+        @ipc.getSpecs.yields(null, @specs)
+        @openProject.resolve(@config)
 
-            expect(launchArgs[0].browser.name).to.eq "chrome"
-            expect(launchArgs[0].spec.name).to.eq "All Specs"
+      context "run all specs", ->
+        it "displays run all specs button", ->
+          cy.contains(".btn", "Run all specs")
 
-      describe "all specs running in browser", ->
-        beforeEach ->
-          cy.contains(".btn", "Run all specs").as("allSpecs").click()
+        it "has play icon", ->
+          cy
+            .contains(".btn", "Run all specs")
+            .find("i").should("have.class", "fa-play")
 
-        it "updates spec icon", ->
-          cy.get("@allSpecs").find("i").should("have.class", "fa-dot-circle-o")
-          cy.get("@allSpecs").find("i").should("not.have.class", "fa-play")
+        it "triggers browser launch on click of button", ->
+          cy
+            .contains(".btn", "Run all specs").click()
+            .then ->
+              launchArgs = @ipc.launchBrowser.lastCall.args
 
-        it "sets spec as active", ->
-          cy.get("@allSpecs").should("have.class", "active")
+              expect(launchArgs[0].browser.name).to.eq "chrome"
+              expect(launchArgs[0].spec.name).to.eq "All Specs"
 
-    context "displays list of specs", ->
-      beforeEach ->
-        @ipc.getSpecs.yields(null, @specs)
-        @openProject.resolve(@config)
+        describe "all specs running in browser", ->
+          beforeEach ->
+            cy.contains(".btn", "Run all specs").as("allSpecs").click()
 
-      it "lists main folders of specs", ->
-        cy.contains(".folder", "integration")
-        cy.contains(".folder", "unit")
+          it "updates spec icon", ->
+            cy.get("@allSpecs").find("i").should("have.class", "fa-dot-circle-o")
+            cy.get("@allSpecs").find("i").should("not.have.class", "fa-play")
 
-      it "lists nested folders", ->
-        cy.get(".folder .folder").contains("accounts")
+          it "sets spec as active", ->
+            cy.get("@allSpecs").should("have.class", "active")
 
-      it "lists test specs", ->
-        cy.get(".file a").contains("app_spec.coffee")
+      context "displays list of specs", ->
+        it "lists main folders of specs", ->
+          cy.contains(".folder", "integration")
+          cy.contains(".folder", "unit")
 
-    context "collapsing specs", ->
-      beforeEach ->
-        @ipc.getSpecs.yields(null, @specs)
-        @openProject.resolve(@config)
+        it "lists nested folders", ->
+          cy.get(".folder .folder").contains("accounts")
 
-      it "sets folder collapsed when clicked", ->
-        cy.get(".folder:first").should("have.class", "folder-expanded")
-        cy.get(".folder .folder-display-name:first").click()
-        cy.get(".folder:first").should("have.class", "folder-collapsed")
+        it "lists test specs", ->
+          cy.get(".file a").contains("app_spec.coffee")
 
-      it "hides children when folder clicked", ->
-        cy.get(".file").should("have.length", 7)
-        cy.get(".folder .folder-display-name:first").click()
-        cy.get(".file").should("have.length", 2)
+      context "collapsing specs", ->
+        it "sets folder collapsed when clicked", ->
+          cy.get(".folder:first").should("have.class", "folder-expanded")
+          cy.get(".folder .folder-display-name:first").click()
+          cy.get(".folder:first").should("have.class", "folder-collapsed")
 
-      it "sets folder expanded when clicked twice", ->
-        cy.get(".folder .folder-display-name:first").click()
-        cy.get(".folder:first").should("have.class", "folder-collapsed")
-        cy.get(".folder .folder-display-name:first").click()
-        cy.get(".folder:first").should("have.class", "folder-expanded")
+        it "hides children when folder clicked", ->
+          cy.get(".file").should("have.length", 7)
+          cy.get(".folder .folder-display-name:first").click()
+          cy.get(".file").should("have.length", 2)
 
-      it "hides children for every folder collapsed", ->
-        lastExpandedFolderSelector = ".folder-expanded:last > div > div > .folder-display-name:last"
+        it "sets folder expanded when clicked twice", ->
+          cy.get(".folder .folder-display-name:first").click()
+          cy.get(".folder:first").should("have.class", "folder-collapsed")
+          cy.get(".folder .folder-display-name:first").click()
+          cy.get(".folder:first").should("have.class", "folder-expanded")
 
-        cy.get(".file").should("have.length", 7)
+        it "hides children for every folder collapsed", ->
+          lastExpandedFolderSelector = ".folder-expanded:last > div > div > .folder-display-name:last"
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 6)
+          cy.get(".file").should("have.length", 7)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 6)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 6)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 5)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 6)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 5)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 5)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 5)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 5)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 5)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 5)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 4)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 5)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 3)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 4)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 1)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 3)
 
-        cy.get(lastExpandedFolderSelector).click()
-        cy.get(".file").should("have.length", 0)
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 1)
+
+          cy.get(lastExpandedFolderSelector).click()
+          cy.get(".file").should("have.length", 0)
 
     context "filtering specs", ->
       describe "typing the filter", ->
@@ -249,6 +256,7 @@ describe "Specs List", ->
           @openProject.resolve(@config)
           cy.get(".filter").should("have.value", "")
 
+
     context "click on spec", ->
       beforeEach ->
         @ipc.getSpecs.yields(null, @specs)
@@ -322,9 +330,7 @@ describe "Specs List", ->
         cy.get("@firstSpec").should("not.have.class", "active")
         cy.get("@secondSpec").should("have.class", "active")
 
-  ## We aren't properly handling this event so skipping
-  ## this test for now until its implemented
-  describe.skip "spec list updates", ->
+  describe "spec list updates", ->
     beforeEach ->
       @ipc.getSpecs.yields(null, @specs)
       @openProject.resolve(@config)
diff --git a/packages/desktop-gui/src/specs/specs-store.js b/packages/desktop-gui/src/specs/specs-store.js
index 23ad1518a7db..b096a4717516 100644
--- a/packages/desktop-gui/src/specs/specs-store.js
+++ b/packages/desktop-gui/src/specs/specs-store.js
@@ -1,10 +1,12 @@
 import _ from 'lodash'
 import { action, computed, observable } from 'mobx'
+import path from 'path'
 
 import localData from '../lib/local-data'
 import Spec from './spec-model'
 import Folder from './folder-model'
 
+const pathSeparatorRe = /[\\\/]/g
 const extRegex = /.*\.\w+$/
 const isFile = (maybeFile) => extRegex.test(maybeFile)
 
@@ -16,7 +18,13 @@ export const allSpecsSpec = new Spec({
 })
 
 const formRelativePath = (spec) => {
-  return spec === allSpecsSpec ? spec.relative : `${spec.type}/${spec.name}`
+  return spec === allSpecsSpec ? spec.relative : path.join(spec.type, spec.name)
+}
+
+const pathsEqual = (path1, path2) => {
+  if (!path1 || !path2) return false
+
+  return path1.replace(pathSeparatorRe, '') === path2.replace(pathSeparatorRe, '')
 }
 
 export class SpecsStore {
@@ -69,7 +77,7 @@ export class SpecsStore {
   }
 
   isChosen (spec) {
-    return this.chosenSpecPath === formRelativePath(spec)
+    return pathsEqual(this.chosenSpecPath, formRelativePath(spec))
   }
 
   _tree (files) {
@@ -80,18 +88,18 @@ export class SpecsStore {
     }
 
     const tree = _.reduce(files, (root, file) => {
-      const segments = [file.type].concat(file.name.split('/'))
+      const segments = [file.type].concat(file.name.split(pathSeparatorRe))
       const segmentsPassed = []
 
       let placeholder = root
 
       _.each(segments, (segment) => {
         segmentsPassed.push(segment)
-        const path = segmentsPassed.join('/')
-        const isCurrentAFile = isFile(path)
-        const props = { path, displayName: segment }
+        const currentPath = path.join(...segmentsPassed)
+        const isCurrentAFile = isFile(currentPath)
+        const props = { path: currentPath, displayName: segment }
 
-        let existing = _.find(placeholder, { path })
+        let existing = _.find(placeholder, (file) => pathsEqual(file.path, currentPath))
 
         if (!existing) {
           existing = isCurrentAFile ? new Spec(_.extend(file, props)) : new Folder(props)
diff --git a/packages/server/lib/util/specs.coffee b/packages/server/lib/util/specs.coffee
index 2f1a10e84fbf..fe0dc1613265 100644
--- a/packages/server/lib/util/specs.coffee
+++ b/packages/server/lib/util/specs.coffee
@@ -7,7 +7,6 @@ minimatch = require("minimatch")
 glob = require("./glob")
 
 MINIMATCH_OPTIONS = { dot: true, matchBase: true }
-backslashRe = /\\/g
 
 getPatternRelativeToProjectRoot = (specPattern, projectRoot) ->
   _.map specPattern, (p) ->
@@ -61,10 +60,6 @@ find = (config, specPattern) ->
   ## relativePathFromIntegrationFolder = foo.coffee
   ## relativePathFromProjectRoot       = cypress/integration/foo.coffee
 
-  ## normalize windows \ into /
-  normalizePath = (file) ->
-    file.replace(backslashRe, '/')
-
   relativePathFromIntegrationFolder = (file) ->
     path.relative(integrationFolderPath, file)
 
@@ -77,8 +72,6 @@ find = (config, specPattern) ->
     if not path.isAbsolute(file)
       throw new Error("Cannot set parts of file from non-absolute path #{file}")
 
-    file = normalizePath(file)
-
     {
       name: relativePathFromIntegrationFolder(file)
       relative: relativePathFromProjectRoot(file)