Developing a legal IDE based on Atom
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

604 lines
19 KiB

  1. /*
  2. * decaffeinate suggestions:
  3. * DS102: Remove unnecessary code created because of implicit returns
  4. * DS207: Consider shorter variations of null checks
  5. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  6. */
  7. const path = require('path')
  8. const fs = require('fs-plus')
  9. const temp = require('temp').track()
  10. const url = require('url')
  11. const { TextEditor } = require('atom')
  12. const MarkdownPreviewView = require('../lib/markdown-preview-view')
  13. const TextMateLanguageMode = new TextEditor().getBuffer().getLanguageMode()
  14. .constructor
  15. describe('MarkdownPreviewView', function () {
  16. let preview = null
  17. beforeEach(function () {
  18. // Makes _.debounce work
  19. jasmine.useRealClock()
  20. jasmine.unspy(TextMateLanguageMode.prototype, 'tokenizeInBackground')
  21. spyOn(atom.packages, 'hasActivatedInitialPackages').andReturn(true)
  22. const filePath = atom.project
  23. .getDirectories()[0]
  24. .resolve('subdir/file.markdown')
  25. preview = new MarkdownPreviewView({ filePath })
  26. jasmine.attachToDOM(preview.element)
  27. waitsForPromise(() => atom.packages.activatePackage('language-ruby'))
  28. waitsForPromise(() => atom.packages.activatePackage('language-javascript'))
  29. waitsForPromise(() => atom.packages.activatePackage('markdown-preview'))
  30. })
  31. afterEach(() => preview.destroy())
  32. describe('::constructor', function () {
  33. it('shows a loading spinner and renders the markdown', function () {
  34. preview.showLoading()
  35. expect(preview.element.querySelector('.markdown-spinner')).toBeDefined()
  36. waitsForPromise(() => preview.renderMarkdown())
  37. runs(() => expect(preview.element.querySelector('.emoji')).toBeDefined())
  38. })
  39. it('shows an error message when there is an error', function () {
  40. preview.showError('Not a real file')
  41. expect(preview.element.textContent).toMatch('Failed')
  42. })
  43. it('rerenders the markdown and the scrollTop stays the same', function () {
  44. waitsForPromise(() => preview.renderMarkdown())
  45. runs(function () {
  46. preview.element.style.maxHeight = '10px'
  47. preview.element.scrollTop = 24
  48. expect(preview.element.scrollTop).toBe(24)
  49. })
  50. waitsForPromise(() => preview.renderMarkdown())
  51. runs(() => expect(preview.element.scrollTop).toBe(24))
  52. })
  53. })
  54. describe('serialization', function () {
  55. let newPreview = null
  56. afterEach(function () {
  57. if (newPreview) {
  58. newPreview.destroy()
  59. }
  60. })
  61. it('recreates the preview when serialized/deserialized', function () {
  62. newPreview = atom.deserializers.deserialize(preview.serialize())
  63. jasmine.attachToDOM(newPreview.element)
  64. expect(newPreview.getPath()).toBe(preview.getPath())
  65. })
  66. it('does not recreate a preview when the file no longer exists', function () {
  67. const filePath = path.join(temp.mkdirSync('markdown-preview-'), 'foo.md')
  68. fs.writeFileSync(filePath, '# Hi')
  69. preview.destroy()
  70. preview = new MarkdownPreviewView({ filePath })
  71. const serialized = preview.serialize()
  72. fs.removeSync(filePath)
  73. newPreview = atom.deserializers.deserialize(serialized)
  74. expect(newPreview).toBeUndefined()
  75. })
  76. it('serializes the editor id when opened for an editor', function () {
  77. preview.destroy()
  78. waitsForPromise(() => atom.workspace.open('new.markdown'))
  79. runs(function () {
  80. preview = new MarkdownPreviewView({
  81. editorId: atom.workspace.getActiveTextEditor().id
  82. })
  83. jasmine.attachToDOM(preview.element)
  84. expect(preview.getPath()).toBe(
  85. atom.workspace.getActiveTextEditor().getPath()
  86. )
  87. newPreview = atom.deserializers.deserialize(preview.serialize())
  88. jasmine.attachToDOM(newPreview.element)
  89. expect(newPreview.getPath()).toBe(preview.getPath())
  90. })
  91. })
  92. })
  93. describe('code block conversion to atom-text-editor tags', function () {
  94. beforeEach(function () {
  95. waitsForPromise(() => preview.renderMarkdown())
  96. })
  97. it('removes line decorations on rendered code blocks', function () {
  98. const editor = preview.element.querySelector(
  99. "atom-text-editor[data-grammar='text plain null-grammar']"
  100. )
  101. const decorations = editor
  102. .getModel()
  103. .getDecorations({ class: 'cursor-line', type: 'line' })
  104. expect(decorations.length).toBe(0)
  105. })
  106. it('sets the editors as read-only', function () {
  107. preview.element
  108. .querySelectorAll('atom-text-editor')
  109. .forEach(editorElement =>
  110. expect(editorElement.getAttribute('tabindex')).toBeNull()
  111. )
  112. })
  113. describe("when the code block's fence name has a matching grammar", function () {
  114. it('assigns the grammar on the atom-text-editor', function () {
  115. const rubyEditor = preview.element.querySelector(
  116. "atom-text-editor[data-grammar='source ruby']"
  117. )
  118. expect(rubyEditor.getModel().getText()).toBe(`\
  119. def func
  120. x = 1
  121. end\
  122. `)
  123. // nested in a list item
  124. const jsEditor = preview.element.querySelector(
  125. "atom-text-editor[data-grammar='source js']"
  126. )
  127. expect(jsEditor.getModel().getText()).toBe(`\
  128. if a === 3 {
  129. b = 5
  130. }\
  131. `)
  132. })
  133. })
  134. describe("when the code block's fence name doesn't have a matching grammar", function () {
  135. it('does not assign a specific grammar', function () {
  136. const plainEditor = preview.element.querySelector(
  137. "atom-text-editor[data-grammar='text plain null-grammar']"
  138. )
  139. expect(plainEditor.getModel().getText()).toBe(`\
  140. function f(x) {
  141. return x++;
  142. }\
  143. `)
  144. })
  145. })
  146. describe('when an editor cannot find the grammar that is later loaded', function () {
  147. it('updates the editor grammar', function () {
  148. let renderSpy = null
  149. if (typeof atom.grammars.onDidRemoveGrammar !== 'function') {
  150. // TODO: Remove once atom.grammars.onDidRemoveGrammar is released
  151. waitsForPromise(() => atom.packages.activatePackage('language-gfm'))
  152. }
  153. runs(
  154. () => (renderSpy = spyOn(preview, 'renderMarkdown').andCallThrough())
  155. )
  156. waitsForPromise(() => atom.packages.deactivatePackage('language-ruby'))
  157. waitsFor(
  158. 'renderMarkdown to be called after disabling a language',
  159. () => renderSpy.callCount === 1
  160. )
  161. runs(function () {
  162. const rubyEditor = preview.element.querySelector(
  163. "atom-text-editor[data-grammar='source ruby']"
  164. )
  165. expect(rubyEditor).toBeNull()
  166. })
  167. waitsForPromise(() => atom.packages.activatePackage('language-ruby'))
  168. waitsFor(
  169. 'renderMarkdown to be called after enabling a language',
  170. () => renderSpy.callCount === 2
  171. )
  172. runs(function () {
  173. const rubyEditor = preview.element.querySelector(
  174. "atom-text-editor[data-grammar='source ruby']"
  175. )
  176. expect(rubyEditor.getModel().getText()).toBe(`\
  177. def func
  178. x = 1
  179. end\
  180. `)
  181. })
  182. })
  183. })
  184. })
  185. describe('image resolving', function () {
  186. beforeEach(function () {
  187. waitsForPromise(() => preview.renderMarkdown())
  188. })
  189. describe('when the image uses a relative path', function () {
  190. it('resolves to a path relative to the file', function () {
  191. const image = preview.element.querySelector('img[alt=Image1]')
  192. expect(image.getAttribute('src')).toBe(
  193. atom.project.getDirectories()[0].resolve('subdir/image1.png')
  194. )
  195. })
  196. })
  197. describe('when the image uses an absolute path that does not exist', function () {
  198. it('resolves to a path relative to the project root', function () {
  199. const image = preview.element.querySelector('img[alt=Image2]')
  200. expect(image.src).toMatch(
  201. url.parse(atom.project.getDirectories()[0].resolve('tmp/image2.png'))
  202. )
  203. })
  204. })
  205. describe('when the image uses an absolute path that exists', function () {
  206. it("doesn't change the URL when allowUnsafeProtocols is true", function () {
  207. preview.destroy()
  208. atom.config.set('markdown-preview.allowUnsafeProtocols', true)
  209. const filePath = path.join(temp.mkdirSync('atom'), 'foo.md')
  210. fs.writeFileSync(filePath, `![absolute](${filePath})`)
  211. preview = new MarkdownPreviewView({ filePath })
  212. jasmine.attachToDOM(preview.element)
  213. waitsForPromise(() => preview.renderMarkdown())
  214. runs(() =>
  215. expect(
  216. preview.element.querySelector('img[alt=absolute]').src
  217. ).toMatch(url.parse(filePath))
  218. )
  219. })
  220. })
  221. it('removes the URL when allowUnsafeProtocols is false', function () {
  222. preview.destroy()
  223. atom.config.set('markdown-preview.allowUnsafeProtocols', false)
  224. const filePath = path.join(temp.mkdirSync('atom'), 'foo.md')
  225. fs.writeFileSync(filePath, `![absolute](${filePath})`)
  226. preview = new MarkdownPreviewView({ filePath })
  227. jasmine.attachToDOM(preview.element)
  228. waitsForPromise(() => preview.renderMarkdown())
  229. runs(() =>
  230. expect(preview.element.querySelector('img[alt=absolute]').src).toMatch(
  231. ''
  232. )
  233. )
  234. })
  235. describe('when the image uses a web URL', function () {
  236. it("doesn't change the URL", function () {
  237. const image = preview.element.querySelector('img[alt=Image3]')
  238. expect(image.src).toBe('http://github.com/image3.png')
  239. })
  240. })
  241. })
  242. describe('gfm newlines', function () {
  243. describe('when gfm newlines are not enabled', function () {
  244. it('creates a single paragraph with <br>', function () {
  245. atom.config.set('markdown-preview.breakOnSingleNewline', false)
  246. waitsForPromise(() => preview.renderMarkdown())
  247. runs(() =>
  248. expect(
  249. preview.element.querySelectorAll('p:last-child br').length
  250. ).toBe(0)
  251. )
  252. })
  253. })
  254. describe('when gfm newlines are enabled', function () {
  255. it('creates a single paragraph with no <br>', function () {
  256. atom.config.set('markdown-preview.breakOnSingleNewline', true)
  257. waitsForPromise(() => preview.renderMarkdown())
  258. runs(() =>
  259. expect(
  260. preview.element.querySelectorAll('p:last-child br').length
  261. ).toBe(1)
  262. )
  263. })
  264. })
  265. })
  266. describe('yaml front matter', function () {
  267. it('creates a table with the YAML variables', function () {
  268. atom.config.set('markdown-preview.breakOnSingleNewline', true)
  269. waitsForPromise(() => preview.renderMarkdown())
  270. runs(() => {
  271. expect(
  272. [...preview.element.querySelectorAll('table th')].map(
  273. el => el.textContent
  274. )
  275. ).toEqual(['variable1', 'array'])
  276. expect(
  277. [...preview.element.querySelectorAll('table td')].map(
  278. el => el.textContent
  279. )
  280. ).toEqual(['value1', 'foo,bar'])
  281. })
  282. })
  283. })
  284. describe('text selections', function () {
  285. it('adds the `has-selection` class to the preview depending on if there is a text selection', function () {
  286. expect(preview.element.classList.contains('has-selection')).toBe(false)
  287. const selection = window.getSelection()
  288. selection.removeAllRanges()
  289. selection.selectAllChildren(document.querySelector('atom-text-editor'))
  290. waitsFor(
  291. () => preview.element.classList.contains('has-selection') === true
  292. )
  293. runs(() => selection.removeAllRanges())
  294. waitsFor(
  295. () => preview.element.classList.contains('has-selection') === false
  296. )
  297. })
  298. })
  299. describe('when core:save-as is triggered', function () {
  300. beforeEach(function () {
  301. preview.destroy()
  302. const filePath = atom.project
  303. .getDirectories()[0]
  304. .resolve('subdir/code-block.md')
  305. preview = new MarkdownPreviewView({ filePath })
  306. // Add to workspace for core:save-as command to be propagated up to the workspace
  307. waitsForPromise(() => atom.workspace.open(preview))
  308. runs(() => jasmine.attachToDOM(atom.views.getView(atom.workspace)))
  309. })
  310. it('saves the rendered HTML and opens it', function () {
  311. const outputPath = fs.realpathSync(temp.mkdirSync()) + 'output.html'
  312. const createRule = (selector, css) => ({
  313. selectorText: selector,
  314. cssText: `${selector} ${css}`
  315. })
  316. const markdownPreviewStyles = [
  317. {
  318. rules: [createRule('.markdown-preview', '{ color: orange; }')]
  319. },
  320. {
  321. rules: [
  322. createRule('.not-included', '{ color: green; }'),
  323. createRule('.markdown-preview :host', '{ color: purple; }')
  324. ]
  325. }
  326. ]
  327. const atomTextEditorStyles = [
  328. 'atom-text-editor .line { color: brown; }\natom-text-editor .number { color: cyan; }',
  329. 'atom-text-editor :host .something { color: black; }',
  330. 'atom-text-editor .hr { background: url(atom://markdown-preview/assets/hr.png); }'
  331. ]
  332. waitsForPromise(() => preview.renderMarkdown())
  333. runs(() => {
  334. expect(fs.isFileSync(outputPath)).toBe(false)
  335. spyOn(preview, 'getSaveDialogOptions').andReturn({
  336. defaultPath: outputPath
  337. })
  338. spyOn(atom.applicationDelegate, 'showSaveDialog').andCallFake(function (
  339. options,
  340. callback
  341. ) {
  342. if (typeof callback === 'function') {
  343. callback(options.defaultPath)
  344. }
  345. // TODO: When https://github.com/atom/atom/pull/16245 lands remove the return
  346. // and the existence check on the callback
  347. return options.defaultPath
  348. })
  349. spyOn(preview, 'getDocumentStyleSheets').andReturn(
  350. markdownPreviewStyles
  351. )
  352. spyOn(preview, 'getTextEditorStyles').andReturn(atomTextEditorStyles)
  353. })
  354. waitsForPromise(() =>
  355. atom.commands.dispatch(preview.element, 'core:save-as')
  356. )
  357. waitsFor(() => {
  358. const activeEditor = atom.workspace.getActiveTextEditor()
  359. return activeEditor && activeEditor.getPath() === outputPath
  360. })
  361. runs(() => {
  362. const element = document.createElement('div')
  363. element.innerHTML = fs.readFileSync(outputPath)
  364. expect(element.querySelector('h1').innerText).toBe('Code Block')
  365. expect(
  366. element.querySelector(
  367. '.line .syntax--source.syntax--js .syntax--constant.syntax--numeric'
  368. ).innerText
  369. ).toBe('3')
  370. expect(
  371. element.querySelector(
  372. '.line .syntax--source.syntax--js .syntax--keyword.syntax--control'
  373. ).innerText
  374. ).toBe('if')
  375. expect(
  376. element.querySelector(
  377. '.line .syntax--source.syntax--js .syntax--constant.syntax--numeric'
  378. ).innerText
  379. ).toBe('3')
  380. })
  381. })
  382. describe('text editor style extraction', function () {
  383. let [extractedStyles] = []
  384. const textEditorStyle = '.editor-style .extraction-test { color: blue; }'
  385. const unrelatedStyle = '.something else { color: red; }'
  386. beforeEach(function () {
  387. atom.styles.addStyleSheet(textEditorStyle, {
  388. context: 'atom-text-editor'
  389. })
  390. atom.styles.addStyleSheet(unrelatedStyle, {
  391. context: 'unrelated-context'
  392. })
  393. return (extractedStyles = preview.getTextEditorStyles())
  394. })
  395. it('returns an array containing atom-text-editor css style strings', function () {
  396. expect(extractedStyles.indexOf(textEditorStyle)).toBeGreaterThan(-1)
  397. })
  398. it('does not return other styles', function () {
  399. expect(extractedStyles.indexOf(unrelatedStyle)).toBe(-1)
  400. })
  401. })
  402. })
  403. describe('when core:copy is triggered', function () {
  404. beforeEach(function () {
  405. preview.destroy()
  406. preview.element.remove()
  407. const filePath = atom.project
  408. .getDirectories()[0]
  409. .resolve('subdir/code-block.md')
  410. preview = new MarkdownPreviewView({ filePath })
  411. jasmine.attachToDOM(preview.element)
  412. waitsForPromise(() => preview.renderMarkdown())
  413. })
  414. describe('when there is no text selected', function () {
  415. it('copies the rendered HTML of the entire Markdown document to the clipboard', function () {
  416. expect(atom.clipboard.read()).toBe('initial clipboard content')
  417. waitsForPromise(() =>
  418. atom.commands.dispatch(preview.element, 'core:copy')
  419. )
  420. runs(() => {
  421. const element = document.createElement('div')
  422. element.innerHTML = atom.clipboard.read()
  423. expect(element.querySelector('h1').innerText).toBe('Code Block')
  424. expect(
  425. element.querySelector(
  426. '.line .syntax--source.syntax--js .syntax--constant.syntax--numeric'
  427. ).innerText
  428. ).toBe('3')
  429. expect(
  430. element.querySelector(
  431. '.line .syntax--source.syntax--js .syntax--keyword.syntax--control'
  432. ).innerText
  433. ).toBe('if')
  434. expect(
  435. element.querySelector(
  436. '.line .syntax--source.syntax--js .syntax--constant.syntax--numeric'
  437. ).innerText
  438. ).toBe('3')
  439. })
  440. })
  441. })
  442. describe('when there is a text selection', function () {
  443. it('directly copies the selection to the clipboard', function () {
  444. const selection = window.getSelection()
  445. selection.removeAllRanges()
  446. const range = document.createRange()
  447. range.setStart(document.querySelector('atom-text-editor'), 0)
  448. range.setEnd(document.querySelector('p').firstChild, 3)
  449. selection.addRange(range)
  450. atom.commands.dispatch(preview.element, 'core:copy')
  451. const clipboardText = atom.clipboard.read()
  452. expect(clipboardText).toBe(`\
  453. if a === 3 {
  454. b = 5
  455. }
  456. enc\
  457. `)
  458. })
  459. })
  460. })
  461. describe('when markdown-preview:select-all is triggered', function () {
  462. it('selects the entire Markdown preview', function () {
  463. const filePath = atom.project
  464. .getDirectories()[0]
  465. .resolve('subdir/code-block.md')
  466. const preview2 = new MarkdownPreviewView({ filePath })
  467. jasmine.attachToDOM(preview2.element)
  468. waitsForPromise(() => preview.renderMarkdown())
  469. runs(function () {
  470. atom.commands.dispatch(preview.element, 'markdown-preview:select-all')
  471. const { commonAncestorContainer } = window.getSelection().getRangeAt(0)
  472. expect(commonAncestorContainer).toEqual(preview.element)
  473. })
  474. waitsForPromise(() => preview2.renderMarkdown())
  475. runs(() => {
  476. atom.commands.dispatch(preview2.element, 'markdown-preview:select-all')
  477. const selection = window.getSelection()
  478. expect(selection.rangeCount).toBe(1)
  479. const { commonAncestorContainer } = selection.getRangeAt(0)
  480. expect(commonAncestorContainer).toEqual(preview2.element)
  481. })
  482. })
  483. })
  484. describe('when markdown-preview:zoom-in or markdown-preview:zoom-out are triggered', function () {
  485. it('increases or decreases the zoom level of the markdown preview element', function () {
  486. jasmine.attachToDOM(preview.element)
  487. waitsForPromise(() => preview.renderMarkdown())
  488. runs(function () {
  489. const originalZoomLevel = getComputedStyle(preview.element).zoom
  490. atom.commands.dispatch(preview.element, 'markdown-preview:zoom-in')
  491. expect(getComputedStyle(preview.element).zoom).toBeGreaterThan(
  492. originalZoomLevel
  493. )
  494. atom.commands.dispatch(preview.element, 'markdown-preview:zoom-out')
  495. expect(getComputedStyle(preview.element).zoom).toBe(originalZoomLevel)
  496. })
  497. })
  498. })
  499. })