pdf-preview.test.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import fetchMock from 'fetch-mock'
  4. import { screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
  5. import PdfPreview from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview'
  6. import { renderWithEditorContext } from '../../../helpers/render-with-context'
  7. const outputFiles = [
  8. {
  9. path: 'output.pdf',
  10. build: '123',
  11. url: '/build/output.pdf', // TODO: PDF URL to render
  12. type: 'pdf',
  13. },
  14. {
  15. path: 'output.bbl',
  16. build: '123',
  17. url: '/build/output.bbl',
  18. type: 'bbl',
  19. },
  20. {
  21. path: 'output.bib',
  22. build: '123',
  23. url: '/build/output.bib',
  24. type: 'bib',
  25. },
  26. {
  27. path: 'example.txt',
  28. build: '123',
  29. url: '/build/example.txt',
  30. type: 'txt',
  31. },
  32. {
  33. path: 'output.log',
  34. build: '123',
  35. url: '/build/output.log',
  36. type: 'log',
  37. },
  38. {
  39. path: 'output.blg',
  40. build: '123',
  41. url: '/build/output.blg',
  42. type: 'blg',
  43. },
  44. ]
  45. const mockCompile = () =>
  46. fetchMock.post('express:/project/:projectId/compile', {
  47. body: {
  48. status: 'success',
  49. clsiServerId: 'foo',
  50. compileGroup: 'priority',
  51. pdfDownloadDomain: '',
  52. outputFiles,
  53. },
  54. })
  55. const mockCompileError = status =>
  56. fetchMock.post('express:/project/:projectId/compile', {
  57. body: {
  58. status,
  59. clsiServerId: 'foo',
  60. compileGroup: 'priority',
  61. },
  62. })
  63. const mockValidationProblems = validationProblems =>
  64. fetchMock.post('express:/project/:projectId/compile', {
  65. body: {
  66. status: 'validation-problems',
  67. validationProblems,
  68. clsiServerId: 'foo',
  69. compileGroup: 'priority',
  70. },
  71. })
  72. const mockClearCache = () =>
  73. fetchMock.delete('express:/project/:projectId/output', 204)
  74. const defaultFileResponses = {
  75. '/build/output.blg': 'This is BibTeX, Version 4.0', // FIXME
  76. '/build/output.log': `
  77. The LaTeX compiler output
  78. * With a lot of details
  79. Wrapped in an HTML <pre> element with
  80. preformatted text which is to be presented exactly
  81. as written in the HTML file
  82. (whitespace included™)
  83. The text is typically rendered using a non-proportional ("monospace") font.
  84. LaTeX Font Info: External font \`cmex10' loaded for size
  85. (Font) <7> on input line 18.
  86. LaTeX Font Info: External font \`cmex10' loaded for size
  87. (Font) <5> on input line 18.
  88. ! Undefined control sequence.
  89. <recently read> \\Zlpha
  90. main.tex, line 23
  91. `,
  92. }
  93. const mockBuildFile = (responses = defaultFileResponses) =>
  94. fetchMock.get('express:/build/:file', (_url, options, request) => {
  95. const url = new URL(_url, 'https://example.com')
  96. if (url.pathname in responses) {
  97. return responses[url.pathname]
  98. }
  99. return 404
  100. })
  101. const storeAndFireEvent = (key, value) => {
  102. localStorage.setItem(key, value)
  103. fireEvent(window, new StorageEvent('storage', { key }))
  104. }
  105. const scope = {
  106. settings: {
  107. syntaxValidation: false,
  108. },
  109. editor: {
  110. sharejs_doc: {
  111. doc_id: 'test-doc',
  112. getSnapshot: () => 'some doc content',
  113. },
  114. },
  115. }
  116. describe('<PdfPreview/>', function () {
  117. var clock
  118. beforeEach(function () {
  119. clock = sinon.useFakeTimers({
  120. shouldAdvanceTime: true,
  121. now: Date.now(),
  122. })
  123. // xhrMock.setup()
  124. })
  125. afterEach(function () {
  126. clock.runAll()
  127. clock.restore()
  128. // xhrMock.teardown()
  129. fetchMock.reset()
  130. localStorage.clear()
  131. sinon.restore()
  132. })
  133. it('renders the PDF preview', async function () {
  134. mockCompile()
  135. mockBuildFile()
  136. renderWithEditorContext(<PdfPreview />, { scope })
  137. // wait for "compile on load" to finish
  138. await screen.findByRole('button', { name: 'Compiling…' })
  139. await screen.findByRole('button', { name: 'Recompile' })
  140. })
  141. it('runs a compile when the Recompile button is pressed', async function () {
  142. mockCompile()
  143. mockBuildFile()
  144. renderWithEditorContext(<PdfPreview />, { scope })
  145. // wait for "compile on load" to finish
  146. await screen.findByRole('button', { name: 'Compiling…' })
  147. await screen.findByRole('button', { name: 'Recompile' })
  148. // press the Recompile button => compile
  149. const button = screen.getByRole('button', { name: 'Recompile' })
  150. button.click()
  151. await screen.findByRole('button', { name: 'Compiling…' })
  152. await screen.findByRole('button', { name: 'Recompile' })
  153. expect(fetchMock.calls()).to.have.length(6)
  154. })
  155. it('runs a compile on doc change if autocompile is enabled', async function () {
  156. mockCompile()
  157. mockBuildFile()
  158. renderWithEditorContext(<PdfPreview />, { scope })
  159. // wait for "compile on load" to finish
  160. await screen.findByRole('button', { name: 'Compiling…' })
  161. await screen.findByRole('button', { name: 'Recompile' })
  162. // switch on auto compile
  163. storeAndFireEvent('autocompile_enabled:project123', true)
  164. // fire a doc:changed event => compile
  165. fireEvent(window, new CustomEvent('doc:changed'))
  166. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  167. await screen.findByRole('button', { name: 'Compiling…' })
  168. await screen.findByRole('button', { name: 'Recompile' })
  169. expect(fetchMock.calls()).to.have.length(6)
  170. })
  171. it('does not run a compile on doc change if autocompile is disabled', async function () {
  172. mockCompile()
  173. mockBuildFile()
  174. renderWithEditorContext(<PdfPreview />, { scope })
  175. // wait for "compile on load" to finish
  176. await screen.findByRole('button', { name: 'Compiling…' })
  177. await screen.findByRole('button', { name: 'Recompile' })
  178. // make sure auto compile is switched off
  179. storeAndFireEvent('autocompile_enabled:project123', false)
  180. // fire a doc:changed event => no compile
  181. fireEvent(window, new CustomEvent('doc:changed'))
  182. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  183. screen.getByRole('button', { name: 'Recompile' })
  184. expect(fetchMock.calls()).to.have.length(3)
  185. })
  186. it('does not run a compile on doc change if autocompile is blocked by syntax check', async function () {
  187. mockCompile()
  188. mockBuildFile()
  189. renderWithEditorContext(<PdfPreview />, {
  190. scope: {
  191. ...scope,
  192. 'settings.syntaxValidation': true, // enable linting in the editor
  193. hasLintingError: true, // mock a linting error
  194. },
  195. })
  196. // wait for "compile on load" to finish
  197. await screen.findByRole('button', { name: 'Compiling…' })
  198. await screen.findByRole('button', { name: 'Recompile' })
  199. // switch on auto compile and syntax checking
  200. storeAndFireEvent('autocompile_enabled:project123', true)
  201. storeAndFireEvent('stop_on_validation_error:project123', true)
  202. // fire a doc:changed event => no compile
  203. fireEvent(window, new CustomEvent('doc:changed'))
  204. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  205. screen.getByRole('button', { name: 'Recompile' })
  206. await screen.findByText('Code check failed')
  207. expect(fetchMock.calls()).to.have.length(3)
  208. })
  209. it('displays an error message if there was a compile error', async function () {
  210. const compileErrorStatuses = {
  211. 'clear-cache':
  212. 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  213. 'clsi-maintenance':
  214. 'The compile servers are down for maintenance, and will be back shortly.',
  215. 'compile-in-progress':
  216. 'A previous compile is still running. Please wait a minute and try compiling again.',
  217. exited: 'Server Error',
  218. failure: 'No PDF',
  219. generic: 'Server Error',
  220. 'project-too-large': 'Project too large',
  221. 'rate-limited': 'Compile rate limit hit',
  222. terminated: 'Compilation cancelled',
  223. timedout: 'Timed out',
  224. 'too-recently-compiled':
  225. 'This project was compiled very recently, so this compile has been skipped.',
  226. unavailable:
  227. 'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
  228. foo:
  229. 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  230. }
  231. for (const [status, message] of Object.entries(compileErrorStatuses)) {
  232. cleanup()
  233. fetchMock.restore()
  234. mockCompileError(status)
  235. renderWithEditorContext(<PdfPreview />, { scope })
  236. // wait for "compile on load" to finish
  237. await screen.findByRole('button', { name: 'Compiling…' })
  238. await screen.findByRole('button', { name: 'Recompile' })
  239. screen.getByText(message)
  240. }
  241. })
  242. it('displays expandable raw logs', async function () {
  243. mockCompile()
  244. mockBuildFile()
  245. // pretend that the content is large enough to trigger a "collapse"
  246. // (in jsdom these values are always zero)
  247. sinon.stub(HTMLElement.prototype, 'scrollHeight').value(500)
  248. sinon.stub(HTMLElement.prototype, 'scrollWidth').value(500)
  249. renderWithEditorContext(<PdfPreview />, { scope })
  250. // wait for "compile on load" to finish
  251. await screen.findByRole('button', { name: 'Compiling…' })
  252. await screen.findByRole('button', { name: 'Recompile' })
  253. const logsButton = screen.getByRole('button', { name: 'View logs' })
  254. logsButton.click()
  255. await screen.findByRole('button', { name: 'View PDF' })
  256. // expand the log
  257. const expandButton = screen.getByRole('button', { name: 'Expand' })
  258. expandButton.click()
  259. // collapse the log
  260. const collapseButton = screen.getByRole('button', { name: 'Collapse' })
  261. collapseButton.click()
  262. })
  263. it('displays error messages if there were validation problems', async function () {
  264. const validationProblems = {
  265. sizeCheck: {
  266. resources: [
  267. { path: 'foo/bar', kbSize: 76221 },
  268. { path: 'bar/baz', kbSize: 2342 },
  269. ],
  270. },
  271. mainFile: true,
  272. conflictedPaths: [
  273. {
  274. path: 'foo/bar',
  275. },
  276. {
  277. path: 'foo/baz',
  278. },
  279. ],
  280. }
  281. mockValidationProblems(validationProblems)
  282. renderWithEditorContext(<PdfPreview />, { scope })
  283. // wait for "compile on load" to finish
  284. await screen.findByRole('button', { name: 'Compiling…' })
  285. await screen.findByRole('button', { name: 'Recompile' })
  286. screen.getByText('Project too large')
  287. screen.getByText('Unknown main document')
  288. screen.getByText('Conflicting Paths Found')
  289. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  290. expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
  291. })
  292. it('sends a clear cache request when the button is pressed', async function () {
  293. mockCompile()
  294. mockBuildFile()
  295. mockClearCache()
  296. renderWithEditorContext(<PdfPreview />, { scope })
  297. // wait for "compile on load" to finish
  298. await screen.findByRole('button', { name: 'Compiling…' })
  299. await screen.findByRole('button', { name: 'Recompile' })
  300. const logsButton = screen.getByRole('button', {
  301. name: 'View logs',
  302. })
  303. logsButton.click()
  304. const clearCacheButton = await screen.findByRole('button', {
  305. name: 'Clear cached files',
  306. })
  307. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  308. // click the button
  309. clearCacheButton.click()
  310. expect(clearCacheButton.hasAttribute('disabled')).to.be.true
  311. await waitFor(() => {
  312. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  313. })
  314. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  315. expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
  316. })
  317. it('handle "recompile from scratch"', async function () {
  318. mockCompile()
  319. mockBuildFile()
  320. mockClearCache()
  321. renderWithEditorContext(<PdfPreview />, { scope })
  322. // wait for "compile on load" to finish
  323. await screen.findByRole('button', { name: 'Compiling…' })
  324. await screen.findByRole('button', { name: 'Recompile' })
  325. // show the logs UI
  326. const logsButton = screen.getByRole('button', {
  327. name: 'View logs',
  328. })
  329. logsButton.click()
  330. const clearCacheButton = await screen.findByRole('button', {
  331. name: 'Clear cached files',
  332. })
  333. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  334. const recompileFromScratch = screen.getByRole('menuitem', {
  335. name: 'Recompile from scratch',
  336. hidden: true,
  337. })
  338. recompileFromScratch.click()
  339. expect(clearCacheButton.hasAttribute('disabled')).to.be.true
  340. // wait for compile to finish
  341. await screen.findByRole('button', { name: 'Compiling…' })
  342. await screen.findByRole('button', { name: 'Recompile' })
  343. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  344. expect(fetchMock.called('express:/project/:projectId/output')).to.be.true
  345. expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
  346. })
  347. })