/* globals assert */

const path = require('path')
const { EventEmitter } = require('events')
const temp = require('temp').track()
const fs = require('fs-plus')
const electron = require('electron')
const { sandbox } = require('sinon')

const AtomApplication = require('../../src/main-process/atom-application')
const parseCommandLine = require('../../src/main-process/parse-command-line')
const { emitterEventPromise, conditionPromise } = require('../async-spec-helpers')

// These tests use a utility class called LaunchScenario, defined below, to manipulate AtomApplication instances that
// (1) are stubbed to only simulate AtomWindow creation and (2) allow you to use a shorthand notation to assert the
// application state after certain launch actions.
//
// Each scenario instance has access to a small set of directories and files created within a dedicated temporary
// directory. For convenience, you may use short names to refer to any of its contents (their basenames, basically).
// Check `LaunchScenario::init()` to see what directories and files are available.
//
// To create an application and its first window, call `await scenario.launch({})`. "Launch" may open multiple windows,
// so it returns a Promise that resolves to an array of StubWindows. Its options argument may be created by
// `parseCommandLine()` from a simulated argv string, or built by hand to include `{pathsToOpen}` and so on.
//
// To create additional windows, call `await scenario.open({})` with similar arguments. `LaunchScenario::open()` returns
// a Promise that resolves to the opened or re-used StubWindows. The one exception is if `urlsToOpen` are provided in the open
// arguments; then it resolves to an Array of StubWindows, because AtomApplication processes each URL individually.
//
// To ensure that the expected windows have been created, call `await scenario.assert('')` with a string specifying the
// expected window contents. The specification shorthand language is as follows:
//
// * '[_ _]' describes a single window with no project roots and no open editors.
// * '[_ 1.md]' describes a single window with no project roots and a single editor open on the file `./a/1.md` within
//   the LaunchScenario temporary directory.
// * '[a _]' describes a single window with one project root - the directory `./a` within the LaunchScenario temporary
//   directory - and no open editors.
// * '[a,b 1.md,2.md]' describes a single window with two project roots - the directories `./a` and `./b` - and two
//   open editors - `./a/1.md` and `./b/2.md`.
// * '[a _] [b,c 2.md]' describes two windows, one with a project root of `./a` and no open editors, and another with
//   two project roots, `./b` and `./c`, and one open editor on `./b/2.md`. The windows are listed in their expected
//   creation order.

describe('AtomApplication', function () {
  let scenario, sinon

  beforeEach(async function () {
    sinon = sandbox.create()
    scenario = await LaunchScenario.create(sinon)
  })

  afterEach(async function () {
    await scenario.destroy()
    sinon.restore()
  })

  describe('command-line interface behavior', function () {
    describe('with no open windows', function () {
      // This is also the case when a user selects the application from the OS shell
      it('opens an empty window', async function () {
        await scenario.launch(parseCommandLine([]))
        await scenario.assert('[_ _]')
      })

      // This is also the case when a user clicks on a file in their file manager
      it('opens a file', async function () {
        await scenario.open(parseCommandLine(['a/1.md']))
        await scenario.assert('[_ 1.md]')
      })

      // This is also the case when a user clicks on a folder in their file manager
      // (or, on macOS, drags the folder to Atom in their doc)
      it('opens a directory', async function () {
        await scenario.open(parseCommandLine(['a']))
        await scenario.assert('[a _]')
      })

      it('opens a file with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a/1.md']))
        await scenario.assert('[_ 1.md]')
      })

      it('opens a directory with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a']))
        await scenario.assert('[a _]')
      })

      it('opens a file with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a/1.md']))
        await scenario.assert('[_ 1.md]')
      })

      it('opens a directory with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a']))
        await scenario.assert('[a _]')
      })

      describe('with previous window state', function () {
        let app

        beforeEach(function () {
          app = scenario.addApplication({
            applicationJson: {
              version: '1',
              windows: [
                { projectRoots: [scenario.convertRootPath('b')] },
                { projectRoots: [scenario.convertRootPath('c')] }
              ]
            }
          })
        })

        describe('with core.restorePreviousWindowsOnStart set to "no"', function () {
          beforeEach(function () {
            app.config.set('core.restorePreviousWindowsOnStart', 'no')
          })

          it("doesn't restore windows when launched with no arguments", async function () {
            await scenario.launch({ app })
            await scenario.assert('[_ _]')
          })

          it("doesn't restore windows when launched with paths to open", async function () {
            await scenario.launch({ app, pathsToOpen: ['a/1.md'] })
            await scenario.assert('[_ 1.md]')
          })

          it("doesn't restore windows when --new-window is provided", async function () {
            await scenario.launch({ app, newWindow: true })
            await scenario.assert('[_ _]')
          })
        })

        describe('with core.restorePreviousWindowsOnStart set to "yes"', function () {
          beforeEach(function () {
            app.config.set('core.restorePreviousWindowsOnStart', 'yes')
          })

          it('restores windows when launched with no arguments', async function () {
            await scenario.launch({ app })
            await scenario.assert('[b _] [c _]')
          })

          it("doesn't restore windows when launched with paths to open", async function () {
            await scenario.launch({ app, pathsToOpen: ['a/1.md'] })
            await scenario.assert('[_ 1.md]')
          })

          it("doesn't restore windows when --new-window is provided", async function () {
            await scenario.launch({ app, newWindow: true })
            await scenario.assert('[_ _]')
          })
        })

        describe('with core.restorePreviousWindowsOnStart set to "always"', function () {
          beforeEach(function () {
            app.config.set('core.restorePreviousWindowsOnStart', 'always')
          })

          it('restores windows when launched with no arguments', async function () {
            await scenario.launch({ app })
            await scenario.assert('[b _] [c _]')
          })

          it('restores windows when launched with a project path to open', async function () {
            await scenario.launch({ app, pathsToOpen: ['a'] })
            await scenario.assert('[b _] [c _] [a _]')
          })

          it('restores windows when launched with a file path to open', async function () {
            await scenario.launch({ app, pathsToOpen: ['a/1.md'] })
            await scenario.assert('[b _] [c 1.md]')
          })

          it('collapses new paths into restored windows when appropriate', async function () {
            await scenario.launch({ app, pathsToOpen: ['b/2.md'] })
            await scenario.assert('[b 2.md] [c _]')
          })

          it("doesn't restore windows when --new-window is provided", async function () {
            await scenario.launch({ app, newWindow: true })
            await scenario.assert('[_ _]')
          })

          it("doesn't restore windows on open, just launch", async function () {
            await scenario.launch({ app, pathsToOpen: ['a'], newWindow: true })
            await scenario.open(parseCommandLine(['b']))
            await scenario.assert('[a _] [b _]')
          })
        })
      })

      describe('with unversioned application state', function () {
        it('reads "initialPaths" as project roots', async function () {
          const app = scenario.addApplication({
            applicationJson: [
              {initialPaths: [scenario.convertRootPath('a')]},
              {initialPaths: [scenario.convertRootPath('b'), scenario.convertRootPath('c')]}
            ]
          })
          app.config.set('core.restorePreviousWindowsOnStart', 'always')

          await scenario.launch({app})
          await scenario.assert('[a _] [b,c _]')
        })

        it('filters file paths from project root lists', async function () {
          const app = scenario.addApplication({
            applicationJson: [
              {initialPaths: [scenario.convertRootPath('b'), scenario.convertEditorPath('a/1.md')]}
            ]
          })
          app.config.set('core.restorePreviousWindowsOnStart', 'always')

          await scenario.launch({app})
          await scenario.assert('[b _]')
        })
      })
    })

    describe('with one empty window', function () {
      beforeEach(async function () {
        await scenario.preconditions('[_ _]')
      })

      // This is also the case when a user selects the application from the OS shell
      it('opens a new, empty window', async function () {
        await scenario.open(parseCommandLine([]))
        await scenario.assert('[_ _] [_ _]')
      })

      // This is also the case when a user clicks on a file in their file manager
      it('opens a file', async function () {
        await scenario.open(parseCommandLine(['a/1.md']))
        await scenario.assert('[_ 1.md]')
      })

      // This is also the case when a user clicks on a folder in their file manager
      it('opens a directory', async function () {
        await scenario.open(parseCommandLine(['a']))
        await scenario.assert('[a _]')
      })

      it('opens a file with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a/1.md']))
        await scenario.assert('[_ 1.md]')
      })

      it('opens a directory with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a']))
        await scenario.assert('[a _]')
      })

      it('opens a file with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a/1.md']))
        await scenario.assert('[_ _] [_ 1.md]')
      })

      it('opens a directory with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a']))
        await scenario.assert('[_ _] [a _]')
      })
    })

    describe('with one window that has a project root', function () {
      beforeEach(async function () {
        await scenario.preconditions('[a _]')
      })

      // This is also the case when a user selects the application from the OS shell
      it('opens a new, empty window', async function () {
        await scenario.open(parseCommandLine([]))
        await scenario.assert('[a _] [_ _]')
      })

      // This is also the case when a user clicks on a file within the project root in their file manager
      it('opens a file within the project root', async function () {
        await scenario.open(parseCommandLine(['a/1.md']))
        await scenario.assert('[a 1.md]')
      })

      // This is also the case when a user clicks on a project root folder in their file manager
      it('opens a directory that matches the project root', async function () {
        await scenario.open(parseCommandLine(['a']))
        await scenario.assert('[a _]')
      })

      // This is also the case when a user clicks on a file outside the project root in their file manager
      it('opens a file outside the project root', async function () {
        await scenario.open(parseCommandLine(['b/2.md']))
        await scenario.assert('[a 2.md]')
      })

      // This is also the case when a user clicks on a new folder in their file manager
      it('opens a directory other than the project root', async function () {
        await scenario.open(parseCommandLine(['b']))
        await scenario.assert('[a _] [b _]')
      })

      it('opens a file within the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a/1.md']))
        await scenario.assert('[a 1.md]')
      })

      it('opens a directory that matches the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a']))
        await scenario.assert('[a _]')
      })

      it('opens a file outside the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b/2.md']))
        await scenario.assert('[a 2.md]')
      })

      it('opens a directory other than the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b']))
        await scenario.assert('[a,b _]')
      })

      it('opens a file within the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a/1.md']))
        await scenario.assert('[a _] [_ 1.md]')
      })

      it('opens a directory that matches the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a']))
        await scenario.assert('[a _] [a _]')
      })

      it('opens a file outside the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b/2.md']))
        await scenario.assert('[a _] [_ 2.md]')
      })

      it('opens a directory other than the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b']))
        await scenario.assert('[a _] [b _]')
      })
    })

    describe('with two windows, one with a project root and one empty', function () {
      beforeEach(async function () {
        await scenario.preconditions('[a _] [_ _]')
      })

      // This is also the case when a user selects the application from the OS shell
      it('opens a new, empty window', async function () {
        await scenario.open(parseCommandLine([]))
        await scenario.assert('[a _] [_ _] [_ _]')
      })

      // This is also the case when a user clicks on a file within the project root in their file manager
      it('opens a file within the project root', async function () {
        await scenario.open(parseCommandLine(['a/1.md']))
        await scenario.assert('[a 1.md] [_ _]')
      })

      // This is also the case when a user clicks on a project root folder in their file manager
      it('opens a directory that matches the project root', async function () {
        await scenario.open(parseCommandLine(['a']))
        await scenario.assert('[a _] [_ _]')
      })

      // This is also the case when a user clicks on a file outside the project root in their file manager
      it('opens a file outside the project root', async function () {
        await scenario.open(parseCommandLine(['b/2.md']))
        await scenario.assert('[a _] [_ 2.md]')
      })

      // This is also the case when a user clicks on a new folder in their file manager
      it('opens a directory other than the project root', async function () {
        await scenario.open(parseCommandLine(['b']))
        await scenario.assert('[a _] [b _]')
      })

      it('opens a file within the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a/1.md']))
        await scenario.assert('[a 1.md] [_ _]')
      })

      it('opens a directory that matches the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a']))
        await scenario.assert('[a _] [_ _]')
      })

      it('opens a file outside the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b/2.md']))
        await scenario.assert('[a _] [_ 2.md]')
      })

      it('opens a directory other than the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b']))
        await scenario.assert('[a _] [b _]')
      })

      it('opens a file within the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a/1.md']))
        await scenario.assert('[a _] [_ _] [_ 1.md]')
      })

      it('opens a directory that matches the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a']))
        await scenario.assert('[a _] [_ _] [a _]')
      })

      it('opens a file outside the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b/2.md']))
        await scenario.assert('[a _] [_ _] [_ 2.md]')
      })

      it('opens a directory other than the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b']))
        await scenario.assert('[a _] [_ _] [b _]')
      })
    })

    describe('with two windows, one empty and one with a project root', function () {
      beforeEach(async function () {
        await scenario.preconditions('[_ _] [a _]')
      })

      // This is also the case when a user selects the application from the OS shell
      it('opens a new, empty window', async function () {
        await scenario.open(parseCommandLine([]))
        await scenario.assert('[_ _] [a _] [_ _]')
      })

      // This is also the case when a user clicks on a file within the project root in their file manager
      it('opens a file within the project root', async function () {
        await scenario.open(parseCommandLine(['a/1.md']))
        await scenario.assert('[_ _] [a 1.md]')
      })

      // This is also the case when a user clicks on a project root folder in their file manager
      it('opens a directory that matches the project root', async function () {
        await scenario.open(parseCommandLine(['a']))
        await scenario.assert('[_ _] [a _]')
      })

      // This is also the case when a user clicks on a file outside the project root in their file manager
      it('opens a file outside the project root', async function () {
        await scenario.open(parseCommandLine(['b/2.md']))
        await scenario.assert('[_ 2.md] [a _]')
      })

      // This is also the case when a user clicks on a new folder in their file manager
      it('opens a directory other than the project root', async function () {
        await scenario.open(parseCommandLine(['b']))
        await scenario.assert('[b _] [a _]')
      })

      it('opens a file within the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a/1.md']))
        await scenario.assert('[_ _] [a 1.md]')
      })

      it('opens a directory that matches the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'a']))
        await scenario.assert('[_ _] [a _]')
      })

      it('opens a file outside the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b/2.md']))
        await scenario.assert('[_ _] [a 2.md]')
      })

      it('opens a directory other than the project root with --add', async function () {
        await scenario.open(parseCommandLine(['--add', 'b']))
        await scenario.assert('[_ _] [a,b _]')
      })

      it('opens a file within the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a/1.md']))
        await scenario.assert('[_ _] [a _] [_ 1.md]')
      })

      it('opens a directory that matches the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'a']))
        await scenario.assert('[_ _] [a _] [a _]')
      })

      it('opens a file outside the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b/2.md']))
        await scenario.assert('[_ _] [a _] [_ 2.md]')
      })

      it('opens a directory other than the project root with --new-window', async function () {
        await scenario.open(parseCommandLine(['--new-window', 'b']))
        await scenario.assert('[_ _] [a _] [b _]')
      })
    })

    describe('--wait', function () {
      it('kills the specified pid after a newly-opened window is closed', async function () {
        const [w0] = await scenario.launch(parseCommandLine(['--new-window', '--wait', '--pid', '101']))
        const w1 = await scenario.open(parseCommandLine(['--new-window', '--wait', '--pid', '202']))

        assert.lengthOf(scenario.killedPids, 0)

        w0.browserWindow.emit('closed')
        assert.deepEqual(scenario.killedPids, [101])

        w1.browserWindow.emit('closed')
        assert.deepEqual(scenario.killedPids, [101, 202])
      })

      it('kills the specified pid after all newly-opened files in an existing window are closed', async function () {
        const [w] = await scenario.launch(parseCommandLine(['--new-window', 'a']))
        await scenario.open(parseCommandLine(['--add', '--wait', '--pid', '303', 'a/1.md', 'b/2.md']))
        await scenario.assert('[a 1.md,2.md]')

        assert.lengthOf(scenario.killedPids, 0)

        scenario.getApplication(0).windowDidClosePathWithWaitSession(w, scenario.convertEditorPath('b/2.md'))
        assert.lengthOf(scenario.killedPids, 0)
        scenario.getApplication(0).windowDidClosePathWithWaitSession(w, scenario.convertEditorPath('a/1.md'))
        assert.deepEqual(scenario.killedPids, [303])
      })

      it('kills the specified pid after a newly-opened directory in an existing window is closed', async function () {
        const [w] = await scenario.launch(parseCommandLine(['--new-window', 'a']))
        await scenario.open(parseCommandLine(['--add', '--wait', '--pid', '404', 'b']))
        await scenario.assert('[a,b _]')

        assert.lengthOf(scenario.killedPids, 0)

        scenario.getApplication(0).windowDidClosePathWithWaitSession(w, scenario.convertRootPath('b'))
        assert.deepEqual(scenario.killedPids, [404])
      })
    })

    describe('atom:// URLs', function () {
      describe('with a package-name host', function () {
        it("loads the package's urlMain in a new window", async function () {
          await scenario.launch({})

          const app = scenario.getApplication(0)
          app.packages = {
            getAvailablePackageMetadata: () => [{ name: 'package-with-url-main', urlMain: 'some/url-main' }],
            resolvePackagePath: () => path.resolve('dot-atom/package-with-url-main')
          }

          const [w1, w2] = await scenario.open(parseCommandLine([
            'atom://package-with-url-main/test1',
            'atom://package-with-url-main/test2'
          ]))

          assert.strictEqual(
            w1.loadSettings.windowInitializationScript,
            path.resolve('dot-atom/package-with-url-main/some/url-main')
          )
          assert.strictEqual(
            w1.loadSettings.urlToOpen,
            'atom://package-with-url-main/test1'
          )

          assert.strictEqual(
            w2.loadSettings.windowInitializationScript,
            path.resolve('dot-atom/package-with-url-main/some/url-main')
          )
          assert.strictEqual(
            w2.loadSettings.urlToOpen,
            'atom://package-with-url-main/test2'
          )
        })

        it('sends a URI message to the most recently focused non-spec window', async function () {
          const [w0] = await scenario.launch({})
          const w1 = await scenario.open(parseCommandLine(['--new-window']))
          const w2 = await scenario.open(parseCommandLine(['--new-window']))
          const w3 = await scenario.open(parseCommandLine(['--test', 'a/1.md']))

          const app = scenario.getApplication(0)
          app.packages = {
            getAvailablePackageMetadata: () => []
          }

          const [uw] = await scenario.open(parseCommandLine(['atom://package-without-url-main/test']))
          assert.strictEqual(uw, w2)

          assert.isTrue(w2.sendURIMessage.calledWith('atom://package-without-url-main/test'))
          assert.strictEqual(w2.focus.callCount, 2)

          for (const other of [w0, w1, w3]) {
            assert.isFalse(other.sendURIMessage.called)
          }
        })

        it('creates a new window and sends a URI message to it once it loads', async function () {
          const [w0] = await scenario.launch(parseCommandLine(['--test', 'a/1.md']))

          const app = scenario.getApplication(0)
          app.packages = {
            getAvailablePackageMetadata: () => []
          }

          const [uw] = await scenario.open(parseCommandLine(['atom://package-without-url-main/test']))
          assert.notStrictEqual(uw, w0)
          assert.strictEqual(
            uw.loadSettings.windowInitializationScript,
            path.resolve(__dirname, '../../src/initialize-application-window.coffee')
          )

          uw.emit('window:loaded')
          assert.isTrue(uw.sendURIMessage.calledWith('atom://package-without-url-main/test'))
        })
      })

      describe('with a "core" host', function () {
        it('sends a URI message to the most recently focused non-spec window that owns the open locations', async function () {
          const [w0] = await scenario.launch(parseCommandLine(['a']))
          const w1 = await scenario.open(parseCommandLine(['--new-window', 'a']))
          const w2 = await scenario.open(parseCommandLine(['--new-window', 'b']))

          const uri = `atom://core/open/file?filename=${encodeURIComponent(scenario.convertEditorPath('a/1.md'))}`
          const [uw] = await scenario.open(parseCommandLine([uri]))
          assert.strictEqual(uw, w1)
          assert.isTrue(w1.sendURIMessage.calledWith(uri))

          for (const other of [w0, w2]) {
            assert.isFalse(other.sendURIMessage.called)
          }
        })

        it('creates a new window and sends a URI message to it once it loads', async function () {
          const [w0] = await scenario.launch(parseCommandLine(['--test', 'a/1.md']))

          const uri = `atom://core/open/file?filename=${encodeURIComponent(scenario.convertEditorPath('b/2.md'))}`
          const [uw] = await scenario.open(parseCommandLine([uri]))
          assert.notStrictEqual(uw, w0)

          uw.emit('window:loaded')
          assert.isTrue(uw.sendURIMessage.calledWith(uri))
        })
      })
    })

    it('opens a file to a specific line number', async function () {
      await scenario.open(parseCommandLine(['a/1.md:10']))
      await scenario.assert('[_ 1.md]')

      const w = scenario.getWindow(0)
      assert.lengthOf(w._locations, 1)
      assert.strictEqual(w._locations[0].initialLine, 9)
      assert.isNull(w._locations[0].initialColumn)
    })

    it('opens a file to a specific line number and column', async function () {
      await scenario.open(parseCommandLine('b/2.md:12:5'))
      await scenario.assert('[_ 2.md]')

      const w = scenario.getWindow(0)
      assert.lengthOf(w._locations, 1)
      assert.strictEqual(w._locations[0].initialLine, 11)
      assert.strictEqual(w._locations[0].initialColumn, 4)
    })

    it('opens a directory with a non-file protocol', async function () {
      await scenario.open(parseCommandLine(['remote://server:3437/some/directory/path']))

      const w = scenario.getWindow(0)
      assert.lengthOf(w._locations, 1)
      assert.strictEqual(w._locations[0].pathToOpen, 'remote://server:3437/some/directory/path')
      assert.isFalse(w._locations[0].exists)
      assert.isFalse(w._locations[0].isDirectory)
      assert.isFalse(w._locations[0].isFile)
    })

    it('truncates trailing whitespace and colons', async function () {
      await scenario.open(parseCommandLine('b/2.md::  '))
      await scenario.assert('[_ 2.md]')

      const w = scenario.getWindow(0)
      assert.lengthOf(w._locations, 1)
      assert.isNull(w._locations[0].initialLine)
      assert.isNull(w._locations[0].initialColumn)
    })
  })

  if (process.platform === 'darwin' || process.platform === 'win32') {
    it('positions new windows at an offset from the previous window', async function () {
      const [w0] = await scenario.launch(parseCommandLine(['a']))
      w0.setSize(400, 400)
      const d0 = w0.getDimensions()

      const w1 = await scenario.open(parseCommandLine(['b']))
      const d1 = w1.getDimensions()

      assert.isAbove(d1.x, d0.x)
      assert.isAbove(d1.y, d0.y)
    })
  }

  if (process.platform === 'darwin') {
    describe('with no windows open', function () {
      let app

      beforeEach(async function () {
        const [w] = await scenario.launch(parseCommandLine([]))

        app = scenario.getApplication(0)
        app.removeWindow(w)
        sinon.stub(app, 'promptForPathToOpen')
      })

      it('opens a new file', function () {
        app.emit('application:open-file')
        assert.isTrue(app.promptForPathToOpen.calledWith('file', { devMode: false, safeMode: false, window: null }))
      })

      it('opens a new directory', function () {
        app.emit('application:open-folder')
        assert.isTrue(app.promptForPathToOpen.calledWith('folder', { devMode: false, safeMode: false, window: null }))
      })

      it('opens a new file or directory', function () {
        app.emit('application:open')
        assert.isTrue(app.promptForPathToOpen.calledWith('all', { devMode: false, safeMode: false, window: null }))
      })

      it('reopens a project in a new window', async function () {
        const paths = scenario.convertPaths(['a', 'b'])
        app.emit('application:reopen-project', { paths })

        await conditionPromise(() => app.getAllWindows().length > 0)

        assert.deepEqual(app.getAllWindows().map(w => Array.from(w._rootPaths)), [paths])
      })
    })
  }

  describe('existing application re-use', function () {
    let createApplication

    const version = electron.app.getVersion()

    beforeEach(function () {
      createApplication = async options => {
        options.version = version

        const app = scenario.addApplication(options)
        await app.listenForArgumentsFromNewProcess(options)
        await app.launch(options)
        return app
      }
    })

    it('creates a new application when no socket is present', async function () {
      const app0 = await AtomApplication.open({ createApplication, version })
      await app0.deleteSocketSecretFile()

      const app1 = await AtomApplication.open({ createApplication, version })
      assert.isNotNull(app1)
      assert.notStrictEqual(app0, app1)
    })

    it('creates a new application for spec windows', async function () {
      const app0 = await AtomApplication.open({ createApplication, version })

      const app1 = await AtomApplication.open({ createApplication, version, ...parseCommandLine(['--test', 'a']) })
      assert.isNotNull(app1)
      assert.notStrictEqual(app0, app1)
    })

    it('sends a request to an existing application when a socket is present', async function () {
      const app0 = await AtomApplication.open({ createApplication, version })
      assert.lengthOf(app0.getAllWindows(), 1)

      const app1 = await AtomApplication.open({ createApplication, version, ...parseCommandLine(['--new-window']) })
      assert.isNull(app1)
      assert.isTrue(electron.app.quit.called)

      await conditionPromise(() => app0.getAllWindows().length === 2)
      await scenario.assert('[_ _] [_ _]')
    })
  })

  describe('IPC handling', function () {
    let w0, w1, w2, app

    beforeEach(async function () {
      w0 = (await scenario.launch(parseCommandLine(['a'])))[0]
      w1 = await scenario.open(parseCommandLine(['--new-window']))
      w2 = await scenario.open(parseCommandLine(['--new-window', 'b']))

      app = scenario.getApplication(0)
      sinon.spy(app, 'openPaths')
      sinon.stub(app, 'promptForPath', (_type, callback, defaultPath) => callback([defaultPath]))
    })

    // This is the IPC message used to handle:
    // * application:reopen-project
    // * choosing "open in new window" when adding a folder that has previously saved state
    // * deprecated call links in deprecation-cop
    // * other direct callers of `atom.open()`
    it('"open" opens a fixed path by the standard opening rules', async function () {
      sinon.stub(app, 'atomWindowForEvent', () => w1)

      electron.ipcMain.emit('open', {}, { pathsToOpen: scenario.convertEditorPath('a/1.md') })
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a 1.md] [_ _] [b _]')

      electron.ipcMain.emit('open', {}, { pathsToOpen: scenario.convertRootPath('c') })
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a 1.md] [c _] [b _]')

      electron.ipcMain.emit('open', {}, { pathsToOpen: scenario.convertRootPath('d') })
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a 1.md] [c _] [b _] [d _]')
    })

    it('"open-chosen-any" opens a file in the sending window', async function () {
      sinon.stub(app, 'atomWindowForEvent', () => w2)

      electron.ipcMain.emit('open-chosen-any', {}, scenario.convertEditorPath('a/1.md'))
      await conditionPromise(() => app.openPaths.called)
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a _] [_ _] [b 1.md]')

      assert.isTrue(app.promptForPath.called)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'all')
    })

    it('"open-chosen-any" opens a directory by the standard opening rules', async function () {
      sinon.stub(app, 'atomWindowForEvent', () => w1)

      // Open unrecognized directory in empty window
      electron.ipcMain.emit('open-chosen-any', {}, scenario.convertRootPath('c'))
      await conditionPromise(() => app.openPaths.callCount > 0)
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a _] [c _] [b _]')

      assert.strictEqual(app.promptForPath.callCount, 1)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'all')

      // Open unrecognized directory in new window
      electron.ipcMain.emit('open-chosen-any', {}, scenario.convertRootPath('d'))
      await conditionPromise(() => app.openPaths.callCount > 1)
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a _] [c _] [b _] [d _]')

      assert.strictEqual(app.promptForPath.callCount, 2)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'all')

      // Open recognized directory in existing window
      electron.ipcMain.emit('open-chosen-any', {}, scenario.convertRootPath('a'))
      await conditionPromise(() => app.openPaths.callCount > 2)
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a _] [c _] [b _] [d _]')

      assert.strictEqual(app.promptForPath.callCount, 3)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'all')
    })

    it('"open-chosen-file" opens a file chooser and opens the chosen file in the sending window', async function () {
      sinon.stub(app, 'atomWindowForEvent', () => w0)

      electron.ipcMain.emit('open-chosen-file', {}, scenario.convertEditorPath('b/2.md'))
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a 2.md] [_ _] [b _]')

      assert.isTrue(app.promptForPath.called)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'file')
    })

    it('"open-chosen-folder" opens a directory chooser and opens the chosen directory', async function () {
      sinon.stub(app, 'atomWindowForEvent', () => w0)

      electron.ipcMain.emit('open-chosen-folder', {}, scenario.convertRootPath('c'))
      await app.openPaths.lastCall.returnValue
      await scenario.assert('[a _] [c _] [b _]')

      assert.isTrue(app.promptForPath.called)
      assert.strictEqual(app.promptForPath.lastCall.args[0], 'folder')
    })
  })

  describe('window state serialization', function () {
    it('occurs immediately when adding a window', async function () {
      await scenario.launch(parseCommandLine(['a']))

      const promise = emitterEventPromise(scenario.getApplication(0), 'application:did-save-state')
      await scenario.open(parseCommandLine(['c', 'b']))
      await promise

      assert.isTrue(scenario.getApplication(0).storageFolder.store.calledWith(
        'application.json',
        {
          version: '1',
          windows: [
            { projectRoots: [scenario.convertRootPath('a')] },
            { projectRoots: [scenario.convertRootPath('b'), scenario.convertRootPath('c')] }
          ]
        }
      ))
    })

    it('occurs immediately when removing a window', async function () {
      await scenario.launch(parseCommandLine(['a']))
      const w = await scenario.open(parseCommandLine(['b']))

      const promise = emitterEventPromise(scenario.getApplication(0), 'application:did-save-state')
      scenario.getApplication(0).removeWindow(w)
      await promise

      assert.isTrue(scenario.getApplication(0).storageFolder.store.calledWith(
        'application.json',
        {
          version: '1',
          windows: [
            { projectRoots: [scenario.convertRootPath('a')] }
          ]
        }
      ))
    })

    it('occurs when the window is blurred', async function () {
      const [w] = await scenario.launch(parseCommandLine(['a']))
      const promise = emitterEventPromise(scenario.getApplication(0), 'application:did-save-state')
      w.browserWindow.emit('blur')
      await promise
    })
  })

  describe('when closing the last window', function () {
    if (process.platform === 'linux' || process.platform === 'win32') {
      it('quits the application', async function () {
        const [w] = await scenario.launch(parseCommandLine(['a']))
        scenario.getApplication(0).removeWindow(w)
        assert.isTrue(electron.app.quit.called)
      })
    } else if (process.platform === 'darwin') {
      it('leaves the application open', async function () {
        const [w] = await scenario.launch(parseCommandLine(['a']))
        scenario.getApplication(0).removeWindow(w)
        assert.isFalse(electron.app.quit.called)
      })
    }
  })

  describe('quitting', function () {
    it('waits until all windows have saved their state before quitting', async function () {
      const [w0] = await scenario.launch(parseCommandLine(['a']))
      const w1 = await scenario.open(parseCommandLine(['b']))
      assert.notStrictEqual(w0, w1)

      sinon.spy(w0, 'close')
      let resolveUnload0
      w0.prepareToUnload = () => new Promise(resolve => { resolveUnload0 = resolve })

      sinon.spy(w1, 'close')
      let resolveUnload1
      w1.prepareToUnload = () => new Promise(resolve => { resolveUnload1 = resolve })

      const evt = { preventDefault: sinon.spy() }
      electron.app.emit('before-quit', evt)
      await new Promise(process.nextTick)
      assert.isTrue(evt.preventDefault.called)
      assert.isFalse(electron.app.quit.called)

      resolveUnload1(true)
      await new Promise(process.nextTick)
      assert.isFalse(electron.app.quit.called)

      resolveUnload0(true)
      await scenario.getApplication(0).lastBeforeQuitPromise
      assert.isTrue(electron.app.quit.called)

      assert.isTrue(w0.close.called)
      assert.isTrue(w1.close.called)
    })

    it('prevents a quit if a user cancels when prompted to save', async function () {
      const [w] = await scenario.launch(parseCommandLine(['a']))
      let resolveUnload
      w.prepareToUnload = () => new Promise(resolve => { resolveUnload = resolve })

      const evt = { preventDefault: sinon.spy() }
      electron.app.emit('before-quit', evt)
      await new Promise(process.nextTick)
      assert.isTrue(evt.preventDefault.called)

      resolveUnload(false)
      await scenario.getApplication(0).lastBeforeQuitPromise

      assert.isFalse(electron.app.quit.called)
    })

    it('closes successfully unloaded windows', async function () {
      const [w0] = await scenario.launch(parseCommandLine(['a']))
      const w1 = await scenario.open(parseCommandLine(['b']))

      sinon.spy(w0, 'close')
      let resolveUnload0
      w0.prepareToUnload = () => new Promise(resolve => { resolveUnload0 = resolve })

      sinon.spy(w1, 'close')
      let resolveUnload1
      w1.prepareToUnload = () => new Promise(resolve => { resolveUnload1 = resolve })

      const evt = { preventDefault () { } }
      electron.app.emit('before-quit', evt)

      resolveUnload0(false)
      resolveUnload1(true)

      await scenario.getApplication(0).lastBeforeQuitPromise

      assert.isFalse(electron.app.quit.called)
      assert.isFalse(w0.close.called)
      assert.isTrue(w1.close.called)
    })
  })
})

class StubWindow extends EventEmitter {
  constructor (sinon, loadSettings, options) {
    super()

    this.loadSettings = loadSettings

    this._dimensions = Object.assign({}, loadSettings.windowDimensions) || { x: 100, y: 100 }
    this._position = { x: 0, y: 0 }
    this._locations = []
    this._rootPaths = new Set()
    this._editorPaths = new Set()

    let resolveClosePromise
    this.closedPromise = new Promise(resolve => { resolveClosePromise = resolve })

    this.minimize = sinon.spy()
    this.maximize = sinon.spy()
    this.center = sinon.spy()
    this.focus = sinon.spy()
    this.show = sinon.spy()
    this.hide = sinon.spy()
    this.prepareToUnload = sinon.spy()
    this.close = resolveClosePromise

    this.replaceEnvironment = sinon.spy()
    this.disableZoom = sinon.spy()

    this.isFocused = sinon.stub().returns(options.isFocused !== undefined ? options.isFocused : false)
    this.isMinimized = sinon.stub().returns(options.isMinimized !== undefined ? options.isMinimized : false)
    this.isMaximized = sinon.stub().returns(options.isMaximized !== undefined ? options.isMaximized : false)

    this.sendURIMessage = sinon.spy()
    this.didChangeUserSettings = sinon.spy()
    this.didFailToReadUserSettings = sinon.spy()

    this.isSpec = loadSettings.isSpec !== undefined ? loadSettings.isSpec : false
    this.devMode = loadSettings.devMode !== undefined ? loadSettings.devMode : false
    this.safeMode = loadSettings.safeMode !== undefined ? loadSettings.safeMode : false

    this.browserWindow = new EventEmitter()
    this.browserWindow.webContents = new EventEmitter()

    const locationsToOpen = this.loadSettings.locationsToOpen || []
    if (!(locationsToOpen.length === 1 && locationsToOpen[0].pathToOpen == null) && !this.isSpec) {
      this.openLocations(locationsToOpen)
    }
  }

  openPath (pathToOpen, initialLine, initialColumn) {
    return this.openLocations([{ pathToOpen, initialLine, initialColumn }])
  }

  openLocations (locations) {
    this._locations.push(...locations)
    for (const location of locations) {
      if (location.pathToOpen) {
        if (location.isDirectory) {
          this._rootPaths.add(location.pathToOpen)
        } else if (location.isFile) {
          this._editorPaths.add(location.pathToOpen)
        }
      }
    }

    this.projectRoots = Array.from(this._rootPaths)
    this.projectRoots.sort()

    this.emit('window:locations-opened')
  }

  setSize (x, y) {
    this._dimensions = { x, y }
  }

  setPosition (x, y) {
    this._position = { x, y }
  }

  isSpecWindow () {
    return this.isSpec
  }

  hasProjectPaths () {
    return this._rootPaths.size > 0
  }

  containsLocations (locations) {
    return locations.every(location => this.containsLocation(location))
  }

  containsLocation (location) {
    if (!location.pathToOpen) return false

    return Array.from(this._rootPaths).some(projectPath => {
      if (location.pathToOpen === projectPath) return true
      if (location.pathToOpen.startsWith(path.join(projectPath, path.sep))) {
        if (!location.exists) return true
        if (!location.isDirectory) return true
      }
      return false
    })
  }

  getDimensions () {
    return Object.assign({}, this._dimensions)
  }
}

class LaunchScenario {
  static async create (sandbox) {
    const scenario = new this(sandbox)
    await scenario.init()
    return scenario
  }

  constructor (sandbox) {
    this.sinon = sandbox

    this.applications = new Set()
    this.windows = new Set()
    this.root = null
    this.atomHome = null
    this.projectRootPool = new Map()
    this.filePathPool = new Map()

    this.killedPids = []
    this.originalAtomHome = null
  }

  async init () {
    if (this.root !== null) {
      return this.root
    }

    this.root = await new Promise((resolve, reject) => {
      temp.mkdir('launch-', (err, rootPath) => {
        if (err) { reject(err) } else { resolve(rootPath) }
      })
    })

    this.atomHome = path.join(this.root, '.atom')
    await new Promise((resolve, reject) => {
      fs.makeTree(this.atomHome, err => {
        if (err) { reject(err) } else { resolve() }
      })
    })
    this.originalAtomHome = process.env.ATOM_HOME
    process.env.ATOM_HOME = this.atomHome

    await Promise.all(
      ['a', 'b', 'c', 'd'].map(dirPath => new Promise((resolve, reject) => {
        const fullDirPath = path.join(this.root, dirPath)
        fs.makeTree(fullDirPath, err => {
          if (err) {
            reject(err)
          } else {
            this.projectRootPool.set(dirPath, fullDirPath)
            resolve()
          }
        })
      }))
    )

    await Promise.all(
      ['a/1.md', 'b/2.md'].map(filePath => new Promise((resolve, reject) => {
        const fullFilePath = path.join(this.root, filePath)
        fs.writeFile(fullFilePath, `file: ${filePath}\n`, { encoding: 'utf8' }, err => {
          if (err) {
            reject(err)
          } else {
            this.filePathPool.set(filePath, fullFilePath)
            this.filePathPool.set(path.basename(filePath), fullFilePath)
            resolve()
          }
        })
      }))
    )

    this.sinon.stub(electron.app, 'quit')
  }

  async preconditions (source) {
    const app = this.addApplication()
    const windowPromises = []

    for (const windowSpec of this.parseWindowSpecs(source)) {
      if (windowSpec.editors.length === 0) {
        windowSpec.editors.push(null)
      }

      windowPromises.push(((theApp, foldersToOpen, pathsToOpen) => {
        return theApp.openPaths({ newWindow: true, foldersToOpen, pathsToOpen })
      })(app, windowSpec.roots, windowSpec.editors))
    }
    await Promise.all(windowPromises)
  }

  launch (options) {
    const app = options.app || this.addApplication()
    delete options.app

    if (options.pathsToOpen) {
      options.pathsToOpen = this.convertPaths(options.pathsToOpen)
    }

    return app.launch(options)
  }

  open (options) {
    if (this.applications.size === 0) {
      return this.launch(options)
    }

    let app = options.app
    if (!app) {
      const apps = Array.from(this.applications)
      app = apps[apps.length - 1]
    } else {
      delete options.app
    }

    if (options.pathsToOpen) {
      options.pathsToOpen = this.convertPaths(options.pathsToOpen)
    }
    options.preserveFocus = true

    return app.openWithOptions(options)
  }

  async assert (source) {
    const windowSpecs = this.parseWindowSpecs(source)
    let specIndex = 0

    const windowPromises = []
    for (const window of this.windows) {
      windowPromises.push((async (theWindow, theSpec) => {
        const { _rootPaths: rootPaths, _editorPaths: editorPaths } = theWindow

        const comparison = {
          ok: true,
          extraWindow: false,
          missingWindow: false,
          extraRoots: [],
          missingRoots: [],
          extraEditors: [],
          missingEditors: [],
          roots: rootPaths,
          editors: editorPaths
        }

        if (!theSpec) {
          comparison.ok = false
          comparison.extraWindow = true
          comparison.extraRoots = rootPaths
          comparison.extraEditors = editorPaths
        } else {
          const [missingRoots, extraRoots] = this.compareSets(theSpec.roots, rootPaths)
          const [missingEditors, extraEditors] = this.compareSets(theSpec.editors, editorPaths)

          comparison.ok = missingRoots.length === 0 &&
            extraRoots.length === 0 &&
            missingEditors.length === 0 &&
            extraEditors.length === 0
          comparison.extraRoots = extraRoots
          comparison.missingRoots = missingRoots
          comparison.extraEditors = extraEditors
          comparison.missingEditors = missingEditors
        }

        return comparison
      })(window, windowSpecs[specIndex++]))
    }

    const comparisons = await Promise.all(windowPromises)
    for (; specIndex < windowSpecs.length; specIndex++) {
      const spec = windowSpecs[specIndex]
      comparisons.push({
        ok: false,
        extraWindow: false,
        missingWindow: true,
        extraRoots: [],
        missingRoots: spec.roots,
        extraEditors: [],
        missingEditors: spec.editors,
        roots: null,
        editors: null
      })
    }

    const shorthandParts = []
    const descriptionParts = []
    for (const comparison of comparisons) {
      if (comparison.roots !== null && comparison.editors !== null) {
        const shortRoots = Array.from(comparison.roots, r => path.basename(r)).join(',')
        const shortPaths = Array.from(comparison.editors, e => path.basename(e)).join(',')
        shorthandParts.push(`[${shortRoots} ${shortPaths}]`)
      }

      if (comparison.ok) {
        continue
      }

      let parts = []
      if (comparison.extraWindow) {
        parts.push('extra window\n')
      } else if (comparison.missingWindow) {
        parts.push('missing window\n')
      } else {
        parts.push('incorrect window\n')
      }

      const shorten = fullPaths => fullPaths.map(fullPath => path.basename(fullPath)).join(', ')

      if (comparison.extraRoots.length > 0) {
        parts.push(`* extra roots ${shorten(comparison.extraRoots)}\n`)
      }
      if (comparison.missingRoots.length > 0) {
        parts.push(`* missing roots ${shorten(comparison.missingRoots)}\n`)
      }
      if (comparison.extraEditors.length > 0) {
        parts.push(`* extra editors ${shorten(comparison.extraEditors)}\n`)
      }
      if (comparison.missingEditors.length > 0) {
        parts.push(`* missing editors ${shorten(comparison.missingEditors)}\n`)
      }

      descriptionParts.push(parts.join(''))
    }

    if (descriptionParts.length !== 0) {
      descriptionParts.unshift(shorthandParts.join(' ') + '\n')
      descriptionParts.unshift('Launched windows did not match spec\n')
    }

    assert.isTrue(descriptionParts.length === 0, descriptionParts.join(''))
  }

  async destroy () {
    await Promise.all(
      Array.from(this.applications, app => app.destroy())
    )

    if (this.originalAtomHome) {
      process.env.ATOM_HOME = this.originalAtomHome
    }
  }

  addApplication (options = {}) {
    const app = new AtomApplication({
      resourcePath: path.resolve(__dirname, '../..'),
      atomHomeDirPath: this.atomHome,
      preserveFocus: true,
      killProcess: pid => { this.killedPids.push(pid) },
      ...options
    })
    this.sinon.stub(app, 'createWindow', loadSettings => {
      const newWindow = new StubWindow(this.sinon, loadSettings, options)
      this.windows.add(newWindow)
      return newWindow
    })
    this.sinon.stub(app.storageFolder, 'load', () => Promise.resolve(
      options.applicationJson || {version: '1', windows: []}
    ))
    this.sinon.stub(app.storageFolder, 'store', () => Promise.resolve())
    this.applications.add(app)
    return app
  }

  getApplication (index) {
    const app = Array.from(this.applications)[index]
    if (!app) {
      throw new Error(`Application ${index} does not exist`)
    }
    return app
  }

  getWindow (index) {
    const window = Array.from(this.windows)[index]
    if (!window) {
      throw new Error(`Window ${index} does not exist`)
    }
    return window
  }

  compareSets (expected, actual) {
    const expectedItems = new Set(expected)
    const extra = []
    const missing = []

    for (const actualItem of actual) {
      if (!expectedItems.delete(actualItem)) {
        // actualItem was present, but not expected
        extra.push(actualItem)
      }
    }
    for (const remainingItem of expectedItems) {
      // remainingItem was expected, but not present
      missing.push(remainingItem)
    }
    return [missing, extra]
  }

  convertRootPath (shortRootPath) {
    if (shortRootPath.startsWith('atom://') || shortRootPath.startsWith('remote://')) { return shortRootPath }

    const fullRootPath = this.projectRootPool.get(shortRootPath)
    if (!fullRootPath) {
      throw new Error(`Unexpected short project root path: ${shortRootPath}`)
    }
    return fullRootPath
  }

  convertEditorPath (shortEditorPath) {
    const [truncatedPath, ...suffix] = shortEditorPath.split(/(?=:)/)
    const fullEditorPath = this.filePathPool.get(truncatedPath)
    if (!fullEditorPath) {
      throw new Error(`Unexpected short editor path: ${shortEditorPath}`)
    }
    return fullEditorPath + suffix.join('')
  }

  convertPaths (paths) {
    return paths.map(shortPath => {
      if (shortPath.startsWith('atom://') || shortPath.startsWith('remote://')) { return shortPath }

      const fullRoot = this.projectRootPool.get(shortPath)
      if (fullRoot) { return fullRoot }

      const [truncatedPath, ...suffix] = shortPath.split(/(?=:)/)
      const fullEditor = this.filePathPool.get(truncatedPath)
      if (fullEditor) { return fullEditor + suffix.join('') }

      throw new Error(`Unexpected short path: ${shortPath}`)
    })
  }

  parseWindowSpecs (source) {
    const specs = []

    const rx = /\s*\[(?:_|(\S+)) (?:_|(\S+))\]/g
    let match = rx.exec(source)

    while (match) {
      const roots = match[1] ? match[1].split(',').map(shortPath => this.convertRootPath(shortPath)) : []
      const editors = match[2] ? match[2].split(',').map(shortPath => this.convertEditorPath(shortPath)) : []
      specs.push({ roots, editors })

      match = rx.exec(source)
    }

    return specs
  }
}
