/** @babel */

import {it, ffit, fffit, beforeEach, afterEach} from './async-spec-helpers'
import TextEditorElement from '../src/text-editor-element'
import _, {extend, flatten, last, toArray} from 'underscore-plus'

const NBSP = String.fromCharCode(160)
const TILE_SIZE = 3

describe('TextEditorComponent', function () {
  let charWidth, component, componentNode, contentNode, editor,
      horizontalScrollbarNode, lineHeightInPixels, tileHeightInPixels,
      verticalScrollbarNode, wrapperNode

  beforeEach(async function () {
    jasmine.useRealClock()

    await atom.packages.activatePackage('language-javascript')
    editor = await atom.workspace.open('sample.js')

    contentNode = document.querySelector('#jasmine-content')
    contentNode.style.width = '1000px'

    wrapperNode = new TextEditorElement()
    wrapperNode.tileSize = TILE_SIZE
    wrapperNode.initialize(editor, atom)
    wrapperNode.setUpdatedSynchronously(false)
    jasmine.attachToDOM(wrapperNode)

    component = wrapperNode.component
    component.setFontFamily('monospace')
    component.setLineHeight(1.3)
    component.setFontSize(20)

    lineHeightInPixels = editor.getLineHeightInPixels()
    tileHeightInPixels = TILE_SIZE * lineHeightInPixels
    charWidth = editor.getDefaultCharWidth()

    componentNode = component.getDomNode()
    verticalScrollbarNode = componentNode.querySelector('.vertical-scrollbar')
    horizontalScrollbarNode = componentNode.querySelector('.horizontal-scrollbar')

    component.measureDimensions()
    await nextViewUpdatePromise()
  })

  afterEach(function () {
    contentNode.style.width = ''
  })

  describe('async updates', function () {
    it('handles corrupted state gracefully', async function () {
      editor.insertNewline()
      component.presenter.startRow = -1
      component.presenter.endRow = 9999
      await nextViewUpdatePromise() // assert an update does occur
    })

    it('does not update when an animation frame was requested but the component got destroyed before its delivery', async function () {
      editor.setText('You should not see this update.')
      component.destroy()

      await nextViewUpdatePromise()

      expect(component.lineNodeForScreenRow(0).textContent).not.toBe('You should not see this update.')
    })
  })

  describe('line rendering', async function () {
    function expectTileContainsRow (tileNode, screenRow, {top}) {
      let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]')
      let tokenizedLine = editor.tokenizedLineForScreenRow(screenRow)

      expect(lineNode.offsetTop).toBe(top)
      if (tokenizedLine.text === '') {
        expect(lineNode.innerHTML).toBe('&nbsp;')
      } else {
        expect(lineNode.textContent).toBe(tokenizedLine.text)
      }
    }

    it('gives the lines container the same height as the wrapper node', async function () {
      let linesNode = componentNode.querySelector('.lines')
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels)
      wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels)
    })

    it('renders higher tiles in front of lower ones', async function () {
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      let tilesNodes = component.tileNodesForLines()
      expect(tilesNodes[0].style.zIndex).toBe('2')
      expect(tilesNodes[1].style.zIndex).toBe('1')
      expect(tilesNodes[2].style.zIndex).toBe('0')
      verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))

      await nextViewUpdatePromise()

      tilesNodes = component.tileNodesForLines()
      expect(tilesNodes[0].style.zIndex).toBe('3')
      expect(tilesNodes[1].style.zIndex).toBe('2')
      expect(tilesNodes[2].style.zIndex).toBe('1')
      expect(tilesNodes[3].style.zIndex).toBe('0')
    })

    it('renders the currently-visible lines in a tiled fashion', async function () {
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      let tilesNodes = component.tileNodesForLines()
      expect(tilesNodes.length).toBe(3)

      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)')
      expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[0], 0, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 1, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 2, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)')
      expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[1], 3, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 4, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 5, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)')
      expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[2], 6, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 7, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 8, {
        top: 2 * lineHeightInPixels
      })

      expect(component.lineNodeForScreenRow(9)).toBeUndefined()

      verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))

      await nextViewUpdatePromise()

      tilesNodes = component.tileNodesForLines()
      expect(component.lineNodeForScreenRow(2)).toBeUndefined()
      expect(tilesNodes.length).toBe(3)

      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[0].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[0], 3, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 4, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 5, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[1].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[1], 6, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 7, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 8, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[2].querySelectorAll('.line').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[2], 9, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 10, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 11, {
        top: 2 * lineHeightInPixels
      })
    })

    it('updates the top position of subsequent tiles when lines are inserted or removed', async function () {
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      editor.getBuffer().deleteRows(0, 1)

      await nextViewUpdatePromise()

      let tilesNodes = component.tileNodesForLines()
      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)')
      expectTileContainsRow(tilesNodes[0], 0, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 1, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 2, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)')
      expectTileContainsRow(tilesNodes[1], 3, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 4, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 5, {
        top: 2 * lineHeightInPixels
      })

      editor.getBuffer().insert([0, 0], '\n\n')

      await nextViewUpdatePromise()

      tilesNodes = component.tileNodesForLines()
      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)')
      expectTileContainsRow(tilesNodes[0], 0, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 1, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[0], 2, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)')
      expectTileContainsRow(tilesNodes[1], 3, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 4, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[1], 5, {
        top: 2 * lineHeightInPixels
      })

      expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)')
      expectTileContainsRow(tilesNodes[2], 6, {
        top: 0 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 7, {
        top: 1 * lineHeightInPixels
      })
      expectTileContainsRow(tilesNodes[2], 8, {
        top: 2 * lineHeightInPixels
      })
    })

    it('updates the lines when lines are inserted or removed above the rendered row range', async function () {
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      verticalScrollbarNode.scrollTop = 5 * lineHeightInPixels
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))

      await nextViewUpdatePromise()

      let buffer = editor.getBuffer()
      buffer.insert([0, 0], '\n\n')

      await nextViewUpdatePromise()

      expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.tokenizedLineForScreenRow(3).text)
      buffer.delete([[0, 0], [3, 0]])

      await nextViewUpdatePromise()

      expect(component.lineNodeForScreenRow(3).textContent).toBe(editor.tokenizedLineForScreenRow(3).text)
    })

    it('updates the top position of lines when the line height changes', async function () {
      let initialLineHeightInPixels = editor.getLineHeightInPixels()

      component.setLineHeight(2)

      await nextViewUpdatePromise()

      let newLineHeightInPixels = editor.getLineHeightInPixels()
      expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels)
      expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels)
    })

    it('updates the top position of lines when the font size changes', async function () {
      let initialLineHeightInPixels = editor.getLineHeightInPixels()
      component.setFontSize(10)

      await nextViewUpdatePromise()

      let newLineHeightInPixels = editor.getLineHeightInPixels()
      expect(newLineHeightInPixels).not.toBe(initialLineHeightInPixels)
      expect(component.lineNodeForScreenRow(1).offsetTop).toBe(1 * newLineHeightInPixels)
    })

    it('renders the .lines div at the full height of the editor if there are not enough lines to scroll vertically', async function () {
      editor.setText('')
      wrapperNode.style.height = '300px'
      component.measureDimensions()
      await nextViewUpdatePromise()
      let linesNode = componentNode.querySelector('.lines')
      expect(linesNode.offsetHeight).toBe(300)
    })

    it('assigns the width of each line so it extends across the full width of the editor', async function () {
      let gutterWidth = componentNode.querySelector('.gutter').offsetWidth
      let scrollViewNode = componentNode.querySelector('.scroll-view')
      let lineNodes = Array.from(componentNode.querySelectorAll('.line'))

      componentNode.style.width = gutterWidth + (30 * charWidth) + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      expect(wrapperNode.getScrollWidth()).toBeGreaterThan(scrollViewNode.offsetWidth)
      let editorFullWidth = wrapperNode.getScrollWidth() + wrapperNode.getVerticalScrollbarWidth()
      for (let lineNode of lineNodes) {
        expect(lineNode.getBoundingClientRect().width).toBe(editorFullWidth)
      }

      componentNode.style.width = gutterWidth + wrapperNode.getScrollWidth() + 100 + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      let scrollViewWidth = scrollViewNode.offsetWidth
      for (let lineNode of lineNodes) {
        expect(lineNode.getBoundingClientRect().width).toBe(scrollViewWidth)
      }
    })

    it('renders an nbsp on empty lines when no line-ending character is defined', function () {
      atom.config.set('editor.showInvisibles', false)
      expect(component.lineNodeForScreenRow(10).textContent).toBe(NBSP)
    })

    it('gives the lines and tiles divs the same background color as the editor to improve GPU performance', async function () {
      let linesNode = componentNode.querySelector('.lines')
      let backgroundColor = getComputedStyle(wrapperNode).backgroundColor

      expect(linesNode.style.backgroundColor).toBe(backgroundColor)
      for (let tileNode of component.tileNodesForLines()) {
        expect(tileNode.style.backgroundColor).toBe(backgroundColor)
      }

      wrapperNode.style.backgroundColor = 'rgb(255, 0, 0)'
      await nextViewUpdatePromise()

      expect(linesNode.style.backgroundColor).toBe('rgb(255, 0, 0)')
      for (let tileNode of component.tileNodesForLines()) {
        expect(tileNode.style.backgroundColor).toBe('rgb(255, 0, 0)')
      }
    })

    it('applies .leading-whitespace for lines with leading spaces and/or tabs', async function () {
      editor.setText(' a')

      await nextViewUpdatePromise()

      let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false)

      editor.setText('\ta')
      await nextViewUpdatePromise()

      leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(false)
    })

    it('applies .trailing-whitespace for lines with trailing spaces and/or tabs', async function () {
      editor.setText(' ')
      await nextViewUpdatePromise()

      let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false)

      editor.setText('\t')
      await nextViewUpdatePromise()

      leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false)
      editor.setText('a ')
      await nextViewUpdatePromise()

      leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false)
      editor.setText('a\t')
      await nextViewUpdatePromise()

      leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
      expect(leafNodes[0].classList.contains('trailing-whitespace')).toBe(true)
      expect(leafNodes[0].classList.contains('leading-whitespace')).toBe(false)
    })

    it('keeps rebuilding lines when continuous reflow is on', function () {
      wrapperNode.setContinuousReflow(true)
      let oldLineNode = componentNode.querySelector('.line')

      waitsFor(function () {
        return componentNode.querySelector('.line') !== oldLineNode
      })
    })

    describe('when showInvisibles is enabled', function () {
      const invisibles = {
        eol: 'E',
        space: 'S',
        tab: 'T',
        cr: 'C'
      }

      beforeEach(async function () {
        atom.config.set('editor.showInvisibles', true)
        atom.config.set('editor.invisibles', invisibles)
        await nextViewUpdatePromise()
      })

      it('re-renders the lines when the showInvisibles config option changes', async function () {
        editor.setText(' a line with tabs\tand spaces \n')
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol)

        atom.config.set('editor.showInvisibles', false)
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe(' a line with tabs and spaces ')

        atom.config.set('editor.showInvisibles', true)
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol)
      })

      it('displays leading/trailing spaces, tabs, and newlines as visible characters', async function () {
        editor.setText(' a line with tabs\tand spaces \n')

        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + invisibles.space + 'a line with tabs' + invisibles.tab + 'and spaces' + invisibles.space + invisibles.eol)

        let leafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
        expect(leafNodes[0].classList.contains('invisible-character')).toBe(true)
        expect(leafNodes[leafNodes.length - 1].classList.contains('invisible-character')).toBe(true)
      })

      it('displays newlines as their own token outside of the other tokens\' scopeDescriptor', async function () {
        editor.setText('let\n')
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(0).innerHTML).toBe('<span class="source js"><span class="storage modifier js">let</span></span><span class="invisible-character">' + invisibles.eol + '</span>')
      })

      it('displays trailing carriage returns using a visible, non-empty value', async function () {
        editor.setText('a line that ends with a carriage return\r\n')
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(0).textContent).toBe('a line that ends with a carriage return' + invisibles.cr + invisibles.eol)
      })

      it('renders invisible line-ending characters on empty lines', function () {
        expect(component.lineNodeForScreenRow(10).textContent).toBe(invisibles.eol)
      })

      it('renders an nbsp on empty lines when the line-ending character is an empty string', async function () {
        atom.config.set('editor.invisibles', {
          eol: ''
        })
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(10).textContent).toBe(NBSP)
      })

      it('renders an nbsp on empty lines when the line-ending character is false', async function () {
        atom.config.set('editor.invisibles', {
          eol: false
        })
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(10).textContent).toBe(NBSP)
      })

      it('interleaves invisible line-ending characters with indent guides on empty lines', async function () {
        atom.config.set('editor.showIndentGuide', true)

        await nextViewUpdatePromise()

        editor.setTextInBufferRange([[10, 0], [11, 0]], '\r\n', {
          normalizeLineEndings: false
        })
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(10).innerHTML).toBe('<span class="indent-guide"><span class="invisible-character">C</span><span class="invisible-character">E</span></span>')
        editor.setTabLength(3)
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(10).innerHTML).toBe('<span class="indent-guide"><span class="invisible-character">C</span><span class="invisible-character">E</span> </span>')
        editor.setTabLength(1)
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(10).innerHTML).toBe('<span class="indent-guide"><span class="invisible-character">C</span></span><span class="indent-guide"><span class="invisible-character">E</span></span>')
        editor.setTextInBufferRange([[9, 0], [9, Infinity]], ' ')
        editor.setTextInBufferRange([[11, 0], [11, Infinity]], ' ')
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(10).innerHTML).toBe('<span class="indent-guide"><span class="invisible-character">C</span></span><span class="invisible-character">E</span>')
      })

      describe('when soft wrapping is enabled', function () {
        beforeEach(async function () {
          editor.setText('a line that wraps \n')
          editor.setSoftWrapped(true)
          await nextViewUpdatePromise()

          componentNode.style.width = 16 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px'
          component.measureDimensions()
          await nextViewUpdatePromise()
        })

        it('does not show end of line invisibles at the end of wrapped lines', function () {
          expect(component.lineNodeForScreenRow(0).textContent).toBe('a line that ')
          expect(component.lineNodeForScreenRow(1).textContent).toBe('wraps' + invisibles.space + invisibles.eol)
        })
      })
    })

    describe('when indent guides are enabled', function () {
      beforeEach(async function () {
        atom.config.set('editor.showIndentGuide', true)
        await nextViewUpdatePromise()
      })

      it('adds an "indent-guide" class to spans comprising the leading whitespace', function () {
        let line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))
        expect(line1LeafNodes[0].textContent).toBe('  ')
        expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe(false)

        let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
        expect(line2LeafNodes[0].textContent).toBe('  ')
        expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[1].textContent).toBe('  ')
        expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(false)
      })

      it('renders leading whitespace spans with the "indent-guide" class for empty lines', async function () {
        editor.getBuffer().insert([1, Infinity], '\n')
        await nextViewUpdatePromise()

        let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
        expect(line2LeafNodes.length).toBe(2)
        expect(line2LeafNodes[0].textContent).toBe('  ')
        expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[1].textContent).toBe('  ')
        expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true)
      })

      it('renders indent guides correctly on lines containing only whitespace', async function () {
        editor.getBuffer().insert([1, Infinity], '\n      ')
        await nextViewUpdatePromise()

        let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
        expect(line2LeafNodes.length).toBe(3)
        expect(line2LeafNodes[0].textContent).toBe('  ')
        expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[1].textContent).toBe('  ')
        expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[2].textContent).toBe('  ')
        expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true)
      })

      it('renders indent guides correctly on lines containing only whitespace when invisibles are enabled', async function () {
        atom.config.set('editor.showInvisibles', true)
        atom.config.set('editor.invisibles', {
          space: '-',
          eol: 'x'
        })
        editor.getBuffer().insert([1, Infinity], '\n      ')

        await nextViewUpdatePromise()

        let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
        expect(line2LeafNodes.length).toBe(4)
        expect(line2LeafNodes[0].textContent).toBe('--')
        expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[1].textContent).toBe('--')
        expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[2].textContent).toBe('--')
        expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(true)
        expect(line2LeafNodes[3].textContent).toBe('x')
      })

      it('does not render indent guides in trailing whitespace for lines containing non whitespace characters', async function () {
        editor.getBuffer().setText('  hi  ')

        await nextViewUpdatePromise()

        let line0LeafNodes = getLeafNodes(component.lineNodeForScreenRow(0))
        expect(line0LeafNodes[0].textContent).toBe('  ')
        expect(line0LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line0LeafNodes[1].textContent).toBe('  ')
        expect(line0LeafNodes[1].classList.contains('indent-guide')).toBe(false)
      })

      it('updates the indent guides on empty lines preceding an indentation change', async function () {
        editor.getBuffer().insert([12, 0], '\n')
        await nextViewUpdatePromise()

        editor.getBuffer().insert([13, 0], '    ')
        await nextViewUpdatePromise()

        let line12LeafNodes = getLeafNodes(component.lineNodeForScreenRow(12))
        expect(line12LeafNodes[0].textContent).toBe('  ')
        expect(line12LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line12LeafNodes[1].textContent).toBe('  ')
        expect(line12LeafNodes[1].classList.contains('indent-guide')).toBe(true)
      })

      it('updates the indent guides on empty lines following an indentation change', async function () {
        editor.getBuffer().insert([12, 2], '\n')

        await nextViewUpdatePromise()

        editor.getBuffer().insert([12, 0], '    ')
        await nextViewUpdatePromise()

        let line13LeafNodes = getLeafNodes(component.lineNodeForScreenRow(13))
        expect(line13LeafNodes[0].textContent).toBe('  ')
        expect(line13LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line13LeafNodes[1].textContent).toBe('  ')
        expect(line13LeafNodes[1].classList.contains('indent-guide')).toBe(true)
      })
    })

    describe('when indent guides are disabled', function () {
      beforeEach(function () {
        expect(atom.config.get('editor.showIndentGuide')).toBe(false)
      })

      it('does not render indent guides on lines containing only whitespace', async function () {
        editor.getBuffer().insert([1, Infinity], '\n      ')

        await nextViewUpdatePromise()

        let line2LeafNodes = getLeafNodes(component.lineNodeForScreenRow(2))
        expect(line2LeafNodes.length).toBe(3)
        expect(line2LeafNodes[0].textContent).toBe('  ')
        expect(line2LeafNodes[0].classList.contains('indent-guide')).toBe(false)
        expect(line2LeafNodes[1].textContent).toBe('  ')
        expect(line2LeafNodes[1].classList.contains('indent-guide')).toBe(false)
        expect(line2LeafNodes[2].textContent).toBe('  ')
        expect(line2LeafNodes[2].classList.contains('indent-guide')).toBe(false)
      })
    })

    describe('when the buffer contains null bytes', function () {
      it('excludes the null byte from character measurement', async function () {
        editor.setText('a\0b')
        await nextViewUpdatePromise()
        expect(wrapperNode.pixelPositionForScreenPosition([0, Infinity]).left).toEqual(2 * charWidth)
      })
    })

    describe('when there is a fold', function () {
      it('renders a fold marker on the folded line', async function () {
        let foldedLineNode = component.lineNodeForScreenRow(4)
        expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy()
        editor.foldBufferRow(4)

        await nextViewUpdatePromise()

        foldedLineNode = component.lineNodeForScreenRow(4)
        expect(foldedLineNode.querySelector('.fold-marker')).toBeTruthy()
        editor.unfoldBufferRow(4)

        await nextViewUpdatePromise()

        foldedLineNode = component.lineNodeForScreenRow(4)
        expect(foldedLineNode.querySelector('.fold-marker')).toBeFalsy()
      })
    })
  })

  describe('gutter rendering', function () {
    function expectTileContainsRow (tileNode, screenRow, {top, text}) {
      let lineNode = tileNode.querySelector('[data-screen-row="' + screenRow + '"]')
      expect(lineNode.offsetTop).toBe(top)
      expect(lineNode.textContent).toBe(text)
    }

    it('renders higher tiles in front of lower ones', async function () {
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      let tilesNodes = component.tileNodesForLineNumbers()
      expect(tilesNodes[0].style.zIndex).toBe('2')
      expect(tilesNodes[1].style.zIndex).toBe('1')
      expect(tilesNodes[2].style.zIndex).toBe('0')
      verticalScrollbarNode.scrollTop = 1 * lineHeightInPixels
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()

      tilesNodes = component.tileNodesForLineNumbers()
      expect(tilesNodes[0].style.zIndex).toBe('3')
      expect(tilesNodes[1].style.zIndex).toBe('2')
      expect(tilesNodes[2].style.zIndex).toBe('1')
      expect(tilesNodes[3].style.zIndex).toBe('0')
    })

    it('gives the line numbers container the same height as the wrapper node', async function () {
      let linesNode = componentNode.querySelector('.line-numbers')
      wrapperNode.style.height = 6.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      expect(linesNode.getBoundingClientRect().height).toBe(6.5 * lineHeightInPixels)
      wrapperNode.style.height = 3.5 * lineHeightInPixels + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      expect(linesNode.getBoundingClientRect().height).toBe(3.5 * lineHeightInPixels)
    })

    it('renders the currently-visible line numbers in a tiled fashion', async function () {
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      let tilesNodes = component.tileNodesForLineNumbers()
      expect(tilesNodes.length).toBe(3)

      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, 0px, 0px)')
      expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(3)
      expectTileContainsRow(tilesNodes[0], 0, {
        top: lineHeightInPixels * 0,
        text: '' + NBSP + '1'
      })
      expectTileContainsRow(tilesNodes[0], 1, {
        top: lineHeightInPixels * 1,
        text: '' + NBSP + '2'
      })
      expectTileContainsRow(tilesNodes[0], 2, {
        top: lineHeightInPixels * 2,
        text: '' + NBSP + '3'
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels) + 'px, 0px)')
      expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(3)
      expectTileContainsRow(tilesNodes[1], 3, {
        top: lineHeightInPixels * 0,
        text: '' + NBSP + '4'
      })
      expectTileContainsRow(tilesNodes[1], 4, {
        top: lineHeightInPixels * 1,
        text: '' + NBSP + '5'
      })
      expectTileContainsRow(tilesNodes[1], 5, {
        top: lineHeightInPixels * 2,
        text: '' + NBSP + '6'
      })

      expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels) + 'px, 0px)')
      expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(3)
      expectTileContainsRow(tilesNodes[2], 6, {
        top: lineHeightInPixels * 0,
        text: '' + NBSP + '7'
      })
      expectTileContainsRow(tilesNodes[2], 7, {
        top: lineHeightInPixels * 1,
        text: '' + NBSP + '8'
      })
      expectTileContainsRow(tilesNodes[2], 8, {
        top: lineHeightInPixels * 2,
        text: '' + NBSP + '9'
      })
      verticalScrollbarNode.scrollTop = TILE_SIZE * lineHeightInPixels + 5
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))

      await nextViewUpdatePromise()

      tilesNodes = component.tileNodesForLineNumbers()
      expect(component.lineNumberNodeForScreenRow(2)).toBeUndefined()
      expect(tilesNodes.length).toBe(3)

      expect(tilesNodes[0].style['-webkit-transform']).toBe('translate3d(0px, ' + (0 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[0].querySelectorAll('.line-number').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[0], 3, {
        top: lineHeightInPixels * 0,
        text: '' + NBSP + '4'
      })
      expectTileContainsRow(tilesNodes[0], 4, {
        top: lineHeightInPixels * 1,
        text: '' + NBSP + '5'
      })
      expectTileContainsRow(tilesNodes[0], 5, {
        top: lineHeightInPixels * 2,
        text: '' + NBSP + '6'
      })

      expect(tilesNodes[1].style['-webkit-transform']).toBe('translate3d(0px, ' + (1 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[1].querySelectorAll('.line-number').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[1], 6, {
        top: 0 * lineHeightInPixels,
        text: '' + NBSP + '7'
      })
      expectTileContainsRow(tilesNodes[1], 7, {
        top: 1 * lineHeightInPixels,
        text: '' + NBSP + '8'
      })
      expectTileContainsRow(tilesNodes[1], 8, {
        top: 2 * lineHeightInPixels,
        text: '' + NBSP + '9'
      })

      expect(tilesNodes[2].style['-webkit-transform']).toBe('translate3d(0px, ' + (2 * tileHeightInPixels - 5) + 'px, 0px)')
      expect(tilesNodes[2].querySelectorAll('.line-number').length).toBe(TILE_SIZE)
      expectTileContainsRow(tilesNodes[2], 9, {
        top: 0 * lineHeightInPixels,
        text: '10'
      })
      expectTileContainsRow(tilesNodes[2], 10, {
        top: 1 * lineHeightInPixels,
        text: '11'
      })
      expectTileContainsRow(tilesNodes[2], 11, {
        top: 2 * lineHeightInPixels,
        text: '12'
      })
    })

    it('updates the translation of subsequent line numbers when lines are inserted or removed', async function () {
      editor.getBuffer().insert([0, 0], '\n\n')
      await nextViewUpdatePromise()

      let lineNumberNodes = componentNode.querySelectorAll('.line-number')
      expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels)
      editor.getBuffer().insert([0, 0], '\n\n')

      await nextViewUpdatePromise()

      expect(component.lineNumberNodeForScreenRow(0).offsetTop).toBe(0 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(1).offsetTop).toBe(1 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(2).offsetTop).toBe(2 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(3).offsetTop).toBe(0 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(4).offsetTop).toBe(1 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(5).offsetTop).toBe(2 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(6).offsetTop).toBe(0 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(7).offsetTop).toBe(1 * lineHeightInPixels)
      expect(component.lineNumberNodeForScreenRow(8).offsetTop).toBe(2 * lineHeightInPixels)
    })

    it('renders • characters for soft-wrapped lines', async function () {
      editor.setSoftWrapped(true)
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 30 * charWidth + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()

      expect(componentNode.querySelectorAll('.line-number').length).toBe(9 + 1)
      expect(component.lineNumberNodeForScreenRow(0).textContent).toBe('' + NBSP + '1')
      expect(component.lineNumberNodeForScreenRow(1).textContent).toBe('' + NBSP + '•')
      expect(component.lineNumberNodeForScreenRow(2).textContent).toBe('' + NBSP + '2')
      expect(component.lineNumberNodeForScreenRow(3).textContent).toBe('' + NBSP + '•')
      expect(component.lineNumberNodeForScreenRow(4).textContent).toBe('' + NBSP + '3')
      expect(component.lineNumberNodeForScreenRow(5).textContent).toBe('' + NBSP + '•')
      expect(component.lineNumberNodeForScreenRow(6).textContent).toBe('' + NBSP + '4')
      expect(component.lineNumberNodeForScreenRow(7).textContent).toBe('' + NBSP + '•')
      expect(component.lineNumberNodeForScreenRow(8).textContent).toBe('' + NBSP + '•')
    })

    it('pads line numbers to be right-justified based on the maximum number of line number digits', async function () {
      editor.getBuffer().setText([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].join('\n'))
      await nextViewUpdatePromise()

      for (let screenRow = 0; screenRow <= 8; ++screenRow) {
        expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + (screenRow + 1))
      }
      expect(component.lineNumberNodeForScreenRow(9).textContent).toBe('10')
      let gutterNode = componentNode.querySelector('.gutter')
      let initialGutterWidth = gutterNode.offsetWidth
      editor.getBuffer().delete([[1, 0], [2, 0]])

      await nextViewUpdatePromise()

      for (let screenRow = 0; screenRow <= 8; ++screenRow) {
        expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + (screenRow + 1))
      }
      expect(gutterNode.offsetWidth).toBeLessThan(initialGutterWidth)
      editor.getBuffer().insert([0, 0], '\n\n')

      await nextViewUpdatePromise()

      for (let screenRow = 0; screenRow <= 8; ++screenRow) {
        expect(component.lineNumberNodeForScreenRow(screenRow).textContent).toBe('' + NBSP + (screenRow + 1))
      }
      expect(component.lineNumberNodeForScreenRow(9).textContent).toBe('10')
      expect(gutterNode.offsetWidth).toBe(initialGutterWidth)
    })

    it('renders the .line-numbers div at the full height of the editor even if it\'s taller than its content', async function () {
      wrapperNode.style.height = componentNode.offsetHeight + 100 + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()
      expect(componentNode.querySelector('.line-numbers').offsetHeight).toBe(componentNode.offsetHeight)
    })

    it('applies the background color of the gutter or the editor to the line numbers to improve GPU performance', async function () {
      let gutterNode = componentNode.querySelector('.gutter')
      let lineNumbersNode = gutterNode.querySelector('.line-numbers')
      let backgroundColor = getComputedStyle(wrapperNode).backgroundColor
      expect(lineNumbersNode.style.backgroundColor).toBe(backgroundColor)
      for (let tileNode of component.tileNodesForLineNumbers()) {
        expect(tileNode.style.backgroundColor).toBe(backgroundColor)
      }

      gutterNode.style.backgroundColor = 'rgb(255, 0, 0)'
      atom.views.performDocumentPoll()
      await nextViewUpdatePromise()

      expect(lineNumbersNode.style.backgroundColor).toBe('rgb(255, 0, 0)')
      for (let tileNode of component.tileNodesForLineNumbers()) {
        expect(tileNode.style.backgroundColor).toBe('rgb(255, 0, 0)')
      }
    })

    it('hides or shows the gutter based on the "::isLineNumberGutterVisible" property on the model and the global "editor.showLineNumbers" config setting', async function () {
      expect(component.gutterContainerComponent.getLineNumberGutterComponent() != null).toBe(true)
      editor.setLineNumberGutterVisible(false)
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.gutter').style.display).toBe('none')
      atom.config.set('editor.showLineNumbers', false)
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.gutter').style.display).toBe('none')
      editor.setLineNumberGutterVisible(true)
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.gutter').style.display).toBe('none')
      atom.config.set('editor.showLineNumbers', true)
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.gutter').style.display).toBe('')
      expect(component.lineNumberNodeForScreenRow(3) != null).toBe(true)
    })

    it('keeps rebuilding line numbers when continuous reflow is on', function () {
      wrapperNode.setContinuousReflow(true)
      let oldLineNode = componentNode.querySelectorAll('.line-number')[1]

      waitsFor(function () {
        return componentNode.querySelectorAll('.line-number')[1] !== oldLineNode
      })
    })

    describe('fold decorations', function () {
      describe('rendering fold decorations', function () {
        it('adds the foldable class to line numbers when the line is foldable', function () {
          expect(lineNumberHasClass(0, 'foldable')).toBe(true)
          expect(lineNumberHasClass(1, 'foldable')).toBe(true)
          expect(lineNumberHasClass(2, 'foldable')).toBe(false)
          expect(lineNumberHasClass(3, 'foldable')).toBe(false)
          expect(lineNumberHasClass(4, 'foldable')).toBe(true)
          expect(lineNumberHasClass(5, 'foldable')).toBe(false)
        })

        it('updates the foldable class on the correct line numbers when the foldable positions change', async function () {
          editor.getBuffer().insert([0, 0], '\n')
          await nextViewUpdatePromise()

          expect(lineNumberHasClass(0, 'foldable')).toBe(false)
          expect(lineNumberHasClass(1, 'foldable')).toBe(true)
          expect(lineNumberHasClass(2, 'foldable')).toBe(true)
          expect(lineNumberHasClass(3, 'foldable')).toBe(false)
          expect(lineNumberHasClass(4, 'foldable')).toBe(false)
          expect(lineNumberHasClass(5, 'foldable')).toBe(true)
          expect(lineNumberHasClass(6, 'foldable')).toBe(false)
        })

        it('updates the foldable class on a line number that becomes foldable', async function () {
          expect(lineNumberHasClass(11, 'foldable')).toBe(false)
          editor.getBuffer().insert([11, 44], '\n    fold me')
          await nextViewUpdatePromise()
          expect(lineNumberHasClass(11, 'foldable')).toBe(true)
          editor.undo()
          await nextViewUpdatePromise()
          expect(lineNumberHasClass(11, 'foldable')).toBe(false)
        })

        it('adds, updates and removes the folded class on the correct line number componentNodes', async function () {
          editor.foldBufferRow(4)
          await nextViewUpdatePromise()

          expect(lineNumberHasClass(4, 'folded')).toBe(true)

          editor.getBuffer().insert([0, 0], '\n')
          await nextViewUpdatePromise()

          expect(lineNumberHasClass(4, 'folded')).toBe(false)
          expect(lineNumberHasClass(5, 'folded')).toBe(true)

          editor.unfoldBufferRow(5)
          await nextViewUpdatePromise()

          expect(lineNumberHasClass(5, 'folded')).toBe(false)
        })

        describe('when soft wrapping is enabled', function () {
          beforeEach(async function () {
            editor.setSoftWrapped(true)
            await nextViewUpdatePromise()
            componentNode.style.width = 16 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px'
            component.measureDimensions()
            await nextViewUpdatePromise()
          })

          it('does not add the foldable class for soft-wrapped lines', function () {
            expect(lineNumberHasClass(0, 'foldable')).toBe(true)
            expect(lineNumberHasClass(1, 'foldable')).toBe(false)
          })
        })
      })

      describe('mouse interactions with fold indicators', function () {
        let gutterNode

        function buildClickEvent (target) {
          return buildMouseEvent('click', {
            target: target
          })
        }

        beforeEach(function () {
          gutterNode = componentNode.querySelector('.gutter')
        })

        describe('when the component is destroyed', function () {
          it('stops listening for folding events', function () {
            let lineNumber, target
            component.destroy()
            lineNumber = component.lineNumberNodeForScreenRow(1)
            target = lineNumber.querySelector('.icon-right')
            return target.dispatchEvent(buildClickEvent(target))
          })
        })

        it('folds and unfolds the block represented by the fold indicator when clicked', async function () {
          expect(lineNumberHasClass(1, 'folded')).toBe(false)

          let lineNumber = component.lineNumberNodeForScreenRow(1)
          let target = lineNumber.querySelector('.icon-right')

          target.dispatchEvent(buildClickEvent(target))

          await nextViewUpdatePromise()

          expect(lineNumberHasClass(1, 'folded')).toBe(true)
          lineNumber = component.lineNumberNodeForScreenRow(1)
          target = lineNumber.querySelector('.icon-right')
          target.dispatchEvent(buildClickEvent(target))

          await nextViewUpdatePromise()

          expect(lineNumberHasClass(1, 'folded')).toBe(false)
        })

        it('does not fold when the line number componentNode is clicked', function () {
          let lineNumber = component.lineNumberNodeForScreenRow(1)
          lineNumber.dispatchEvent(buildClickEvent(lineNumber))
          waits(100)
          runs(function () {
            expect(lineNumberHasClass(1, 'folded')).toBe(false)
          })
        })
      })
    })
  })

  describe('cursor rendering', function () {
    it('renders the currently visible cursors', async function () {
      let cursor1 = editor.getLastCursor()
      cursor1.setScreenPosition([0, 5], {
        autoscroll: false
      })
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 20 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      let cursorNodes = componentNode.querySelectorAll('.cursor')
      expect(cursorNodes.length).toBe(1)
      expect(cursorNodes[0].offsetHeight).toBe(lineHeightInPixels)
      expect(cursorNodes[0].offsetWidth).toBeCloseTo(charWidth, 0)
      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)')
      let cursor2 = editor.addCursorAtScreenPosition([8, 11], {
        autoscroll: false
      })
      let cursor3 = editor.addCursorAtScreenPosition([4, 10], {
        autoscroll: false
      })
      await nextViewUpdatePromise()

      cursorNodes = componentNode.querySelectorAll('.cursor')
      expect(cursorNodes.length).toBe(2)
      expect(cursorNodes[0].offsetTop).toBe(0)
      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(5 * charWidth)) + 'px, ' + (0 * lineHeightInPixels) + 'px)')
      expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth)) + 'px, ' + (4 * lineHeightInPixels) + 'px)')
      verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels
      horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()

      horizontalScrollbarNode.scrollLeft = 3.5 * charWidth
      horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()

      cursorNodes = componentNode.querySelectorAll('.cursor')
      expect(cursorNodes.length).toBe(2)
      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(10 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)')
      expect(cursorNodes[1].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)')
      editor.onDidChangeCursorPosition(cursorMovedListener = jasmine.createSpy('cursorMovedListener'))
      cursor3.setScreenPosition([4, 11], {
        autoscroll: false
      })
      await nextViewUpdatePromise()

      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (4 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)')
      expect(cursorMovedListener).toHaveBeenCalled()
      cursor3.destroy()
      await nextViewUpdatePromise()

      cursorNodes = componentNode.querySelectorAll('.cursor')
      expect(cursorNodes.length).toBe(1)
      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(11 * charWidth - horizontalScrollbarNode.scrollLeft)) + 'px, ' + (8 * lineHeightInPixels - verticalScrollbarNode.scrollTop) + 'px)')
    })

    it('accounts for character widths when positioning cursors', async function () {
      atom.config.set('editor.fontFamily', 'sans-serif')
      editor.setCursorScreenPosition([0, 16])
      await nextViewUpdatePromise()

      let cursor = componentNode.querySelector('.cursor')
      let cursorRect = cursor.getBoundingClientRect()
      let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.storage.type.function.js').firstChild
      let range = document.createRange()
      range.setStart(cursorLocationTextNode, 0)
      range.setEnd(cursorLocationTextNode, 1)
      let rangeRect = range.getBoundingClientRect()
      expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0)
      expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0)
    })

    it('accounts for the width of paired characters when positioning cursors', async function () {
      atom.config.set('editor.fontFamily', 'sans-serif')
      editor.setText('he\u0301y')
      editor.setCursorBufferPosition([0, 3])
      await nextViewUpdatePromise()

      let cursor = componentNode.querySelector('.cursor')
      let cursorRect = cursor.getBoundingClientRect()
      let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.source.js').childNodes[2]
      let range = document.createRange()
      range.setStart(cursorLocationTextNode, 0)
      range.setEnd(cursorLocationTextNode, 1)
      let rangeRect = range.getBoundingClientRect()
      expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0)
      expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0)
    })

    it('positions cursors correctly after character widths are changed via a stylesheet change', async function () {
      atom.config.set('editor.fontFamily', 'sans-serif')
      editor.setCursorScreenPosition([0, 16])
      await nextViewUpdatePromise()

      atom.styles.addStyleSheet('.function.js {\n  font-weight: bold;\n}', {
        context: 'atom-text-editor'
      })
      await nextViewUpdatePromise()

      let cursor = componentNode.querySelector('.cursor')
      let cursorRect = cursor.getBoundingClientRect()
      let cursorLocationTextNode = component.lineNodeForScreenRow(0).querySelector('.storage.type.function.js').firstChild
      let range = document.createRange()
      range.setStart(cursorLocationTextNode, 0)
      range.setEnd(cursorLocationTextNode, 1)
      let rangeRect = range.getBoundingClientRect()
      expect(cursorRect.left).toBeCloseTo(rangeRect.left, 0)
      expect(cursorRect.width).toBeCloseTo(rangeRect.width, 0)
      atom.themes.removeStylesheet('test')
    })

    it('sets the cursor to the default character width at the end of a line', async function () {
      editor.setCursorScreenPosition([0, Infinity])
      await nextViewUpdatePromise()
      let cursorNode = componentNode.querySelector('.cursor')
      expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0)
    })

    it('gives the cursor a non-zero width even if it\'s inside atomic tokens', async function () {
      editor.setCursorScreenPosition([1, 0])
      await nextViewUpdatePromise()
      let cursorNode = componentNode.querySelector('.cursor')
      expect(cursorNode.offsetWidth).toBeCloseTo(charWidth, 0)
    })

    it('blinks cursors when they are not moving', async function () {
      let cursorsNode = componentNode.querySelector('.cursors')
      wrapperNode.focus()
      await nextViewUpdatePromise()
      expect(cursorsNode.classList.contains('blink-off')).toBe(false)
      await conditionPromise(function () {
        return cursorsNode.classList.contains('blink-off')
      })
      await conditionPromise(function () {
        return !cursorsNode.classList.contains('blink-off')
      })
      editor.moveRight()
      await nextViewUpdatePromise()
      expect(cursorsNode.classList.contains('blink-off')).toBe(false)
      await conditionPromise(function () {
        return cursorsNode.classList.contains('blink-off')
      })
    })

    it('does not render cursors that are associated with non-empty selections', async function () {
      editor.setSelectedScreenRange([[0, 4], [4, 6]])
      editor.addCursorAtScreenPosition([6, 8])
      await nextViewUpdatePromise()
      let cursorNodes = componentNode.querySelectorAll('.cursor')
      expect(cursorNodes.length).toBe(1)
      expect(cursorNodes[0].style['-webkit-transform']).toBe('translate(' + (Math.round(8 * charWidth)) + 'px, ' + (6 * lineHeightInPixels) + 'px)')
    })

    it('updates cursor positions when the line height changes', async function () {
      editor.setCursorBufferPosition([1, 10])
      component.setLineHeight(2)
      await nextViewUpdatePromise()
      let cursorNode = componentNode.querySelector('.cursor')
      expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)')
    })

    it('updates cursor positions when the font size changes', async function () {
      editor.setCursorBufferPosition([1, 10])
      component.setFontSize(10)
      await nextViewUpdatePromise()
      let cursorNode = componentNode.querySelector('.cursor')
      expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(10 * editor.getDefaultCharWidth())) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)')
    })

    it('updates cursor positions when the font family changes', async function () {
      editor.setCursorBufferPosition([1, 10])
      component.setFontFamily('sans-serif')
      await nextViewUpdatePromise()
      let cursorNode = componentNode.querySelector('.cursor')
      let left = wrapperNode.pixelPositionForScreenPosition([1, 10]).left
      expect(cursorNode.style['-webkit-transform']).toBe('translate(' + (Math.round(left)) + 'px, ' + (editor.getLineHeightInPixels()) + 'px)')
    })
  })

  describe('selection rendering', function () {
    let scrollViewClientLeft, scrollViewNode

    beforeEach(function () {
      scrollViewNode = componentNode.querySelector('.scroll-view')
      scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left
    })

    it('renders 1 region for 1-line selections', async function () {
      editor.setSelectedScreenRange([[1, 6], [1, 10]])
      await nextViewUpdatePromise()

      let regions = componentNode.querySelectorAll('.selection .region')
      expect(regions.length).toBe(1)

      let regionRect = regions[0].getBoundingClientRect()
      expect(regionRect.top).toBe(1 * lineHeightInPixels)
      expect(regionRect.height).toBe(1 * lineHeightInPixels)
      expect(regionRect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0)
      expect(regionRect.width).toBeCloseTo(4 * charWidth, 0)
    })

    it('renders 2 regions for 2-line selections', async function () {
      editor.setSelectedScreenRange([[1, 6], [2, 10]])
      await nextViewUpdatePromise()

      let tileNode = component.tileNodesForLines()[0]
      let regions = tileNode.querySelectorAll('.selection .region')
      expect(regions.length).toBe(2)

      let region1Rect = regions[0].getBoundingClientRect()
      expect(region1Rect.top).toBe(1 * lineHeightInPixels)
      expect(region1Rect.height).toBe(1 * lineHeightInPixels)
      expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0)
      expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      let region2Rect = regions[1].getBoundingClientRect()
      expect(region2Rect.top).toBe(2 * lineHeightInPixels)
      expect(region2Rect.height).toBe(1 * lineHeightInPixels)
      expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region2Rect.width).toBeCloseTo(10 * charWidth, 0)
    })

    it('renders 3 regions per tile for selections with more than 2 lines', async function () {
      editor.setSelectedScreenRange([[0, 6], [5, 10]])
      await nextViewUpdatePromise()

      let region1Rect, region2Rect, region3Rect, regions, tileNode
      tileNode = component.tileNodesForLines()[0]
      regions = tileNode.querySelectorAll('.selection .region')
      expect(regions.length).toBe(3)

      region1Rect = regions[0].getBoundingClientRect()
      expect(region1Rect.top).toBe(0)
      expect(region1Rect.height).toBe(1 * lineHeightInPixels)
      expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 6 * charWidth, 0)
      expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      region2Rect = regions[1].getBoundingClientRect()
      expect(region2Rect.top).toBe(1 * lineHeightInPixels)
      expect(region2Rect.height).toBe(1 * lineHeightInPixels)
      expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      region3Rect = regions[2].getBoundingClientRect()
      expect(region3Rect.top).toBe(2 * lineHeightInPixels)
      expect(region3Rect.height).toBe(1 * lineHeightInPixels)
      expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region3Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      tileNode = component.tileNodesForLines()[1]
      regions = tileNode.querySelectorAll('.selection .region')
      expect(regions.length).toBe(3)

      region1Rect = regions[0].getBoundingClientRect()
      expect(region1Rect.top).toBe(3 * lineHeightInPixels)
      expect(region1Rect.height).toBe(1 * lineHeightInPixels)
      expect(region1Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region1Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      region2Rect = regions[1].getBoundingClientRect()
      expect(region2Rect.top).toBe(4 * lineHeightInPixels)
      expect(region2Rect.height).toBe(1 * lineHeightInPixels)
      expect(region2Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region2Rect.right).toBeCloseTo(tileNode.getBoundingClientRect().right, 0)

      region3Rect = regions[2].getBoundingClientRect()
      expect(region3Rect.top).toBe(5 * lineHeightInPixels)
      expect(region3Rect.height).toBe(1 * lineHeightInPixels)
      expect(region3Rect.left).toBeCloseTo(scrollViewClientLeft + 0, 0)
      expect(region3Rect.width).toBeCloseTo(10 * charWidth, 0)
    })

    it('does not render empty selections', async function () {
      editor.addSelectionForBufferRange([[2, 2], [2, 2]])
      await nextViewUpdatePromise()
      expect(editor.getSelections()[0].isEmpty()).toBe(true)
      expect(editor.getSelections()[1].isEmpty()).toBe(true)
      expect(componentNode.querySelectorAll('.selection').length).toBe(0)
    })

    it('updates selections when the line height changes', async function () {
      editor.setSelectedBufferRange([[1, 6], [1, 10]])
      component.setLineHeight(2)
      await nextViewUpdatePromise()
      let selectionNode = componentNode.querySelector('.region')
      expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels())
    })

    it('updates selections when the font size changes', async function () {
      editor.setSelectedBufferRange([[1, 6], [1, 10]])
      component.setFontSize(10)

      await nextViewUpdatePromise()

      let selectionNode = componentNode.querySelector('.region')
      expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels())
      expect(selectionNode.offsetLeft).toBeCloseTo(6 * editor.getDefaultCharWidth(), 0)
    })

    it('updates selections when the font family changes', async function () {
      editor.setSelectedBufferRange([[1, 6], [1, 10]])
      component.setFontFamily('sans-serif')

      await nextViewUpdatePromise()

      let selectionNode = componentNode.querySelector('.region')
      expect(selectionNode.offsetTop).toBe(editor.getLineHeightInPixels())
      expect(selectionNode.offsetLeft).toBeCloseTo(wrapperNode.pixelPositionForScreenPosition([1, 6]).left, 0)
    })

    it('will flash the selection when flash:true is passed to editor::setSelectedBufferRange', async function () {
      editor.setSelectedBufferRange([[1, 6], [1, 10]], {
        flash: true
      })
      await nextViewUpdatePromise()

      let selectionNode = componentNode.querySelector('.selection')
      expect(selectionNode.classList.contains('flash')).toBe(true)

      await conditionPromise(function () {
        return !selectionNode.classList.contains('flash')
      })

      editor.setSelectedBufferRange([[1, 5], [1, 7]], {
        flash: true
      })
      await nextViewUpdatePromise()

      expect(selectionNode.classList.contains('flash')).toBe(true)
    })
  })

  describe('line decoration rendering', function () {
    let decoration, marker

    beforeEach(async function () {
      marker = editor.addMarkerLayer({
        maintainHistory: true
      }).markBufferRange([[2, 13], [3, 15]], {
        invalidate: 'inside'
      })
      decoration = editor.decorateMarker(marker, {
        type: ['line-number', 'line'],
        'class': 'a'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
    })

    it('applies line decoration classes to lines and line numbers', async function () {
      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true)
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      let marker2 = editor.displayBuffer.markBufferRange([[9, 0], [9, 0]])
      editor.decorateMarker(marker2, {
        type: ['line-number', 'line'],
        'class': 'b'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      verticalScrollbarNode.scrollTop = 4.5 * lineHeightInPixels
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()

      expect(lineAndLineNumberHaveClass(9, 'b')).toBe(true)

      editor.foldBufferRow(5)
      await nextViewUpdatePromise()

      expect(lineAndLineNumberHaveClass(9, 'b')).toBe(false)
      expect(lineAndLineNumberHaveClass(6, 'b')).toBe(true)
    })

    it('only applies decorations to screen rows that are spanned by their marker when lines are soft-wrapped', async function () {
      editor.setText('a line that wraps, ok')
      editor.setSoftWrapped(true)
      componentNode.style.width = 16 * charWidth + 'px'
      component.measureDimensions()

      await nextViewUpdatePromise()
      marker.destroy()
      marker = editor.markBufferRange([[0, 0], [0, 2]])
      editor.decorateMarker(marker, {
        type: ['line-number', 'line'],
        'class': 'b'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(0, 'b')).toBe(true)
      expect(lineNumberHasClass(1, 'b')).toBe(false)
      marker.setBufferRange([[0, 0], [0, Infinity]])
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(0, 'b')).toBe(true)
      expect(lineNumberHasClass(1, 'b')).toBe(true)
    })

    it('updates decorations when markers move', async function () {
      expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false)

      editor.getBuffer().insert([0, 0], '\n')
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(5, 'a')).toBe(false)

      marker.setBufferRange([[4, 4], [6, 4]])
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(4, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(5, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(6, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(7, 'a')).toBe(false)
    })

    it('remove decoration classes when decorations are removed', async function () {
      decoration.destroy()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      expect(lineNumberHasClass(1, 'a')).toBe(false)
      expect(lineNumberHasClass(2, 'a')).toBe(false)
      expect(lineNumberHasClass(3, 'a')).toBe(false)
      expect(lineNumberHasClass(4, 'a')).toBe(false)
    })

    it('removes decorations when their marker is invalidated', async function () {
      editor.getBuffer().insert([3, 2], 'n')
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(marker.isValid()).toBe(false)
      expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false)
      editor.undo()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(marker.isValid()).toBe(true)
      expect(lineAndLineNumberHaveClass(1, 'a')).toBe(false)
      expect(lineAndLineNumberHaveClass(2, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(3, 'a')).toBe(true)
      expect(lineAndLineNumberHaveClass(4, 'a')).toBe(false)
    })

    it('removes decorations when their marker is destroyed', async function () {
      marker.destroy()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      expect(lineNumberHasClass(1, 'a')).toBe(false)
      expect(lineNumberHasClass(2, 'a')).toBe(false)
      expect(lineNumberHasClass(3, 'a')).toBe(false)
      expect(lineNumberHasClass(4, 'a')).toBe(false)
    })

    describe('when the decoration\'s "onlyHead" property is true', function () {
      it('only applies the decoration\'s class to lines containing the marker\'s head', async function () {
        editor.decorateMarker(marker, {
          type: ['line-number', 'line'],
          'class': 'only-head',
          onlyHead: true
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()
        expect(lineAndLineNumberHaveClass(1, 'only-head')).toBe(false)
        expect(lineAndLineNumberHaveClass(2, 'only-head')).toBe(false)
        expect(lineAndLineNumberHaveClass(3, 'only-head')).toBe(true)
        expect(lineAndLineNumberHaveClass(4, 'only-head')).toBe(false)
      })
    })

    describe('when the decoration\'s "onlyEmpty" property is true', function () {
      it('only applies the decoration when its marker is empty', async function () {
        editor.decorateMarker(marker, {
          type: ['line-number', 'line'],
          'class': 'only-empty',
          onlyEmpty: true
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false)
        expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(false)

        marker.clearTail()
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(lineAndLineNumberHaveClass(2, 'only-empty')).toBe(false)
        expect(lineAndLineNumberHaveClass(3, 'only-empty')).toBe(true)
      })
    })

    describe('when the decoration\'s "onlyNonEmpty" property is true', function () {
      it('only applies the decoration when its marker is non-empty', async function () {
        editor.decorateMarker(marker, {
          type: ['line-number', 'line'],
          'class': 'only-non-empty',
          onlyNonEmpty: true
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(true)
        expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(true)

        marker.clearTail()
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(lineAndLineNumberHaveClass(2, 'only-non-empty')).toBe(false)
        expect(lineAndLineNumberHaveClass(3, 'only-non-empty')).toBe(false)
      })
    })
  })

  describe('highlight decoration rendering', function () {
    let decoration, marker, scrollViewClientLeft

    beforeEach(async function () {
      scrollViewClientLeft = componentNode.querySelector('.scroll-view').getBoundingClientRect().left
      marker = editor.addMarkerLayer({
        maintainHistory: true
      }).markBufferRange([[2, 13], [3, 15]], {
        invalidate: 'inside'
      })
      decoration = editor.decorateMarker(marker, {
        type: 'highlight',
        'class': 'test-highlight'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
    })

    it('does not render highlights for off-screen lines until they come on-screen', async function () {
      wrapperNode.style.height = 2.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      marker = editor.displayBuffer.markBufferRange([[9, 2], [9, 4]], {
        invalidate: 'inside'
      })
      editor.decorateMarker(marker, {
        type: 'highlight',
        'class': 'some-highlight'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(component.presenter.endRow).toBeLessThan(9)
      let regions = componentNode.querySelectorAll('.some-highlight .region')
      expect(regions.length).toBe(0)
      verticalScrollbarNode.scrollTop = 6 * lineHeightInPixels
      verticalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()

      expect(component.presenter.endRow).toBeGreaterThan(8)
      regions = componentNode.querySelectorAll('.some-highlight .region')
      expect(regions.length).toBe(1)
      let regionRect = regions[0].style
      expect(regionRect.top).toBe(0 + 'px')
      expect(regionRect.height).toBe(1 * lineHeightInPixels + 'px')
      expect(regionRect.left).toBe(Math.round(2 * charWidth) + 'px')
      expect(regionRect.width).toBe(Math.round(2 * charWidth) + 'px')
    })

    it('renders highlights decoration\'s marker is added', async function () {
      let regions = componentNode.querySelectorAll('.test-highlight .region')
      expect(regions.length).toBe(2)
    })

    it('removes highlights when a decoration is removed', async function () {
      decoration.destroy()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      let regions = componentNode.querySelectorAll('.test-highlight .region')
      expect(regions.length).toBe(0)
    })

    it('does not render a highlight that is within a fold', async function () {
      editor.foldBufferRow(1)
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      expect(componentNode.querySelectorAll('.test-highlight').length).toBe(0)
    })

    it('removes highlights when a decoration\'s marker is destroyed', async function () {
      marker.destroy()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      let regions = componentNode.querySelectorAll('.test-highlight .region')
      expect(regions.length).toBe(0)
    })

    it('only renders highlights when a decoration\'s marker is valid', async function () {
      editor.getBuffer().insert([3, 2], 'n')
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(marker.isValid()).toBe(false)
      let regions = componentNode.querySelectorAll('.test-highlight .region')
      expect(regions.length).toBe(0)
      editor.getBuffer().undo()
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(marker.isValid()).toBe(true)
      regions = componentNode.querySelectorAll('.test-highlight .region')
      expect(regions.length).toBe(2)
    })

    it('allows multiple space-delimited decoration classes', async function () {
      decoration.setProperties({
        type: 'highlight',
        'class': 'foo bar'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      expect(componentNode.querySelectorAll('.foo.bar').length).toBe(2)
      decoration.setProperties({
        type: 'highlight',
        'class': 'bar baz'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      expect(componentNode.querySelectorAll('.bar.baz').length).toBe(2)
    })

    it('renders classes on the regions directly if "deprecatedRegionClass" option is defined', async function () {
      decoration = editor.decorateMarker(marker, {
        type: 'highlight',
        'class': 'test-highlight',
        deprecatedRegionClass: 'test-highlight-region'
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()
      let regions = componentNode.querySelectorAll('.test-highlight .region.test-highlight-region')
      expect(regions.length).toBe(2)
    })

    describe('when flashing a decoration via Decoration::flash()', function () {
      let highlightNode

      beforeEach(async function () {
        highlightNode = componentNode.querySelectorAll('.test-highlight')[1]
      })

      it('adds and removes the flash class specified in ::flash', async function () {
        expect(highlightNode.classList.contains('flash-class')).toBe(false)
        decoration.flash('flash-class', 10)
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(highlightNode.classList.contains('flash-class')).toBe(true)
        await conditionPromise(function () {
          return !highlightNode.classList.contains('flash-class')
        })
      })

      describe('when ::flash is called again before the first has finished', function () {
        it('removes the class from the decoration highlight before adding it for the second ::flash call', async function () {
          decoration.flash('flash-class', 500)
          await decorationsUpdatedPromise(editor)
          await nextViewUpdatePromise()
          expect(highlightNode.classList.contains('flash-class')).toBe(true)

          decoration.flash('flash-class', 500)
          await decorationsUpdatedPromise(editor)
          await nextViewUpdatePromise()

          expect(highlightNode.classList.contains('flash-class')).toBe(false)

          await conditionPromise(function () {
            return highlightNode.classList.contains('flash-class')
          })
        })
      })
    })

    describe('when a decoration\'s marker moves', function () {
      it('moves rendered highlights when the buffer is changed', async function () {
        let regionStyle = componentNode.querySelector('.test-highlight .region').style
        let originalTop = parseInt(regionStyle.top)
        expect(originalTop).toBe(2 * lineHeightInPixels)

        editor.getBuffer().insert([0, 0], '\n')
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        regionStyle = componentNode.querySelector('.test-highlight .region').style
        let newTop = parseInt(regionStyle.top)
        expect(newTop).toBe(0)
      })

      it('moves rendered highlights when the marker is manually moved', async function () {
        let regionStyle = componentNode.querySelector('.test-highlight .region').style
        expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels)

        marker.setBufferRange([[5, 8], [5, 13]])
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        regionStyle = componentNode.querySelector('.test-highlight .region').style
        expect(parseInt(regionStyle.top)).toBe(2 * lineHeightInPixels)
      })
    })

    describe('when a decoration is updated via Decoration::update', function () {
      it('renders the decoration\'s new params', async function () {
        expect(componentNode.querySelector('.test-highlight')).toBeTruthy()
        decoration.setProperties({
          type: 'highlight',
          'class': 'new-test-highlight'
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()
        expect(componentNode.querySelector('.test-highlight')).toBeFalsy()
        expect(componentNode.querySelector('.new-test-highlight')).toBeTruthy()
      })
    })
  })

  describe('overlay decoration rendering', function () {
    let gutterWidth, item

    beforeEach(function () {
      item = document.createElement('div')
      item.classList.add('overlay-test')
      item.style.background = 'red'
      gutterWidth = componentNode.querySelector('.gutter').offsetWidth
    })

    describe('when the marker is empty', function () {
      it('renders an overlay decoration when added and removes the overlay when the decoration is destroyed', async function () {
        let marker = editor.displayBuffer.markBufferRange([[2, 13], [2, 13]], {
          invalidate: 'never'
        })
        let decoration = editor.decorateMarker(marker, {
          type: 'overlay',
          item: item
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        let overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test')
        expect(overlay).toBe(item)

        decoration.destroy()
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        overlay = component.getTopmostDOMNode().querySelector('atom-overlay .overlay-test')
        expect(overlay).toBe(null)
      })

      it('renders the overlay element with the CSS class specified by the decoration', async function () {
        let marker = editor.displayBuffer.markBufferRange([[2, 13], [2, 13]], {
          invalidate: 'never'
        })
        let decoration = editor.decorateMarker(marker, {
          type: 'overlay',
          'class': 'my-overlay',
          item: item
        })

        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        let overlay = component.getTopmostDOMNode().querySelector('atom-overlay.my-overlay')
        expect(overlay).not.toBe(null)
        let child = overlay.querySelector('.overlay-test')
        expect(child).toBe(item)
      })
    })

    describe('when the marker is not empty', function () {
      it('renders at the head of the marker by default', async function () {
        let marker = editor.displayBuffer.markBufferRange([[2, 5], [2, 10]], {
          invalidate: 'never'
        })
        let decoration = editor.decorateMarker(marker, {
          type: 'overlay',
          item: item
        })

        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        let position = wrapperNode.pixelPositionForBufferPosition([2, 10])
        let overlay = component.getTopmostDOMNode().querySelector('atom-overlay')
        expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px')
        expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px')
      })
    })

    describe('positioning the overlay when near the edge of the editor', function () {
      let itemHeight, itemWidth, windowHeight, windowWidth

      beforeEach(async function () {
        atom.storeWindowDimensions()
        itemWidth = Math.round(4 * editor.getDefaultCharWidth())
        itemHeight = 4 * editor.getLineHeightInPixels()
        windowWidth = Math.round(gutterWidth + 30 * editor.getDefaultCharWidth())
        windowHeight = 10 * editor.getLineHeightInPixels()
        item.style.width = itemWidth + 'px'
        item.style.height = itemHeight + 'px'
        wrapperNode.style.width = windowWidth + 'px'
        wrapperNode.style.height = windowHeight + 'px'
        atom.setWindowDimensions({
          width: windowWidth,
          height: windowHeight
        })
        component.measureDimensions()
        component.measureWindowSize()
        await nextViewUpdatePromise()
      })

      afterEach(function () {
        atom.restoreWindowDimensions()
      })

      it('slides horizontally left when near the right edge on #win32 and #darwin', async function () {
        let marker = editor.displayBuffer.markBufferRange([[0, 26], [0, 26]], {
          invalidate: 'never'
        })
        let decoration = editor.decorateMarker(marker, {
          type: 'overlay',
          item: item
        })
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        let position = wrapperNode.pixelPositionForBufferPosition([0, 26])
        let overlay = component.getTopmostDOMNode().querySelector('atom-overlay')
        expect(overlay.style.left).toBe(Math.round(position.left + gutterWidth) + 'px')
        expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px')

        editor.insertText('a')
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(overlay.style.left).toBe(windowWidth - itemWidth + 'px')
        expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px')

        editor.insertText('b')
        await decorationsUpdatedPromise(editor)
        await nextViewUpdatePromise()

        expect(overlay.style.left).toBe(windowWidth - itemWidth + 'px')
        expect(overlay.style.top).toBe(position.top + editor.getLineHeightInPixels() + 'px')
      })
    })
  })

  describe('hidden input field', function () {
    it('renders the hidden input field at the position of the last cursor if the cursor is on screen and the editor is focused', async function () {
      editor.setVerticalScrollMargin(0)
      editor.setHorizontalScrollMargin(0)
      let inputNode = componentNode.querySelector('.hidden-input')
      wrapperNode.style.height = 5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(editor.getCursorScreenPosition()).toEqual([0, 0])

      wrapperNode.setScrollTop(3 * lineHeightInPixels)
      wrapperNode.setScrollLeft(3 * charWidth)
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe(0)
      expect(inputNode.offsetLeft).toBe(0)

      editor.setCursorBufferPosition([5, 4], {
        autoscroll: false
      })
      await decorationsUpdatedPromise(editor)
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe(0)
      expect(inputNode.offsetLeft).toBe(0)

      wrapperNode.focus()
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe((5 * lineHeightInPixels) - wrapperNode.getScrollTop())
      expect(inputNode.offsetLeft).toBeCloseTo((4 * charWidth) - wrapperNode.getScrollLeft(), 0)

      inputNode.blur()
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe(0)
      expect(inputNode.offsetLeft).toBe(0)

      editor.setCursorBufferPosition([1, 2], {
        autoscroll: false
      })
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe(0)
      expect(inputNode.offsetLeft).toBe(0)

      inputNode.focus()
      await nextViewUpdatePromise()

      expect(inputNode.offsetTop).toBe(0)
      expect(inputNode.offsetLeft).toBe(0)
    })
  })

  describe('mouse interactions on the lines', function () {
    let linesNode

    beforeEach(function () {
      linesNode = componentNode.querySelector('.lines')
    })

    describe('when the mouse is single-clicked above the first line', function () {
      it('moves the cursor to the start of file buffer position', async function () {
        let height
        editor.setText('foo')
        editor.setCursorBufferPosition([0, 3])
        height = 4.5 * lineHeightInPixels
        wrapperNode.style.height = height + 'px'
        wrapperNode.style.width = 10 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        let coordinates = clientCoordinatesForScreenPosition([0, 2])
        coordinates.clientY = -1
        linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates))

        await nextViewUpdatePromise()
        expect(editor.getCursorScreenPosition()).toEqual([0, 0])
      })
    })

    describe('when the mouse is single-clicked below the last line', function () {
      it('moves the cursor to the end of file buffer position', async function () {
        editor.setText('foo')
        editor.setCursorBufferPosition([0, 0])
        let height = 4.5 * lineHeightInPixels
        wrapperNode.style.height = height + 'px'
        wrapperNode.style.width = 10 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        let coordinates = clientCoordinatesForScreenPosition([0, 2])
        coordinates.clientY = height * 2

        linesNode.dispatchEvent(buildMouseEvent('mousedown', coordinates))
        await nextViewUpdatePromise()

        expect(editor.getCursorScreenPosition()).toEqual([0, 3])
      })
    })

    describe('when a non-folded line is single-clicked', function () {
      describe('when no modifier keys are held down', function () {
        it('moves the cursor to the nearest screen position', async function () {
          wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
          wrapperNode.style.width = 10 * charWidth + 'px'
          component.measureDimensions()
          wrapperNode.setScrollTop(3.5 * lineHeightInPixels)
          wrapperNode.setScrollLeft(2 * charWidth)
          await nextViewUpdatePromise()
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8])))
          await nextViewUpdatePromise()
          expect(editor.getCursorScreenPosition()).toEqual([4, 8])
        })
      })

      describe('when the shift key is held down', function () {
        it('selects to the nearest screen position', async function () {
          editor.setCursorScreenPosition([3, 4])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), {
            shiftKey: true
          }))
          await nextViewUpdatePromise()
          expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [5, 6]])
        })
      })

      describe('when the command key is held down', function () {
        describe('the current cursor position and screen position do not match', function () {
          it('adds a cursor at the nearest screen position', async function () {
            editor.setCursorScreenPosition([3, 4])
            linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 6]), {
              metaKey: true
            }))
            await nextViewUpdatePromise()
            expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]], [[5, 6], [5, 6]]])
          })
        })

        describe('when there are multiple cursors, and one of the cursor\'s screen position is the same as the mouse click screen position', async function () {
          it('removes a cursor at the mouse screen position', async function () {
            editor.setCursorScreenPosition([3, 4])
            editor.addCursorAtScreenPosition([5, 2])
            editor.addCursorAtScreenPosition([7, 5])
            linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), {
              metaKey: true
            }))
            await nextViewUpdatePromise()
            expect(editor.getSelectedScreenRanges()).toEqual([[[5, 2], [5, 2]], [[7, 5], [7, 5]]])
          })
        })

        describe('when there is a single cursor and the click occurs at the cursor\'s screen position', async function () {
          it('neither adds a new cursor nor removes the current cursor', async function () {
            editor.setCursorScreenPosition([3, 4])
            linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([3, 4]), {
              metaKey: true
            }))
            await nextViewUpdatePromise()
            expect(editor.getSelectedScreenRanges()).toEqual([[[3, 4], [3, 4]]])
          })
        })
      })
    })

    describe('when a non-folded line is double-clicked', function () {
      describe('when no modifier keys are held down', function () {
        it('selects the word containing the nearest screen position', function () {
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 1
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 2
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), {
            detail: 1
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [6, 6]])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), {
            detail: 1,
            shiftKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[6, 6], [8, 8]])
        })
      })

      describe('when the command key is held down', function () {
        it('selects the word containing the newly-added cursor', function () {
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 1,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 2,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 6], [5, 13]]])
        })
      })
    })

    describe('when a non-folded line is triple-clicked', function () {
      describe('when no modifier keys are held down', function () {
        it('selects the line containing the nearest screen position', function () {
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 1
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 2
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 3
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([6, 6]), {
            detail: 1,
            shiftKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [7, 0]])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([7, 5]), {
            detail: 1
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([8, 8]), {
            detail: 1,
            shiftKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRange()).toEqual([[7, 5], [8, 8]])
        })
      })

      describe('when the command key is held down', function () {
        it('selects the line containing the newly-added cursor', function () {
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 1,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 2,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
            detail: 3,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mouseup'))
          expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [0, 0]], [[5, 0], [6, 0]]])
        })
      })
    })

    describe('when the mouse is clicked and dragged', function () {
      it('selects to the nearest screen position until the mouse button is released', async function () {
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
          which: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), {
          which: 1
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), {
          which: 1
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]])
        linesNode.dispatchEvent(buildMouseEvent('mouseup'))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([12, 0]), {
          which: 1
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [10, 0]])
      })

      it('autoscrolls when the cursor approaches the boundaries of the editor', async function () {
        wrapperNode.style.height = '100px'
        wrapperNode.style.width = '100px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(wrapperNode.getScrollLeft()).toBe(0)

        linesNode.dispatchEvent(buildMouseEvent('mousedown', {
          clientX: 0,
          clientY: 0
        }, {
          which: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', {
          clientX: 100,
          clientY: 50
        }, {
          which: 1
        }))

        for (let i = 0; i <= 5; ++i) {
          await nextAnimationFramePromise()
        }

        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(wrapperNode.getScrollLeft()).toBeGreaterThan(0)
        linesNode.dispatchEvent(buildMouseEvent('mousemove', {
          clientX: 100,
          clientY: 100
        }, {
          which: 1
        }))

        for (let i = 0; i <= 5; ++i) {
          await nextAnimationFramePromise()
        }

        expect(wrapperNode.getScrollTop()).toBeGreaterThan(0)
        let previousScrollTop = wrapperNode.getScrollTop()
        let previousScrollLeft = wrapperNode.getScrollLeft()

        linesNode.dispatchEvent(buildMouseEvent('mousemove', {
          clientX: 10,
          clientY: 50
        }, {
          which: 1
        }))

        for (let i = 0; i <= 5; ++i) {
          await nextAnimationFramePromise()
        }

        expect(wrapperNode.getScrollTop()).toBe(previousScrollTop)
        expect(wrapperNode.getScrollLeft()).toBeLessThan(previousScrollLeft)
        linesNode.dispatchEvent(buildMouseEvent('mousemove', {
          clientX: 10,
          clientY: 10
        }, {
          which: 1
        }))

        for (let i = 0; i <= 5; ++i) {
          await nextAnimationFramePromise()
        }

        expect(wrapperNode.getScrollTop()).toBeLessThan(previousScrollTop)
      })

      it('stops selecting if the mouse is dragged into the dev tools', async function () {
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
          which: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), {
          which: 1
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([10, 0]), {
          which: 0
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), {
          which: 1
        }))
        await nextAnimationFramePromise()
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]])
      })

      it('stops selecting before the buffer is modified during the drag', async function () {
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
          which: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [6, 8]])

        editor.insertText('x')
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), {
          which: 1
        }))
        expect(editor.getSelectedScreenRange()).toEqual([[2, 5], [2, 5]])

        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
          which: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([5, 4]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [5, 4]])

        editor.delete()
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 0]), {
          which: 1
        }))
        expect(editor.getSelectedScreenRange()).toEqual([[2, 4], [2, 4]])
      })

      describe('when the command key is held down', function () {
        it('adds a new selection and selects to the nearest screen position, then merges intersecting selections when the mouse button is released', async function () {
          editor.setSelectedScreenRange([[4, 4], [4, 9]])
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
            which: 1,
            metaKey: true
          }))
          linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), {
            which: 1
          }))
          await nextAnimationFramePromise()

          expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [6, 8]]])

          linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([4, 6]), {
            which: 1
          }))
          await nextAnimationFramePromise()

          expect(editor.getSelectedScreenRanges()).toEqual([[[4, 4], [4, 9]], [[2, 4], [4, 6]]])
          linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([4, 6]), {
            which: 1
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[2, 4], [4, 9]]])
        })
      })

      describe('when the editor is destroyed while dragging', function () {
        it('cleans up the handlers for window.mouseup and window.mousemove', async function () {
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([2, 4]), {
            which: 1
          }))
          linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 8]), {
            which: 1
          }))
          await nextAnimationFramePromise()

          spyOn(window, 'removeEventListener').andCallThrough()
          linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([6, 10]), {
            which: 1
          }))

          editor.destroy()
          await nextAnimationFramePromise()

          for (let call of window.removeEventListener.calls) {
            call.args.pop()
          }
          expect(window.removeEventListener).toHaveBeenCalledWith('mouseup')
          expect(window.removeEventListener).toHaveBeenCalledWith('mousemove')
        })
      })
    })

    describe('when the mouse is double-clicked and dragged', function () {
      it('expands the selection over the nearest word as the cursor moves', async function () {
        jasmine.attachToDOM(wrapperNode)
        wrapperNode.style.height = 6 * lineHeightInPixels + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
          detail: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mouseup'))

        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
          detail: 2
        }))
        expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [5, 13]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [12, 2]])
        let maximalScrollTop = wrapperNode.getScrollTop()
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([9, 3]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[5, 6], [9, 4]])
        expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop)
        linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), {
          which: 1
        }))
      })
    })

    describe('when the mouse is triple-clicked and dragged', function () {
      it('expands the selection over the nearest line as the cursor moves', async function () {
        jasmine.attachToDOM(wrapperNode)
        wrapperNode.style.height = 6 * lineHeightInPixels + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
          detail: 1
        }))
        linesNode.dispatchEvent(buildMouseEvent('mouseup'))
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
          detail: 2
        }))
        linesNode.dispatchEvent(buildMouseEvent('mouseup'))
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([5, 10]), {
          detail: 3
        }))
        expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [6, 0]])
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([11, 11]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [12, 2]])
        let maximalScrollTop = wrapperNode.getScrollTop()
        linesNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenPosition([8, 4]), {
          which: 1
        }))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[5, 0], [8, 0]])
        expect(wrapperNode.getScrollTop()).toBe(maximalScrollTop)
        linesNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([9, 3]), {
          which: 1
        }))
      })
    })

    describe('when a line is folded', function () {
      beforeEach(async function () {
        editor.foldBufferRow(4)
        await nextViewUpdatePromise()
      })

      describe('when the folded line\'s fold-marker is clicked', function () {
        it('unfolds the buffer row', function () {
          let target = component.lineNodeForScreenRow(4).querySelector('.fold-marker')
          linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]), {
            target: target
          }))
          expect(editor.isFoldedAtBufferRow(4)).toBe(false)
        })
      })
    })

    describe('when the horizontal scrollbar is interacted with', function () {
      it('clicking on the scrollbar does not move the cursor', function () {
        let target = horizontalScrollbarNode
        linesNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([4, 8]), {
          target: target
        }))
        expect(editor.getCursorScreenPosition()).toEqual([0, 0])
      })
    })
  })

  describe('mouse interactions on the gutter', function () {
    let gutterNode

    beforeEach(function () {
      gutterNode = componentNode.querySelector('.gutter')
    })

    describe('when the component is destroyed', function () {
      it('stops listening for selection events', function () {
        component.destroy()
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1)))
        expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [0, 0]])
      })
    })

    describe('when the gutter is clicked', function () {
      it('selects the clicked row', function () {
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4)))
        expect(editor.getSelectedScreenRange()).toEqual([[4, 0], [5, 0]])
      })
    })

    describe('when the gutter is meta-clicked', function () {
      it('creates a new selection for the clicked row', function () {
        editor.setSelectedScreenRange([[3, 0], [3, 2]])
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), {
          metaKey: true
        }))
        expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]]])
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), {
          metaKey: true
        }))
        expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [5, 0]], [[6, 0], [7, 0]]])
      })
    })

    describe('when the gutter is shift-clicked', function () {
      beforeEach(function () {
        editor.setSelectedScreenRange([[3, 4], [4, 5]])
      })

      describe('when the clicked row is before the current selection\'s tail', function () {
        it('selects to the beginning of the clicked row', function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
            shiftKey: true
          }))
          expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [3, 4]])
        })
      })

      describe('when the clicked row is after the current selection\'s tail', function () {
        it('selects to the beginning of the row following the clicked row', function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), {
            shiftKey: true
          }))
          expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [7, 0]])
        })
      })
    })

    describe('when the gutter is clicked and dragged', function () {
      describe('when dragging downward', function () {
        it('selects the rows between the start and end of the drag', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2)))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6)))
          await nextAnimationFramePromise()
          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6)))
          expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]])
        })
      })

      describe('when dragging upward', function () {
        it('selects the rows between the start and end of the drag', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6)))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2)))
          await nextAnimationFramePromise()
          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2)))
          expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]])
        })
      })

      it('orients the selection appropriately when the mouse moves above or below the initially-clicked row', async function () {
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4)))
        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2)))
        await nextAnimationFramePromise()
        expect(editor.getLastSelection().isReversed()).toBe(true)
        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6)))
        await nextAnimationFramePromise()
        expect(editor.getLastSelection().isReversed()).toBe(false)
      })

      it('autoscrolls when the cursor approaches the top or bottom of the editor', async function () {
        wrapperNode.style.height = 6 * lineHeightInPixels + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)

        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2)))
        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8)))
        await nextAnimationFramePromise()

        expect(wrapperNode.getScrollTop()).toBeGreaterThan(0)
        let maxScrollTop = wrapperNode.getScrollTop()

        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(10)))
        await nextAnimationFramePromise()

        expect(wrapperNode.getScrollTop()).toBe(maxScrollTop)

        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7)))
        await nextAnimationFramePromise()

        expect(wrapperNode.getScrollTop()).toBeLessThan(maxScrollTop)
      })

      it('stops selecting if a textInput event occurs during the drag', async function () {
        gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2)))
        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6)))
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [7, 0]])

        let inputEvent = new Event('textInput')
        inputEvent.data = 'x'
        Object.defineProperty(inputEvent, 'target', {
          get: function () {
            return componentNode.querySelector('.hidden-input')
          }
        })
        componentNode.dispatchEvent(inputEvent)
        await nextAnimationFramePromise()

        expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]])
        gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(12)))
        expect(editor.getSelectedScreenRange()).toEqual([[2, 1], [2, 1]])
      })
    })

    describe('when the gutter is meta-clicked and dragged', function () {
      beforeEach(function () {
        editor.setSelectedScreenRange([[3, 0], [3, 2]])
      })

      describe('when dragging downward', function () {
        it('selects the rows between the start and end of the drag', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(4), {
            metaKey: true
          }))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          await nextAnimationFramePromise()

          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]])
        })

        it('merges overlapping selections when the mouse button is released', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), {
            metaKey: true
          }))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          await nextAnimationFramePromise()

          expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[2, 0], [7, 0]]])
          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]])
        })
      })

      describe('when dragging upward', function () {
        it('selects the rows between the start and end of the drag', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(4), {
            metaKey: true
          }))
          await nextAnimationFramePromise()

          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(4), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[3, 0], [3, 2]], [[4, 0], [7, 0]]])
        })

        it('merges overlapping selections', async function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6), {
            metaKey: true
          }))
          gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2), {
            metaKey: true
          }))
          await nextAnimationFramePromise()

          gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(2), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[2, 0], [7, 0]]])
        })
      })
    })

    describe('when the gutter is shift-clicked and dragged', function () {
      describe('when the shift-click is below the existing selection\'s tail', function () {
        describe('when dragging downward', function () {
          it('selects the rows between the existing selection\'s tail and the end of the drag', async function () {
            editor.setSelectedScreenRange([[3, 4], [4, 5]])
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), {
              shiftKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]])
          })
        })

        describe('when dragging upward', function () {
          it('selects the rows between the end of the drag and the tail of the existing selection', async function () {
            editor.setSelectedScreenRange([[4, 4], [5, 5]])
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), {
              shiftKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[4, 4], [6, 0]])
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]])
          })
        })
      })

      describe('when the shift-click is above the existing selection\'s tail', function () {
        describe('when dragging upward', function () {
          it('selects the rows between the end of the drag and the tail of the existing selection', async function () {
            editor.setSelectedScreenRange([[4, 4], [5, 5]])
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), {
              shiftKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [4, 4]])
          })
        })

        describe('when dragging downward', function () {
          it('selects the rows between the existing selection\'s tail and the end of the drag', async function () {
            editor.setSelectedScreenRange([[3, 4], [4, 5]])
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
              shiftKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(2)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[2, 0], [3, 4]])
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(8)))
            await nextAnimationFramePromise()
            expect(editor.getSelectedScreenRange()).toEqual([[3, 4], [9, 0]])
          })
        })
      })
    })

    describe('when soft wrap is enabled', function () {
      beforeEach(async function () {
        gutterNode = componentNode.querySelector('.gutter')
        editor.setSoftWrapped(true)
        await nextViewUpdatePromise()
        componentNode.style.width = 21 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()
      })

      describe('when the gutter is clicked', function () {
        it('selects the clicked buffer row', function () {
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1)))
          expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [2, 0]])
        })
      })

      describe('when the gutter is meta-clicked', function () {
        it('creates a new selection for the clicked buffer row', function () {
          editor.setSelectedScreenRange([[1, 0], [1, 2]])
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(2), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]]])
          gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), {
            metaKey: true
          }))
          expect(editor.getSelectedScreenRanges()).toEqual([[[1, 0], [1, 2]], [[2, 0], [5, 0]], [[5, 0], [10, 0]]])
        })
      })

      describe('when the gutter is shift-clicked', function () {
        beforeEach(function () {
          return editor.setSelectedScreenRange([[7, 4], [7, 6]])
        })

        describe('when the clicked row is before the current selection\'s tail', function () {
          it('selects to the beginning of the clicked buffer row', function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
              shiftKey: true
            }))
            expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [7, 4]])
          })
        })

        describe('when the clicked row is after the current selection\'s tail', function () {
          it('selects to the beginning of the screen row following the clicked buffer row', function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), {
              shiftKey: true
            }))
            expect(editor.getSelectedScreenRange()).toEqual([[7, 4], [16, 0]])
          })
        })
      })

      describe('when the gutter is clicked and dragged', function () {
        describe('when dragging downward', function () {
          it('selects the buffer row containing the click, then screen rows until the end of the drag', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1)))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(6)))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(6)))
            expect(editor.getSelectedScreenRange()).toEqual([[0, 0], [6, 14]])
          })
        })

        describe('when dragging upward', function () {
          it('selects the buffer row containing the click, then screen rows until the end of the drag', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(6)))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1)))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(1)))
            expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [10, 0]])
          })
        })
      })

      describe('when the gutter is meta-clicked and dragged', function () {
        beforeEach(function () {
          editor.setSelectedScreenRange([[7, 4], [7, 6]])
        })

        describe('when dragging downward', function () {
          it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
              metaKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3), {
              metaKey: true
            }))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(3), {
              metaKey: true
            }))
            expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[0, 0], [3, 14]]])
          })

          it('merges overlapping selections on mouseup', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
              metaKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7), {
              metaKey: true
            }))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(7), {
              metaKey: true
            }))
            expect(editor.getSelectedScreenRanges()).toEqual([[[0, 0], [7, 12]]])
          })
        })

        describe('when dragging upward', function () {
          it('adds a selection from the buffer row containing the click to the screen row containing the end of the drag', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), {
              metaKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11), {
              metaKey: true
            }))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(11), {
              metaKey: true
            }))
            expect(editor.getSelectedScreenRanges()).toEqual([[[7, 4], [7, 6]], [[11, 4], [19, 0]]])
          })

          it('merges overlapping selections on mouseup', async function () {
            gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(17), {
              metaKey: true
            }))
            gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(5), {
              metaKey: true
            }))
            await nextAnimationFramePromise()
            gutterNode.dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenRowInGutter(5), {
              metaKey: true
            }))
            expect(editor.getSelectedScreenRanges()).toEqual([[[5, 0], [19, 0]]])
          })
        })
      })

      describe('when the gutter is shift-clicked and dragged', function () {
        describe('when the shift-click is below the existing selection\'s tail', function () {
          describe('when dragging downward', function () {
            it('selects the screen rows between the existing selection\'s tail and the end of the drag', async function () {
              editor.setSelectedScreenRange([[1, 4], [1, 7]])
              gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(7), {
                shiftKey: true
              }))
              gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(11)))
              await nextAnimationFramePromise()
              expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [11, 14]])
            })
          })

          describe('when dragging upward', function () {
            it('selects the screen rows between the end of the drag and the tail of the existing selection', async function () {
              editor.setSelectedScreenRange([[1, 4], [1, 7]])
              gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(11), {
                shiftKey: true
              }))
              gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(7)))
              await nextAnimationFramePromise()
              expect(editor.getSelectedScreenRange()).toEqual([[1, 4], [7, 12]])
            })
          })
        })

        describe('when the shift-click is above the existing selection\'s tail', function () {
          describe('when dragging upward', function () {
            it('selects the screen rows between the end of the drag and the tail of the existing selection', async function () {
              editor.setSelectedScreenRange([[7, 4], [7, 6]])
              gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(3), {
                shiftKey: true
              }))
              gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(1)))
              await nextAnimationFramePromise()
              expect(editor.getSelectedScreenRange()).toEqual([[1, 0], [7, 4]])
            })
          })

          describe('when dragging downward', function () {
            it('selects the screen rows between the existing selection\'s tail and the end of the drag', async function () {
              editor.setSelectedScreenRange([[7, 4], [7, 6]])
              gutterNode.dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenRowInGutter(1), {
                shiftKey: true
              }))
              gutterNode.dispatchEvent(buildMouseEvent('mousemove', clientCoordinatesForScreenRowInGutter(3)))
              await nextAnimationFramePromise()
              expect(editor.getSelectedScreenRange()).toEqual([[3, 2], [7, 4]])
            })
          })
        })
      })
    })
  })

  describe('focus handling', async function () {
    let inputNode
    beforeEach(function () {
      inputNode = componentNode.querySelector('.hidden-input')
    })

    it('transfers focus to the hidden input', function () {
      expect(document.activeElement).toBe(document.body)
      wrapperNode.focus()
      expect(document.activeElement).toBe(wrapperNode)
      expect(wrapperNode.shadowRoot.activeElement).toBe(inputNode)
    })

    it('adds the "is-focused" class to the editor when the hidden input is focused', async function () {
      expect(document.activeElement).toBe(document.body)
      inputNode.focus()
      await nextViewUpdatePromise()

      expect(componentNode.classList.contains('is-focused')).toBe(true)
      expect(wrapperNode.classList.contains('is-focused')).toBe(true)
      inputNode.blur()
      await nextViewUpdatePromise()

      expect(componentNode.classList.contains('is-focused')).toBe(false)
      expect(wrapperNode.classList.contains('is-focused')).toBe(false)
    })
  })

  describe('selection handling', function () {
    let cursor

    beforeEach(async function () {
      editor.setCursorScreenPosition([0, 0])
      await nextViewUpdatePromise()
    })

    it('adds the "has-selection" class to the editor when there is a selection', async function () {
      expect(componentNode.classList.contains('has-selection')).toBe(false)
      editor.selectDown()
      await nextViewUpdatePromise()
      expect(componentNode.classList.contains('has-selection')).toBe(true)
      editor.moveDown()
      await nextViewUpdatePromise()
      expect(componentNode.classList.contains('has-selection')).toBe(false)
    })
  })

  describe('scrolling', function () {
    it('updates the vertical scrollbar when the scrollTop is changed in the model', async function () {
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()
      expect(verticalScrollbarNode.scrollTop).toBe(0)
      wrapperNode.setScrollTop(10)
      await nextViewUpdatePromise()
      expect(verticalScrollbarNode.scrollTop).toBe(10)
    })

    it('updates the horizontal scrollbar and the x transform of the lines based on the scrollLeft of the model', async function () {
      componentNode.style.width = 30 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      let top = 0
      let tilesNodes = component.tileNodesForLines()
      for (let tileNode of tilesNodes) {
        expect(tileNode.style['-webkit-transform']).toBe('translate3d(0px, ' + top + 'px, 0px)')
        top += tileNode.offsetHeight
      }
      expect(horizontalScrollbarNode.scrollLeft).toBe(0)
      wrapperNode.setScrollLeft(100)

      await nextViewUpdatePromise()

      top = 0
      for (let tileNode of tilesNodes) {
        expect(tileNode.style['-webkit-transform']).toBe('translate3d(-100px, ' + top + 'px, 0px)')
        top += tileNode.offsetHeight
      }
      expect(horizontalScrollbarNode.scrollLeft).toBe(100)
    })

    it('updates the scrollLeft of the model when the scrollLeft of the horizontal scrollbar changes', async function () {
      componentNode.style.width = 30 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()
      expect(wrapperNode.getScrollLeft()).toBe(0)
      horizontalScrollbarNode.scrollLeft = 100
      horizontalScrollbarNode.dispatchEvent(new UIEvent('scroll'))
      await nextViewUpdatePromise()
      expect(wrapperNode.getScrollLeft()).toBe(100)
    })

    it('does not obscure the last line with the horizontal scrollbar', async function () {
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      wrapperNode.setScrollBottom(wrapperNode.getScrollHeight())
      await nextViewUpdatePromise()

      let lastLineNode = component.lineNodeForScreenRow(editor.getLastScreenRow())
      let bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom
      topOfHorizontalScrollbar = horizontalScrollbarNode.getBoundingClientRect().top
      expect(bottomOfLastLine).toBe(topOfHorizontalScrollbar)
      wrapperNode.style.width = 100 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      bottomOfLastLine = lastLineNode.getBoundingClientRect().bottom
      let bottomOfEditor = componentNode.getBoundingClientRect().bottom
      expect(bottomOfLastLine).toBe(bottomOfEditor)
    })

    it('does not obscure the last character of the longest line with the vertical scrollbar', async function () {
      wrapperNode.style.height = 7 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      wrapperNode.setScrollLeft(Infinity)

      await nextViewUpdatePromise()
      let rightOfLongestLine = component.lineNodeForScreenRow(6).querySelector('.line > span:last-child').getBoundingClientRect().right
      let leftOfVerticalScrollbar = verticalScrollbarNode.getBoundingClientRect().left
      expect(Math.round(rightOfLongestLine)).toBeCloseTo(leftOfVerticalScrollbar - 1, 0)
    })

    it('only displays dummy scrollbars when scrollable in that direction', async function () {
      expect(verticalScrollbarNode.style.display).toBe('none')
      expect(horizontalScrollbarNode.style.display).toBe('none')
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = '1000px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.display).toBe('')
      expect(horizontalScrollbarNode.style.display).toBe('none')
      componentNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.display).toBe('')
      expect(horizontalScrollbarNode.style.display).toBe('')
      wrapperNode.style.height = 20 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.display).toBe('none')
      expect(horizontalScrollbarNode.style.display).toBe('')
    })

    it('makes the dummy scrollbar divs only as tall/wide as the actual scrollbars', async function () {
      wrapperNode.style.height = 4 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      atom.styles.addStyleSheet('::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}', {
        context: 'atom-text-editor'
      })

      await nextAnimationFramePromise()
      await nextAnimationFramePromise()

      let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner')
      expect(verticalScrollbarNode.offsetWidth).toBe(8)
      expect(horizontalScrollbarNode.offsetHeight).toBe(8)
      expect(scrollbarCornerNode.offsetWidth).toBe(8)
      expect(scrollbarCornerNode.offsetHeight).toBe(8)
      atom.themes.removeStylesheet('test')
    })

    it('assigns the bottom/right of the scrollbars to the width of the opposite scrollbar if it is visible', async function () {
      let scrollbarCornerNode = componentNode.querySelector('.scrollbar-corner')
      expect(verticalScrollbarNode.style.bottom).toBe('0px')
      expect(horizontalScrollbarNode.style.right).toBe('0px')
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = '1000px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.bottom).toBe('0px')
      expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px')
      expect(scrollbarCornerNode.style.display).toBe('none')
      componentNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px')
      expect(horizontalScrollbarNode.style.right).toBe(verticalScrollbarNode.offsetWidth + 'px')
      expect(scrollbarCornerNode.style.display).toBe('')
      wrapperNode.style.height = 20 * lineHeightInPixels + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(verticalScrollbarNode.style.bottom).toBe(horizontalScrollbarNode.offsetHeight + 'px')
      expect(horizontalScrollbarNode.style.right).toBe('0px')
      expect(scrollbarCornerNode.style.display).toBe('none')
    })

    it('accounts for the width of the gutter in the scrollWidth of the horizontal scrollbar', async function () {
      let gutterNode = componentNode.querySelector('.gutter')
      componentNode.style.width = 10 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      expect(horizontalScrollbarNode.scrollWidth).toBe(wrapperNode.getScrollWidth())
      expect(horizontalScrollbarNode.style.left).toBe('0px')
    })
  })

  describe('mousewheel events', function () {
    beforeEach(function () {
      atom.config.set('editor.scrollSensitivity', 100)
    })

    describe('updating scrollTop and scrollLeft', function () {
      beforeEach(async function () {
        wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
        wrapperNode.style.width = 20 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()
      })

      it('updates the scrollLeft or scrollTop on mousewheel events depending on which delta is greater (x or y)', async function () {
        expect(verticalScrollbarNode.scrollTop).toBe(0)
        expect(horizontalScrollbarNode.scrollLeft).toBe(0)
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: -5,
          wheelDeltaY: -10
        }))
        await nextAnimationFramePromise()

        expect(verticalScrollbarNode.scrollTop).toBe(10)
        expect(horizontalScrollbarNode.scrollLeft).toBe(0)
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: -15,
          wheelDeltaY: -5
        }))
        await nextAnimationFramePromise()

        expect(verticalScrollbarNode.scrollTop).toBe(10)
        expect(horizontalScrollbarNode.scrollLeft).toBe(15)
      })

      it('updates the scrollLeft or scrollTop according to the scroll sensitivity', async function () {
        atom.config.set('editor.scrollSensitivity', 50)
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: -5,
          wheelDeltaY: -10
        }))
        await nextAnimationFramePromise()

        expect(horizontalScrollbarNode.scrollLeft).toBe(0)
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: -15,
          wheelDeltaY: -5
        }))
        await nextAnimationFramePromise()

        expect(verticalScrollbarNode.scrollTop).toBe(5)
        expect(horizontalScrollbarNode.scrollLeft).toBe(7)
      })

      it('uses the previous scrollSensitivity when the value is not an int', async function () {
        atom.config.set('editor.scrollSensitivity', 'nope')
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: -10
        }))
        await nextAnimationFramePromise()
        expect(verticalScrollbarNode.scrollTop).toBe(10)
      })

      it('parses negative scrollSensitivity values at the minimum', async function () {
        atom.config.set('editor.scrollSensitivity', -50)
        componentNode.dispatchEvent(new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: -10
        }))
        await nextAnimationFramePromise()
        expect(verticalScrollbarNode.scrollTop).toBe(1)
      })
    })

    describe('when the mousewheel event\'s target is a line', function () {
      it('keeps the line on the DOM if it is scrolled off-screen', async function () {
        component.presenter.stoppedScrollingDelay = 3000 // account for slower build machines
        wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
        wrapperNode.style.width = 20 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        let lineNode = componentNode.querySelector('.line')
        let wheelEvent = new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: -500
        })
        Object.defineProperty(wheelEvent, 'target', {
          get: function () {
            return lineNode
          }
        })
        componentNode.dispatchEvent(wheelEvent)
        await nextViewUpdatePromise()

        expect(componentNode.contains(lineNode)).toBe(true)
      })

      it('does not set the mouseWheelScreenRow if scrolling horizontally', async function () {
        wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
        wrapperNode.style.width = 20 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        let lineNode = componentNode.querySelector('.line')
        let wheelEvent = new WheelEvent('mousewheel', {
          wheelDeltaX: 10,
          wheelDeltaY: 0
        })
        Object.defineProperty(wheelEvent, 'target', {
          get: function () {
            return lineNode
          }
        })
        componentNode.dispatchEvent(wheelEvent)
        await nextAnimationFramePromise()

        expect(component.presenter.mouseWheelScreenRow).toBe(null)
      })

      it('clears the mouseWheelScreenRow after a delay even if the event does not cause scrolling', async function () {
        expect(wrapperNode.getScrollTop()).toBe(0)
        let lineNode = componentNode.querySelector('.line')
        let wheelEvent = new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: 10
        })
        Object.defineProperty(wheelEvent, 'target', {
          get: function () {
            return lineNode
          }
        })
        componentNode.dispatchEvent(wheelEvent)
        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(component.presenter.mouseWheelScreenRow).toBe(0)

        await conditionPromise(function () {
          return component.presenter.mouseWheelScreenRow == null
        })
      })

      it('does not preserve the line if it is on screen', function () {
        let lineNode, lineNodes, wheelEvent
        expect(componentNode.querySelectorAll('.line-number').length).toBe(14)
        lineNodes = componentNode.querySelectorAll('.line')
        expect(lineNodes.length).toBe(13)
        lineNode = lineNodes[0]
        wheelEvent = new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: 100
        })
        Object.defineProperty(wheelEvent, 'target', {
          get: function () {
            return lineNode
          }
        })
        componentNode.dispatchEvent(wheelEvent)
        expect(component.presenter.mouseWheelScreenRow).toBe(0)
        editor.insertText('hello')
        expect(componentNode.querySelectorAll('.line-number').length).toBe(14)
        expect(componentNode.querySelectorAll('.line').length).toBe(13)
      })
    })

    describe('when the mousewheel event\'s target is a line number', function () {
      it('keeps the line number on the DOM if it is scrolled off-screen', async function () {
        wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
        wrapperNode.style.width = 20 * charWidth + 'px'
        component.measureDimensions()
        await nextViewUpdatePromise()

        let lineNumberNode = componentNode.querySelectorAll('.line-number')[1]
        let wheelEvent = new WheelEvent('mousewheel', {
          wheelDeltaX: 0,
          wheelDeltaY: -500
        })
        Object.defineProperty(wheelEvent, 'target', {
          get: function () {
            return lineNumberNode
          }
        })
        componentNode.dispatchEvent(wheelEvent)
        await nextAnimationFramePromise()

        expect(componentNode.contains(lineNumberNode)).toBe(true)
      })
    })

    it('only prevents the default action of the mousewheel event if it actually lead to scrolling', async function () {
      spyOn(WheelEvent.prototype, 'preventDefault').andCallThrough()
      wrapperNode.style.height = 4.5 * lineHeightInPixels + 'px'
      wrapperNode.style.width = 20 * charWidth + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: 0,
        wheelDeltaY: 50
      }))
      expect(wrapperNode.getScrollTop()).toBe(0)
      expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled()
      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: 0,
        wheelDeltaY: -3000
      }))
      await nextAnimationFramePromise()

      let maxScrollTop = wrapperNode.getScrollTop()
      expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled()
      WheelEvent.prototype.preventDefault.reset()
      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: 0,
        wheelDeltaY: -30
      }))
      expect(wrapperNode.getScrollTop()).toBe(maxScrollTop)
      expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled()
      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: 50,
        wheelDeltaY: 0
      }))
      expect(wrapperNode.getScrollLeft()).toBe(0)
      expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled()
      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: -3000,
        wheelDeltaY: 0
      }))
      await nextAnimationFramePromise()

      let maxScrollLeft = wrapperNode.getScrollLeft()
      expect(WheelEvent.prototype.preventDefault).toHaveBeenCalled()
      WheelEvent.prototype.preventDefault.reset()
      componentNode.dispatchEvent(new WheelEvent('mousewheel', {
        wheelDeltaX: -30,
        wheelDeltaY: 0
      }))
      expect(wrapperNode.getScrollLeft()).toBe(maxScrollLeft)
      expect(WheelEvent.prototype.preventDefault).not.toHaveBeenCalled()
    })
  })

  describe('input events', function () {
    function buildTextInputEvent ({data, target}) {
      let event = new Event('textInput')
      event.data = data
      Object.defineProperty(event, 'target', {
        get: function () {
          return target
        }
      })
      return event
    }

    let inputNode

    beforeEach(function () {
      inputNode = componentNode.querySelector('.hidden-input')
    })

    it('inserts the newest character in the input\'s value into the buffer', async function () {
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'x',
        target: inputNode
      }))
      await nextViewUpdatePromise()

      expect(editor.lineTextForBufferRow(0)).toBe('xvar quicksort = function () {')
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'y',
        target: inputNode
      }))

      expect(editor.lineTextForBufferRow(0)).toBe('xyvar quicksort = function () {')
    })

    it('replaces the last character if the length of the input\'s value does not increase, as occurs with the accented character menu', async function () {
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'u',
        target: inputNode
      }))
      await nextViewUpdatePromise()

      expect(editor.lineTextForBufferRow(0)).toBe('uvar quicksort = function () {')
      inputNode.setSelectionRange(0, 1)
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'ü',
        target: inputNode
      }))
      await nextViewUpdatePromise()

      expect(editor.lineTextForBufferRow(0)).toBe('üvar quicksort = function () {')
    })

    it('does not handle input events when input is disabled', async function () {
      component.setInputEnabled(false)
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'x',
        target: inputNode
      }))
      expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {')
      await nextAnimationFramePromise()
      expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {')
    })

    it('groups events that occur close together in time into single undo entries', function () {
      let currentTime = 0
      spyOn(Date, 'now').andCallFake(function () {
        return currentTime
      })
      atom.config.set('editor.undoGroupingInterval', 100)
      editor.setText('')
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'x',
        target: inputNode
      }))
      currentTime += 99
      componentNode.dispatchEvent(buildTextInputEvent({
        data: 'y',
        target: inputNode
      }))
      currentTime += 99
      componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', {
        bubbles: true,
        cancelable: true
      }))
      currentTime += 101
      componentNode.dispatchEvent(new CustomEvent('editor:duplicate-lines', {
        bubbles: true,
        cancelable: true
      }))
      expect(editor.getText()).toBe('xy\nxy\nxy')
      componentNode.dispatchEvent(new CustomEvent('core:undo', {
        bubbles: true,
        cancelable: true
      }))
      expect(editor.getText()).toBe('xy\nxy')
      componentNode.dispatchEvent(new CustomEvent('core:undo', {
        bubbles: true,
        cancelable: true
      }))
      expect(editor.getText()).toBe('')
    })

    describe('when IME composition is used to insert international characters', function () {
      function buildIMECompositionEvent (event, {data, target} = {}) {
        event = new Event(event)
        event.data = data
        Object.defineProperty(event, 'target', {
          get: function () {
            return target
          }
        })
        return event
      }

      let inputNode

      beforeEach(function () {
        inputNode = componentNode.querySelector('.hidden-input')
      })

      describe('when nothing is selected', function () {
        it('inserts the chosen completion', function () {
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 's',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 'sd',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildTextInputEvent({
            data: '速度',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('速度var quicksort = function () {')
        })

        it('reverts back to the original text when the completion helper is dismissed', function () {
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 's',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('svar quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 'sd',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('sdvar quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {')
        })

        it('allows multiple accented character to be inserted with the \' on a US international layout', function () {
          inputNode.value = '\''
          inputNode.setSelectionRange(0, 1)
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: '\'',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('\'var quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildTextInputEvent({
            data: 'á',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('ávar quicksort = function () {')
          inputNode.value = '\''
          inputNode.setSelectionRange(0, 1)
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: '\'',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('á\'var quicksort = function () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildTextInputEvent({
            data: 'á',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('áávar quicksort = function () {')
        })
      })

      describe('when a string is selected', function () {
        beforeEach(function () {
          editor.setSelectedBufferRanges([[[0, 4], [0, 9]], [[0, 16], [0, 19]]])
        })

        it('inserts the chosen completion', function () {
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 's',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 'sd',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildTextInputEvent({
            data: '速度',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var 速度sort = 速度ction () {')
        })

        it('reverts back to the original text when the completion helper is dismissed', function () {
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionstart', {
            target: inputNode
          }))
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 's',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var ssort = sction () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionupdate', {
            data: 'sd',
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var sdsort = sdction () {')
          componentNode.dispatchEvent(buildIMECompositionEvent('compositionend', {
            target: inputNode
          }))
          expect(editor.lineTextForBufferRow(0)).toBe('var quicksort = function () {')
        })
      })
    })
  })

  describe('commands', function () {
    describe('editor:consolidate-selections', function () {
      it('consolidates selections on the editor model, aborting the key binding if there is only one selection', function () {
        spyOn(editor, 'consolidateSelections').andCallThrough()
        let event = new CustomEvent('editor:consolidate-selections', {
          bubbles: true,
          cancelable: true
        })
        event.abortKeyBinding = jasmine.createSpy('event.abortKeyBinding')
        componentNode.dispatchEvent(event)
        expect(editor.consolidateSelections).toHaveBeenCalled()
        expect(event.abortKeyBinding).toHaveBeenCalled()
      })
    })
  })

  describe('when changing the font', async function () {
    it('measures the default char, the korean char, the double width char and the half width char widths', async function () {
      expect(editor.getDefaultCharWidth()).toBeCloseTo(12, 0)
      component.setFontSize(10)
      await nextViewUpdatePromise()
      expect(editor.getDefaultCharWidth()).toBeCloseTo(6, 0)
      expect(editor.getKoreanCharWidth()).toBeCloseTo(9, 0)
      expect(editor.getDoubleWidthCharWidth()).toBe(10)
      expect(editor.getHalfWidthCharWidth()).toBe(5)
    })
  })

  describe('hiding and showing the editor', function () {
    describe('when the editor is hidden when it is mounted', function () {
      it('defers measurement and rendering until the editor becomes visible', function () {
        wrapperNode.remove()
        let hiddenParent = document.createElement('div')
        hiddenParent.style.display = 'none'
        contentNode.appendChild(hiddenParent)
        wrapperNode = new TextEditorElement()
        wrapperNode.tileSize = TILE_SIZE
        wrapperNode.initialize(editor, atom)
        hiddenParent.appendChild(wrapperNode)
        component = wrapperNode.component
        componentNode = component.getDomNode()
        expect(componentNode.querySelectorAll('.line').length).toBe(0)
        hiddenParent.style.display = 'block'
        atom.views.performDocumentPoll()
        expect(componentNode.querySelectorAll('.line').length).toBeGreaterThan(0)
      })
    })

    describe('when the lineHeight changes while the editor is hidden', function () {
      it('does not attempt to measure the lineHeightInPixels until the editor becomes visible again', function () {
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        let initialLineHeightInPixels = editor.getLineHeightInPixels()
        component.setLineHeight(2)
        expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels)
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels)
      })
    })

    describe('when the fontSize changes while the editor is hidden', function () {
      it('does not attempt to measure the lineHeightInPixels or defaultCharWidth until the editor becomes visible again', function () {
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        let initialLineHeightInPixels = editor.getLineHeightInPixels()
        let initialCharWidth = editor.getDefaultCharWidth()
        component.setFontSize(22)
        expect(editor.getLineHeightInPixels()).toBe(initialLineHeightInPixels)
        expect(editor.getDefaultCharWidth()).toBe(initialCharWidth)
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        expect(editor.getLineHeightInPixels()).not.toBe(initialLineHeightInPixels)
        expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth)
      })

      it('does not re-measure character widths until the editor is shown again', async function () {
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        component.setFontSize(22)
        editor.getBuffer().insert([0, 0], 'a')
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        editor.setCursorBufferPosition([0, Infinity])
        await nextViewUpdatePromise()
        let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left
        let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right
        expect(cursorLeft).toBeCloseTo(line0Right, 0)
      })
    })

    describe('when the fontFamily changes while the editor is hidden', function () {
      it('does not attempt to measure the defaultCharWidth until the editor becomes visible again', function () {
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        let initialLineHeightInPixels = editor.getLineHeightInPixels()
        let initialCharWidth = editor.getDefaultCharWidth()
        component.setFontFamily('serif')
        expect(editor.getDefaultCharWidth()).toBe(initialCharWidth)
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        expect(editor.getDefaultCharWidth()).not.toBe(initialCharWidth)
      })

      it('does not re-measure character widths until the editor is shown again', async function () {
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        component.setFontFamily('serif')
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        editor.setCursorBufferPosition([0, Infinity])
        await nextViewUpdatePromise()
        let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left
        let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right
        expect(cursorLeft).toBeCloseTo(line0Right, 0)
      })
    })

    describe('when stylesheets change while the editor is hidden', function () {
      afterEach(function () {
        atom.themes.removeStylesheet('test')
      })

      it('does not re-measure character widths until the editor is shown again', async function () {
        atom.config.set('editor.fontFamily', 'sans-serif')
        wrapperNode.style.display = 'none'
        component.checkForVisibilityChange()
        atom.themes.applyStylesheet('test', '.function.js {\n  font-weight: bold;\n}')
        wrapperNode.style.display = ''
        component.checkForVisibilityChange()
        editor.setCursorBufferPosition([0, Infinity])
        await nextViewUpdatePromise()
        let cursorLeft = componentNode.querySelector('.cursor').getBoundingClientRect().left
        let line0Right = componentNode.querySelector('.line > span:last-child').getBoundingClientRect().right
        expect(cursorLeft).toBeCloseTo(line0Right, 0)
      })
    })
  })

  describe('soft wrapping', function () {
    beforeEach(async function () {
      editor.setSoftWrapped(true)
      await nextViewUpdatePromise()
    })

    it('updates the wrap location when the editor is resized', async function () {
      let newHeight = 4 * editor.getLineHeightInPixels() + 'px'
      expect(parseInt(newHeight)).toBeLessThan(wrapperNode.offsetHeight)
      wrapperNode.style.height = newHeight
      await nextViewUpdatePromise()

      expect(componentNode.querySelectorAll('.line')).toHaveLength(7)
      let gutterWidth = componentNode.querySelector('.gutter').offsetWidth
      componentNode.style.width = gutterWidth + 14 * charWidth + wrapperNode.getVerticalScrollbarWidth() + 'px'
      atom.views.performDocumentPoll()
      await nextViewUpdatePromise()
      expect(componentNode.querySelector('.line').textContent).toBe('var quicksort ')
    })

    it('accounts for the scroll view\'s padding when determining the wrap location', async function () {
      let scrollViewNode = componentNode.querySelector('.scroll-view')
      scrollViewNode.style.paddingLeft = 20 + 'px'
      componentNode.style.width = 30 * charWidth + 'px'
      atom.views.performDocumentPoll()
      await nextViewUpdatePromise()
      expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = ')
    })
  })

  describe('default decorations', function () {
    it('applies .cursor-line decorations for line numbers overlapping selections', async function () {
      editor.setCursorScreenPosition([4, 4])
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(3, 'cursor-line')).toBe(false)
      expect(lineNumberHasClass(4, 'cursor-line')).toBe(true)
      expect(lineNumberHasClass(5, 'cursor-line')).toBe(false)
      editor.setSelectedScreenRange([[3, 4], [4, 4]])
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(3, 'cursor-line')).toBe(true)
      expect(lineNumberHasClass(4, 'cursor-line')).toBe(true)
      editor.setSelectedScreenRange([[3, 4], [4, 0]])
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(3, 'cursor-line')).toBe(true)
      expect(lineNumberHasClass(4, 'cursor-line')).toBe(false)
    })

    it('does not apply .cursor-line to the last line of a selection if it\'s empty', async function () {
      editor.setSelectedScreenRange([[3, 4], [5, 0]])
      await nextViewUpdatePromise()
      expect(lineNumberHasClass(3, 'cursor-line')).toBe(true)
      expect(lineNumberHasClass(4, 'cursor-line')).toBe(true)
      expect(lineNumberHasClass(5, 'cursor-line')).toBe(false)
    })

    it('applies .cursor-line decorations for lines containing the cursor in non-empty selections', async function () {
      editor.setCursorScreenPosition([4, 4])
      await nextViewUpdatePromise()

      expect(lineHasClass(3, 'cursor-line')).toBe(false)
      expect(lineHasClass(4, 'cursor-line')).toBe(true)
      expect(lineHasClass(5, 'cursor-line')).toBe(false)
      editor.setSelectedScreenRange([[3, 4], [4, 4]])
      await nextViewUpdatePromise()

      expect(lineHasClass(2, 'cursor-line')).toBe(false)
      expect(lineHasClass(3, 'cursor-line')).toBe(false)
      expect(lineHasClass(4, 'cursor-line')).toBe(false)
      expect(lineHasClass(5, 'cursor-line')).toBe(false)
    })

    it('applies .cursor-line-no-selection to line numbers for rows containing the cursor when the selection is empty', async function () {
      editor.setCursorScreenPosition([4, 4])
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(true)
      editor.setSelectedScreenRange([[3, 4], [4, 4]])
      await nextViewUpdatePromise()

      expect(lineNumberHasClass(4, 'cursor-line-no-selection')).toBe(false)
    })
  })

  describe('height', function () {
    describe('when the wrapper view has an explicit height', function () {
      it('does not assign a height on the component node', async function () {
        wrapperNode.style.height = '200px'
        component.measureDimensions()
        await nextViewUpdatePromise()
        expect(componentNode.style.height).toBe('')
      })
    })

    describe('when the wrapper view does not have an explicit height', function () {
      it('assigns a height on the component node based on the editor\'s content', function () {
        expect(wrapperNode.style.height).toBe('')
        expect(componentNode.style.height).toBe(editor.getScreenLineCount() * lineHeightInPixels + 'px')
      })
    })
  })

  describe('when the "mini" property is true', function () {
    beforeEach(async function () {
      editor.setMini(true)
      await nextViewUpdatePromise()
    })

    it('does not render the gutter', function () {
      expect(componentNode.querySelector('.gutter')).toBeNull()
    })

    it('adds the "mini" class to the wrapper view', function () {
      expect(wrapperNode.classList.contains('mini')).toBe(true)
    })

    it('does not have an opaque background on lines', function () {
      expect(component.linesComponent.getDomNode().getAttribute('style')).not.toContain('background-color')
    })

    it('does not render invisible characters', function () {
      atom.config.set('editor.invisibles', {
        eol: 'E'
      })
      atom.config.set('editor.showInvisibles', true)
      expect(component.lineNodeForScreenRow(0).textContent).toBe('var quicksort = function () {')
    })

    it('does not assign an explicit line-height on the editor contents', function () {
      expect(componentNode.style.lineHeight).toBe('')
    })

    it('does not apply cursor-line decorations', function () {
      expect(component.lineNodeForScreenRow(0).classList.contains('cursor-line')).toBe(false)
    })
  })

  describe('when placholderText is specified', function () {
    it('renders the placeholder text when the buffer is empty', async function () {
      editor.setPlaceholderText('Hello World')
      expect(componentNode.querySelector('.placeholder-text')).toBeNull()
      editor.setText('')
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.placeholder-text').textContent).toBe('Hello World')
      editor.setText('hey')
      await nextViewUpdatePromise()

      expect(componentNode.querySelector('.placeholder-text')).toBeNull()
    })
  })

  describe('grammar data attributes', function () {
    it('adds and updates the grammar data attribute based on the current grammar', function () {
      expect(wrapperNode.dataset.grammar).toBe('source js')
      editor.setGrammar(atom.grammars.nullGrammar)
      expect(wrapperNode.dataset.grammar).toBe('text plain null-grammar')
    })
  })

  describe('encoding data attributes', function () {
    it('adds and updates the encoding data attribute based on the current encoding', function () {
      expect(wrapperNode.dataset.encoding).toBe('utf8')
      editor.setEncoding('utf16le')
      expect(wrapperNode.dataset.encoding).toBe('utf16le')
    })
  })

  describe('detaching and reattaching the editor (regression)', function () {
    it('does not throw an exception', function () {
      wrapperNode.remove()
      jasmine.attachToDOM(wrapperNode)
      atom.commands.dispatch(wrapperNode, 'core:move-right')
      expect(editor.getCursorBufferPosition()).toEqual([0, 1])
    })
  })

  describe('scoped config settings', function () {
    let coffeeComponent, coffeeEditor

    beforeEach(async function () {
      await atom.packages.activatePackage('language-coffee-script')
      coffeeEditor = await atom.workspace.open('coffee.coffee', {autoIndent: false})
    })

    afterEach(function () {
      atom.packages.deactivatePackages()
      atom.packages.unloadPackages()
    })

    describe('soft wrap settings', function () {
      beforeEach(function () {
        atom.config.set('editor.softWrap', true, {
          scopeSelector: '.source.coffee'
        })
        atom.config.set('editor.preferredLineLength', 17, {
          scopeSelector: '.source.coffee'
        })
        atom.config.set('editor.softWrapAtPreferredLineLength', true, {
          scopeSelector: '.source.coffee'
        })
        editor.setDefaultCharWidth(1)
        editor.setEditorWidthInChars(20)
        coffeeEditor.setDefaultCharWidth(1)
        coffeeEditor.setEditorWidthInChars(20)
      })

      it('wraps lines when editor.softWrap is true for a matching scope', function () {
        expect(editor.lineTextForScreenRow(2)).toEqual('    if (items.length <= 1) return items;')
        expect(coffeeEditor.lineTextForScreenRow(3)).toEqual('    return items ')
      })

      it('updates the wrapped lines when editor.preferredLineLength changes', function () {
        atom.config.set('editor.preferredLineLength', 20, {
          scopeSelector: '.source.coffee'
        })
        expect(coffeeEditor.lineTextForScreenRow(2)).toEqual('    return items if ')
      })

      it('updates the wrapped lines when editor.softWrapAtPreferredLineLength changes', function () {
        atom.config.set('editor.softWrapAtPreferredLineLength', false, {
          scopeSelector: '.source.coffee'
        })
        expect(coffeeEditor.lineTextForScreenRow(2)).toEqual('    return items if ')
      })

      it('updates the wrapped lines when editor.softWrap changes', function () {
        atom.config.set('editor.softWrap', false, {
          scopeSelector: '.source.coffee'
        })
        expect(coffeeEditor.lineTextForScreenRow(2)).toEqual('    return items if items.length <= 1')
        atom.config.set('editor.softWrap', true, {
          scopeSelector: '.source.coffee'
        })
        expect(coffeeEditor.lineTextForScreenRow(3)).toEqual('    return items ')
      })

      it('updates the wrapped lines when the grammar changes', function () {
        editor.setGrammar(coffeeEditor.getGrammar())
        expect(editor.isSoftWrapped()).toBe(true)
        expect(editor.lineTextForScreenRow(0)).toEqual('var quicksort = ')
      })

      describe('::isSoftWrapped()', function () {
        it('returns the correct value based on the scoped settings', function () {
          expect(editor.isSoftWrapped()).toBe(false)
          expect(coffeeEditor.isSoftWrapped()).toBe(true)
        })
      })
    })

    describe('invisibles settings', function () {
      const jsInvisibles = {
        eol: 'J',
        space: 'A',
        tab: 'V',
        cr: 'A'
      }
      const coffeeInvisibles = {
        eol: 'C',
        space: 'O',
        tab: 'F',
        cr: 'E'
      }

      beforeEach(async function () {
        atom.config.set('editor.showInvisibles', true, {
          scopeSelector: '.source.js'
        })
        atom.config.set('editor.invisibles', jsInvisibles, {
          scopeSelector: '.source.js'
        })
        atom.config.set('editor.showInvisibles', false, {
          scopeSelector: '.source.coffee'
        })
        atom.config.set('editor.invisibles', coffeeInvisibles, {
          scopeSelector: '.source.coffee'
        })
        editor.setText(' a line with tabs\tand spaces \n')
        await nextViewUpdatePromise()
      })

      it('renders the invisibles when editor.showInvisibles is true for a given grammar', function () {
        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + jsInvisibles.space + 'a line with tabs' + jsInvisibles.tab + 'and spaces' + jsInvisibles.space + jsInvisibles.eol)
      })

      it('does not render the invisibles when editor.showInvisibles is false for a given grammar', async function () {
        editor.setGrammar(coffeeEditor.getGrammar())
        await nextViewUpdatePromise()
        expect(component.lineNodeForScreenRow(0).textContent).toBe(' a line with tabs and spaces ')
      })

      it('re-renders the invisibles when the invisible settings change', async function () {
        let jsGrammar = editor.getGrammar()
        editor.setGrammar(coffeeEditor.getGrammar())
        atom.config.set('editor.showInvisibles', true, {
          scopeSelector: '.source.coffee'
        })
        await nextViewUpdatePromise()

        let newInvisibles = {
          eol: 'N',
          space: 'E',
          tab: 'W',
          cr: 'I'
        }

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + coffeeInvisibles.space + 'a line with tabs' + coffeeInvisibles.tab + 'and spaces' + coffeeInvisibles.space + coffeeInvisibles.eol)
        atom.config.set('editor.invisibles', newInvisibles, {
          scopeSelector: '.source.coffee'
        })
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + newInvisibles.space + 'a line with tabs' + newInvisibles.tab + 'and spaces' + newInvisibles.space + newInvisibles.eol)
        editor.setGrammar(jsGrammar)
        await nextViewUpdatePromise()

        expect(component.lineNodeForScreenRow(0).textContent).toBe('' + jsInvisibles.space + 'a line with tabs' + jsInvisibles.tab + 'and spaces' + jsInvisibles.space + jsInvisibles.eol)
      })
    })

    describe('editor.showIndentGuide', function () {
      beforeEach(async function () {
        atom.config.set('editor.showIndentGuide', true, {
          scopeSelector: '.source.js'
        })
        atom.config.set('editor.showIndentGuide', false, {
          scopeSelector: '.source.coffee'
        })
        await nextViewUpdatePromise()
      })

      it('has an "indent-guide" class when scoped editor.showIndentGuide is true, but not when scoped editor.showIndentGuide is false', async function () {
        let line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))
        expect(line1LeafNodes[0].textContent).toBe('  ')
        expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe(false)
        editor.setGrammar(coffeeEditor.getGrammar())
        await nextViewUpdatePromise()

        line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))
        expect(line1LeafNodes[0].textContent).toBe('  ')
        expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe(false)
        expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe(false)
      })

      it('removes the "indent-guide" class when editor.showIndentGuide to false', async function () {
        let line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))

        expect(line1LeafNodes[0].textContent).toBe('  ')
        expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe(true)
        expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe(false)
        atom.config.set('editor.showIndentGuide', false, {
          scopeSelector: '.source.js'
        })
        await nextViewUpdatePromise()

        line1LeafNodes = getLeafNodes(component.lineNodeForScreenRow(1))
        expect(line1LeafNodes[0].textContent).toBe('  ')
        expect(line1LeafNodes[0].classList.contains('indent-guide')).toBe(false)
        expect(line1LeafNodes[1].classList.contains('indent-guide')).toBe(false)
      })
    })
  })

  describe('autoscroll', function () {
    beforeEach(async function () {
      editor.setVerticalScrollMargin(2)
      editor.setHorizontalScrollMargin(2)
      component.setLineHeight('10px')
      component.setFontSize(17)
      component.measureDimensions()
      await nextViewUpdatePromise()

      wrapperNode.setWidth(55)
      wrapperNode.setHeight(55)
      component.measureDimensions()
      await nextViewUpdatePromise()

      component.presenter.setHorizontalScrollbarHeight(0)
      component.presenter.setVerticalScrollbarWidth(0)
      await nextViewUpdatePromise()
    })

    describe('when selecting buffer ranges', function () {
      it('autoscrolls the selection if it is last unless the "autoscroll" option is false', async function () {
        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.setSelectedBufferRange([[5, 6], [6, 8]])
        await nextViewUpdatePromise()

        let right = wrapperNode.pixelPositionForBufferPosition([6, 8 + editor.getHorizontalScrollMargin()]).left
        expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10)
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0)
        editor.setSelectedBufferRange([[0, 0], [0, 0]])
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(wrapperNode.getScrollLeft()).toBe(0)
        editor.setSelectedBufferRange([[6, 6], [6, 8]])
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10)
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0)
      })
    })

    describe('when adding selections for buffer ranges', function () {
      it('autoscrolls to the added selection if needed', async function () {
        editor.addSelectionForBufferRange([[8, 10], [8, 15]])
        await nextViewUpdatePromise()

        let right = wrapperNode.pixelPositionForBufferPosition([8, 15]).left
        expect(wrapperNode.getScrollBottom()).toBe((9 * 10) + (2 * 10))
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right + 2 * 10, 0)
      })
    })

    describe('when selecting lines containing cursors', function () {
      it('autoscrolls to the selection', async function () {
        editor.setCursorScreenPosition([5, 6])
        await nextViewUpdatePromise()

        wrapperNode.scrollToTop()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.selectLinesContainingCursors()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe((7 + editor.getVerticalScrollMargin()) * 10)
      })
    })

    describe('when inserting text', function () {
      describe('when there are multiple empty selections on different lines', function () {
        it('autoscrolls to the last cursor', async function () {
          editor.setCursorScreenPosition([1, 2], {
            autoscroll: false
          })
          await nextViewUpdatePromise()

          editor.addCursorAtScreenPosition([10, 4], {
            autoscroll: false
          })
          await nextViewUpdatePromise()

          expect(wrapperNode.getScrollTop()).toBe(0)
          editor.insertText('a')
          await nextViewUpdatePromise()

          expect(wrapperNode.getScrollTop()).toBe(75)
        })
      })
    })

    describe('when scrolled to cursor position', function () {
      it('scrolls the last cursor into view, centering around the cursor if possible and the "center" option is not false', async function () {
        editor.setCursorScreenPosition([8, 8], {
          autoscroll: false
        })
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(wrapperNode.getScrollLeft()).toBe(0)
        editor.scrollToCursorPosition()
        await nextViewUpdatePromise()

        let right = wrapperNode.pixelPositionForScreenPosition([8, 9 + editor.getHorizontalScrollMargin()]).left
        expect(wrapperNode.getScrollTop()).toBe((8.8 * 10) - 30)
        expect(wrapperNode.getScrollBottom()).toBe((8.3 * 10) + 30)
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0)
        wrapperNode.setScrollTop(0)
        editor.scrollToCursorPosition({
          center: false
        })
        expect(wrapperNode.getScrollTop()).toBe((7.8 - editor.getVerticalScrollMargin()) * 10)
        expect(wrapperNode.getScrollBottom()).toBe((9.3 + editor.getVerticalScrollMargin()) * 10)
      })
    })

    describe('moving cursors', function () {
      it('scrolls down when the last cursor gets closer than ::verticalScrollMargin to the bottom of the editor', async function () {
        expect(wrapperNode.getScrollTop()).toBe(0)
        expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10)
        editor.setCursorScreenPosition([2, 0])
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(5.5 * 10)
        editor.moveDown()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(6 * 10)
        editor.moveDown()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(7 * 10)
      })

      it('scrolls up when the last cursor gets closer than ::verticalScrollMargin to the top of the editor', async function () {
        editor.setCursorScreenPosition([11, 0])
        await nextViewUpdatePromise()

        wrapperNode.setScrollBottom(wrapperNode.getScrollHeight())
        await nextViewUpdatePromise()

        editor.moveUp()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(wrapperNode.getScrollHeight())
        editor.moveUp()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(7 * 10)
        editor.moveUp()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(6 * 10)
      })

      it('scrolls right when the last cursor gets closer than ::horizontalScrollMargin to the right of the editor', async function () {
        expect(wrapperNode.getScrollLeft()).toBe(0)
        expect(wrapperNode.getScrollRight()).toBe(5.5 * 10)
        editor.setCursorScreenPosition([0, 2])
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollRight()).toBe(5.5 * 10)
        editor.moveRight()
        await nextViewUpdatePromise()

        let margin = component.presenter.getHorizontalScrollMarginInPixels()
        let right = wrapperNode.pixelPositionForScreenPosition([0, 4]).left + margin
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0)
        editor.moveRight()
        await nextViewUpdatePromise()

        right = wrapperNode.pixelPositionForScreenPosition([0, 5]).left + margin
        expect(wrapperNode.getScrollRight()).toBeCloseTo(right, 0)
      })

      it('scrolls left when the last cursor gets closer than ::horizontalScrollMargin to the left of the editor', async function () {
        wrapperNode.setScrollRight(wrapperNode.getScrollWidth())
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollRight()).toBe(wrapperNode.getScrollWidth())
        editor.setCursorScreenPosition([6, 62], {
          autoscroll: false
        })
        await nextViewUpdatePromise()

        editor.moveLeft()
        await nextViewUpdatePromise()

        let margin = component.presenter.getHorizontalScrollMarginInPixels()
        let left = wrapperNode.pixelPositionForScreenPosition([6, 61]).left - margin
        expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0)
        editor.moveLeft()
        await nextViewUpdatePromise()

        left = wrapperNode.pixelPositionForScreenPosition([6, 60]).left - margin
        expect(wrapperNode.getScrollLeft()).toBeCloseTo(left, 0)
      })

      it('scrolls down when inserting lines makes the document longer than the editor\'s height', async function () {
        editor.setCursorScreenPosition([13, Infinity])
        editor.insertNewline()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(14 * 10)
        editor.insertNewline()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollBottom()).toBe(15 * 10)
      })

      it('autoscrolls to the cursor when it moves due to undo', async function () {
        editor.insertText('abc')
        wrapperNode.setScrollTop(Infinity)
        await nextViewUpdatePromise()

        editor.undo()
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
      })

      it('does not scroll when the cursor moves into the visible area', async function () {
        editor.setCursorBufferPosition([0, 0])
        await nextViewUpdatePromise()

        wrapperNode.setScrollTop(40)
        await nextViewUpdatePromise()

        editor.setCursorBufferPosition([6, 0])
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(40)
      })

      it('honors the autoscroll option on cursor and selection manipulation methods', async function () {
        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.addCursorAtScreenPosition([11, 11], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.addCursorAtBufferPosition([11, 11], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.setCursorScreenPosition([11, 11], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.setCursorBufferPosition([11, 11], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.addSelectionForBufferRange([[11, 11], [11, 11]], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.addSelectionForScreenRange([[11, 11], [11, 12]], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.setSelectedBufferRange([[11, 0], [11, 1]], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.setSelectedScreenRange([[11, 0], [11, 6]], {autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.clearSelections({autoscroll: false})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.addSelectionForScreenRange([[0, 0], [0, 4]])
        await nextViewUpdatePromise()

        editor.getCursors()[0].setScreenPosition([11, 11], {autoscroll: true})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBeGreaterThan(0)
        editor.getCursors()[0].setBufferPosition([0, 0], {autoscroll: true})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
        editor.getSelections()[0].setScreenRange([[11, 0], [11, 4]], {autoscroll: true})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBeGreaterThan(0)
        editor.getSelections()[0].setBufferRange([[0, 0], [0, 4]], {autoscroll: true})
        await nextViewUpdatePromise()

        expect(wrapperNode.getScrollTop()).toBe(0)
      })
    })
  })

  describe('::getVisibleRowRange()', function () {
    beforeEach(async function () {
      wrapperNode.style.height = lineHeightInPixels * 8 + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()
    })

    it('returns the first and the last visible rows', async function () {
      component.setScrollTop(0)
      await nextViewUpdatePromise()
      expect(component.getVisibleRowRange()).toEqual([0, 9])
    })

    it('ends at last buffer row even if there\'s more space available', async function () {
      wrapperNode.style.height = lineHeightInPixels * 13 + 'px'
      component.measureDimensions()
      await nextViewUpdatePromise()

      component.setScrollTop(60)
      await nextViewUpdatePromise()

      expect(component.getVisibleRowRange()).toEqual([0, 13])
    })
  })

  describe('middle mouse paste on Linux', function () {
    let originalPlatform

    beforeEach(function () {
      originalPlatform = process.platform
      Object.defineProperty(process, 'platform', {
        value: 'linux'
      })
    })

    afterEach(function () {
      Object.defineProperty(process, 'platform', {
        value: originalPlatform
      })
    })

    it('pastes the previously selected text at the clicked location', async function () {
      let clipboardWrittenTo = false
      spyOn(require('ipc'), 'send').andCallFake(function (eventName, selectedText) {
        if (eventName === 'write-text-to-selection-clipboard') {
          require('../src/safe-clipboard').writeText(selectedText, 'selection')
          clipboardWrittenTo = true
        }
      })
      atom.clipboard.write('')
      component.trackSelectionClipboard()
      editor.setSelectedBufferRange([[1, 6], [1, 10]])

      await conditionPromise(function () {
        return clipboardWrittenTo
      })

      componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mousedown', clientCoordinatesForScreenPosition([10, 0]), {
        button: 1
      }))
      componentNode.querySelector('.scroll-view').dispatchEvent(buildMouseEvent('mouseup', clientCoordinatesForScreenPosition([10, 0]), {
        which: 2
      }))
      expect(atom.clipboard.read()).toBe('sort')
      expect(editor.lineTextForBufferRow(10)).toBe('sort')
    })
  })

  function buildMouseEvent (type, ...propertiesObjects) {
    let properties = extend({
      bubbles: true,
      cancelable: true
    }, ...propertiesObjects)

    if (properties.detail == null) {
      properties.detail = 1
    }

    let event = new MouseEvent(type, properties)
    if (properties.which != null) {
      Object.defineProperty(event, 'which', {
        get: function () {
          return properties.which
        }
      })
    }
    if (properties.target != null) {
      Object.defineProperty(event, 'target', {
        get: function () {
          return properties.target
        }
      })
      Object.defineProperty(event, 'srcObject', {
        get: function () {
          return properties.target
        }
      })
    }
    return event
  }

  function clientCoordinatesForScreenPosition (screenPosition) {
    let clientX, clientY, positionOffset, scrollViewClientRect
    positionOffset = wrapperNode.pixelPositionForScreenPosition(screenPosition)
    scrollViewClientRect = componentNode.querySelector('.scroll-view').getBoundingClientRect()
    clientX = scrollViewClientRect.left + positionOffset.left - wrapperNode.getScrollLeft()
    clientY = scrollViewClientRect.top + positionOffset.top - wrapperNode.getScrollTop()
    return {
      clientX: clientX,
      clientY: clientY
    }
  }

  function clientCoordinatesForScreenRowInGutter (screenRow) {
    let clientX, clientY, gutterClientRect, positionOffset
    positionOffset = wrapperNode.pixelPositionForScreenPosition([screenRow, Infinity])
    gutterClientRect = componentNode.querySelector('.gutter').getBoundingClientRect()
    clientX = gutterClientRect.left + positionOffset.left - wrapperNode.getScrollLeft()
    clientY = gutterClientRect.top + positionOffset.top - wrapperNode.getScrollTop()
    return {
      clientX: clientX,
      clientY: clientY
    }
  }

  function lineAndLineNumberHaveClass (screenRow, klass) {
    return lineHasClass(screenRow, klass) && lineNumberHasClass(screenRow, klass)
  }

  function lineNumberHasClass (screenRow, klass) {
    return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass)
  }

  function lineNumberForBufferRowHasClass (bufferRow, klass) {
    let screenRow
    screenRow = editor.displayBuffer.screenRowForBufferRow(bufferRow)
    return component.lineNumberNodeForScreenRow(screenRow).classList.contains(klass)
  }

  function lineHasClass (screenRow, klass) {
    return component.lineNodeForScreenRow(screenRow).classList.contains(klass)
  }

  function getLeafNodes (node) {
    if (node.children.length > 0) {
      return flatten(toArray(node.children).map(getLeafNodes))
    } else {
      return [node]
    }
  }

  function conditionPromise (condition)  {
    let timeoutError = new Error("Timed out waiting on condition")
    Error.captureStackTrace(timeoutError, conditionPromise)

    return new Promise(function (resolve, reject) {
      let interval = window.setInterval(function () {
        if (condition()) {
          window.clearInterval(interval)
          window.clearTimeout(timeout)
          resolve()
        }
      }, 100)
      let timeout = window.setTimeout(function () {
        window.clearInterval(interval)
        reject(timeoutError)
      }, 5000)
    })
  }

  function timeoutPromise (timeout) {
    return new Promise(function (resolve) {
      window.setTimeout(resolve, timeout)
    })
  }

  function nextAnimationFramePromise () {
    return new Promise(function (resolve) {
      window.requestAnimationFrame(resolve)
    })
  }

  function nextViewUpdatePromise () {
    let timeoutError = new Error('Timed out waiting on a view update.')
    Error.captureStackTrace(timeoutError, nextViewUpdatePromise)

    return new Promise(function (resolve, reject) {
      let nextUpdatePromise = atom.views.getNextUpdatePromise()
      nextUpdatePromise.then(function (ts) {
        window.clearTimeout(timeout)
        resolve(ts)
      })
      let timeout = window.setTimeout(function () {
        timeoutError.message += ' Frame pending? ' + atom.views.animationFrameRequest + ' Same next update promise pending? ' + (nextUpdatePromise === atom.views.nextUpdatePromise)
        reject(timeoutError)
      }, 30000)
    })
  }

  function decorationsUpdatedPromise(editor) {
    return new Promise(function (resolve) {
      let disposable = editor.onDidUpdateDecorations(function () {
        disposable.dispose()
        resolve()
      })
    })
  }
})
