pdf-preview.test.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  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. import nock from 'nock'
  8. import {
  9. corruptPDF,
  10. defaultFileResponses,
  11. mockBuildFile,
  12. mockClearCache,
  13. mockCompile,
  14. mockCompileError,
  15. mockValidationProblems,
  16. mockValidPdf,
  17. } from '../utils/mock-compile'
  18. const mockDelayed = fn => {
  19. let _resolve = null
  20. const delayPromise = new Promise((resolve, reject) => {
  21. _resolve = resolve
  22. })
  23. fn(delayPromise)
  24. return _resolve
  25. }
  26. const storeAndFireEvent = (key, value) => {
  27. localStorage.setItem(key, value)
  28. fireEvent(window, new StorageEvent('storage', { key }))
  29. }
  30. const scope = {
  31. settings: {
  32. syntaxValidation: false,
  33. },
  34. editor: {
  35. sharejs_doc: {
  36. doc_id: 'test-doc',
  37. getSnapshot: () => 'some doc content',
  38. },
  39. },
  40. }
  41. describe('<PdfPreview/>', function () {
  42. let clock
  43. beforeEach(function () {
  44. clock = sinon.useFakeTimers({
  45. shouldAdvanceTime: true,
  46. now: Date.now(),
  47. })
  48. nock.cleanAll()
  49. })
  50. afterEach(function () {
  51. clock.runAll()
  52. clock.restore()
  53. fetchMock.reset()
  54. localStorage.clear()
  55. sinon.restore()
  56. })
  57. it('renders the PDF preview', async function () {
  58. mockCompile()
  59. mockBuildFile()
  60. mockValidPdf()
  61. renderWithEditorContext(<PdfPreview />, { scope })
  62. // wait for "compile on load" to finish
  63. await screen.findByRole('button', { name: 'Compiling…' })
  64. await screen.findByRole('button', { name: 'Recompile' })
  65. })
  66. it('runs a compile when the Recompile button is pressed', async function () {
  67. mockCompile()
  68. mockBuildFile()
  69. mockValidPdf()
  70. renderWithEditorContext(<PdfPreview />, { scope })
  71. // wait for "compile on load" to finish
  72. await screen.findByRole('button', { name: 'Compiling…' })
  73. await screen.findByRole('button', { name: 'Recompile' })
  74. mockValidPdf()
  75. // press the Recompile button => compile
  76. const button = screen.getByRole('button', { name: 'Recompile' })
  77. button.click()
  78. await screen.findByRole('button', { name: 'Compiling…' })
  79. await screen.findByRole('button', { name: 'Recompile' })
  80. expect(fetchMock.calls()).to.have.length(6)
  81. })
  82. it('runs a compile on `pdf:recompile` event', async function () {
  83. mockCompile()
  84. mockBuildFile()
  85. mockValidPdf()
  86. renderWithEditorContext(<PdfPreview />, { scope })
  87. // wait for "compile on load" to finish
  88. await screen.findByRole('button', { name: 'Compiling…' })
  89. await screen.findByRole('button', { name: 'Recompile' })
  90. mockValidPdf()
  91. fireEvent(window, new CustomEvent('pdf:recompile'))
  92. await screen.findByRole('button', { name: 'Compiling…' })
  93. await screen.findByRole('button', { name: 'Recompile' })
  94. expect(fetchMock.calls()).to.have.length(6)
  95. })
  96. it('does not compile while compiling', async function () {
  97. mockDelayed(mockCompile)
  98. renderWithEditorContext(<PdfPreview />, { scope })
  99. // trigger compiles while "compile on load" is running
  100. await screen.findByRole('button', { name: 'Compiling…' })
  101. fireEvent(window, new CustomEvent('pdf:recompile'))
  102. expect(fetchMock.calls()).to.have.length(1)
  103. })
  104. it('disables compile button while compile is running', async function () {
  105. mockCompile()
  106. mockBuildFile()
  107. mockValidPdf()
  108. renderWithEditorContext(<PdfPreview />, { scope })
  109. let button = screen.getByRole('button', { name: 'Compiling…' })
  110. expect(button.hasAttribute('disabled')).to.be.true
  111. button = await screen.findByRole('button', { name: 'Recompile' })
  112. expect(button.hasAttribute('disabled')).to.be.false
  113. })
  114. it('runs a compile on doc change if autocompile is enabled', async function () {
  115. mockCompile()
  116. mockBuildFile()
  117. mockValidPdf()
  118. renderWithEditorContext(<PdfPreview />, { scope })
  119. // wait for "compile on load" to finish
  120. await screen.findByRole('button', { name: 'Compiling…' })
  121. await screen.findByRole('button', { name: 'Recompile' })
  122. // switch on auto compile
  123. storeAndFireEvent('autocompile_enabled:project123', true)
  124. mockValidPdf()
  125. // fire a doc:changed event => compile
  126. fireEvent(window, new CustomEvent('doc:changed'))
  127. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  128. await screen.findByRole('button', { name: 'Compiling…' })
  129. await screen.findByRole('button', { name: 'Recompile' })
  130. expect(fetchMock.calls()).to.have.length(6)
  131. })
  132. it('does not run a compile on doc change if autocompile is disabled', async function () {
  133. mockCompile()
  134. mockBuildFile()
  135. mockValidPdf()
  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. // make sure auto compile is switched off
  141. storeAndFireEvent('autocompile_enabled:project123', false)
  142. // fire a doc:changed event => no compile
  143. fireEvent(window, new CustomEvent('doc:changed'))
  144. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  145. screen.getByRole('button', { name: 'Recompile' })
  146. expect(fetchMock.calls()).to.have.length(3)
  147. })
  148. it('does not run a compile on doc change if autocompile is blocked by syntax check', async function () {
  149. mockCompile()
  150. mockBuildFile()
  151. mockValidPdf()
  152. renderWithEditorContext(<PdfPreview />, {
  153. scope: {
  154. ...scope,
  155. 'settings.syntaxValidation': true, // enable linting in the editor
  156. hasLintingError: true, // mock a linting error
  157. },
  158. })
  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 and syntax checking
  163. storeAndFireEvent('autocompile_enabled:project123', true)
  164. storeAndFireEvent('stop_on_validation_error:project123', true)
  165. // fire a doc:changed event => no compile
  166. fireEvent(window, new CustomEvent('doc:changed'))
  167. clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
  168. screen.getByRole('button', { name: 'Recompile' })
  169. await screen.findByText('Code check failed')
  170. expect(fetchMock.calls()).to.have.length(3)
  171. })
  172. describe('displays error messages', function () {
  173. const compileErrorStatuses = {
  174. 'clear-cache':
  175. 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  176. 'clsi-maintenance':
  177. 'The compile servers are down for maintenance, and will be back shortly.',
  178. 'compile-in-progress':
  179. 'A previous compile is still running. Please wait a minute and try compiling again.',
  180. exited: 'Server Error',
  181. failure: 'No PDF',
  182. generic: 'Server Error',
  183. 'project-too-large': 'Project too large',
  184. 'rate-limited': 'Compile rate limit hit',
  185. terminated: 'Compilation cancelled',
  186. timedout: 'Timed out',
  187. 'too-recently-compiled':
  188. 'This project was compiled very recently, so this compile has been skipped.',
  189. unavailable:
  190. 'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
  191. foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  192. }
  193. for (const [status, message] of Object.entries(compileErrorStatuses)) {
  194. it(`displays error message for '${status}' status`, async function () {
  195. cleanup()
  196. fetchMock.restore()
  197. mockCompileError(status)
  198. renderWithEditorContext(<PdfPreview />, { scope })
  199. // wait for "compile on load" to finish
  200. await screen.findByRole('button', { name: 'Compiling…' })
  201. await screen.findByRole('button', { name: 'Recompile' })
  202. screen.getByText(message)
  203. })
  204. }
  205. })
  206. it('displays expandable raw logs', async function () {
  207. mockCompile()
  208. mockBuildFile()
  209. mockValidPdf()
  210. // pretend that the content is large enough to trigger a "collapse"
  211. // (in jsdom these values are always zero)
  212. sinon.stub(HTMLElement.prototype, 'scrollHeight').value(500)
  213. sinon.stub(HTMLElement.prototype, 'scrollWidth').value(500)
  214. renderWithEditorContext(<PdfPreview />, { scope })
  215. // wait for "compile on load" to finish
  216. await screen.findByRole('button', { name: 'Compiling…' })
  217. await screen.findByRole('button', { name: 'Recompile' })
  218. const logsButton = screen.getByRole('button', { name: 'View logs' })
  219. logsButton.click()
  220. await screen.findByRole('button', { name: 'View PDF' })
  221. // expand the log
  222. const [expandButton] = screen.getAllByRole('button', { name: 'Expand' })
  223. expandButton.click()
  224. // collapse the log
  225. const [collapseButton] = screen.getAllByRole('button', { name: 'Collapse' })
  226. collapseButton.click()
  227. })
  228. it('displays error messages if there were validation problems', async function () {
  229. const validationProblems = {
  230. sizeCheck: {
  231. resources: [
  232. { path: 'foo/bar', kbSize: 76221 },
  233. { path: 'bar/baz', kbSize: 2342 },
  234. ],
  235. },
  236. mainFile: true,
  237. conflictedPaths: [
  238. {
  239. path: 'foo/bar',
  240. },
  241. {
  242. path: 'foo/baz',
  243. },
  244. ],
  245. }
  246. mockValidationProblems(validationProblems)
  247. renderWithEditorContext(<PdfPreview />, { scope })
  248. // wait for "compile on load" to finish
  249. await screen.findByRole('button', { name: 'Compiling…' })
  250. await screen.findByRole('button', { name: 'Recompile' })
  251. screen.getByText('Project too large')
  252. screen.getByText('Unknown main document')
  253. screen.getByText('Conflicting Paths Found')
  254. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  255. expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be
  256. .false // TODO: actual path
  257. })
  258. it('sends a clear cache request when the button is pressed', async function () {
  259. mockCompile()
  260. mockBuildFile()
  261. mockValidPdf()
  262. renderWithEditorContext(<PdfPreview />, { scope })
  263. // wait for "compile on load" to finish
  264. await screen.findByRole('button', { name: 'Compiling…' })
  265. await screen.findByRole('button', { name: 'Recompile' })
  266. const logsButton = screen.getByRole('button', {
  267. name: 'View logs',
  268. })
  269. logsButton.click()
  270. const clearCacheButton = await screen.findByRole('button', {
  271. name: 'Clear cached files',
  272. })
  273. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  274. mockClearCache()
  275. // click the button
  276. clearCacheButton.click()
  277. expect(clearCacheButton.hasAttribute('disabled')).to.be.true
  278. await waitFor(() => {
  279. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  280. })
  281. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  282. expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be.true // TODO: actual path
  283. })
  284. it('handle "recompile from scratch"', async function () {
  285. mockCompile()
  286. mockBuildFile()
  287. mockValidPdf()
  288. renderWithEditorContext(<PdfPreview />, { scope })
  289. // wait for "compile on load" to finish
  290. await screen.findByRole('button', { name: 'Compiling…' })
  291. await screen.findByRole('button', { name: 'Recompile' })
  292. // show the logs UI
  293. const logsButton = screen.getByRole('button', {
  294. name: 'View logs',
  295. })
  296. logsButton.click()
  297. const clearCacheButton = await screen.findByRole('button', {
  298. name: 'Clear cached files',
  299. })
  300. expect(clearCacheButton.hasAttribute('disabled')).to.be.false
  301. mockValidPdf()
  302. mockClearCache()
  303. const recompileFromScratch = screen.getByRole('menuitem', {
  304. name: 'Recompile from scratch',
  305. hidden: true,
  306. })
  307. recompileFromScratch.click()
  308. expect(clearCacheButton.hasAttribute('disabled')).to.be.true
  309. // wait for compile to finish
  310. await screen.findByRole('button', { name: 'Compiling…' })
  311. await screen.findByRole('button', { name: 'Recompile' })
  312. expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
  313. expect(fetchMock.called('express:/project/:projectId/output')).to.be.true
  314. expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be.true // TODO: actual path
  315. })
  316. it('shows an error for an invalid URL', async function () {
  317. mockCompile()
  318. mockBuildFile()
  319. nock('https://clsi.test-overleaf.com')
  320. .get(/^\/build\/output.pdf/)
  321. .replyWithError({
  322. message: 'something awful happened',
  323. code: 'AWFUL_ERROR',
  324. })
  325. renderWithEditorContext(<PdfPreview />, { scope })
  326. await screen.findByText('Something went wrong while rendering this PDF.')
  327. expect(screen.queryByLabelText('Page 1')).to.not.exist
  328. expect(nock.isDone()).to.be.true
  329. })
  330. it('shows an error for a corrupt PDF', async function () {
  331. mockCompile()
  332. mockBuildFile()
  333. nock('https://clsi.test-overleaf.com')
  334. .get(/^\/build\/output.pdf/)
  335. .replyWithFile(200, corruptPDF)
  336. renderWithEditorContext(<PdfPreview />, { scope })
  337. await screen.findByText('Something went wrong while rendering this PDF.')
  338. expect(screen.queryByLabelText('Page 1')).to.not.exist
  339. expect(nock.isDone()).to.be.true
  340. })
  341. describe('human readable logs', function () {
  342. it('shows human readable hint for undefined reference errors', async function () {
  343. mockCompile()
  344. mockBuildFile({
  345. ...defaultFileResponses,
  346. '/build/output.log': `
  347. log This is pdfTeX, Version 3.14159265-2.6-1.40.21 (TeX Live 2020) (preloaded format=pdflatex 2020.9.10) 8 FEB 2022 16:27
  348. entering extended mode
  349. \\write18 enabled.
  350. %&-line parsing enabled.
  351. **main.tex
  352. (./main.tex
  353. LaTeX2e <2020-02-02> patch level 5
  354. LaTeX Warning: Reference \`intorduction' on page 1 undefined on input line 11.
  355. LaTeX Warning: Reference \`section1' on page 1 undefined on input line 13.
  356. [1
  357. {/usr/local/texlive/2020/texmf-var/fonts/map/pdftex/updmap/pdftex.map}] (/compi
  358. le/output.aux)
  359. LaTeX Warning: There were undefined references.
  360. )
  361. `,
  362. })
  363. mockValidPdf()
  364. renderWithEditorContext(<PdfPreview />, { scope })
  365. await screen.findByText(
  366. "Reference `intorduction' on page 1 undefined on input line 11."
  367. )
  368. await screen.findByText(
  369. "Reference `section1' on page 1 undefined on input line 13."
  370. )
  371. await screen.findByText('There were undefined references.')
  372. const hints = await screen.findAllByText(
  373. /You have referenced something which has not yet been labelled/
  374. )
  375. expect(hints.length).to.equal(3)
  376. })
  377. it('idoes not show human readable hint for undefined reference errors', async function () {
  378. mockCompile()
  379. mockBuildFile({
  380. ...defaultFileResponses,
  381. '/build/output.log': `
  382. Package rerunfilecheck Info: File \`output.out' has not changed.
  383. (rerunfilecheck) Checksum: 339DB29951BB30436898BC39909EA4FA;11265.
  384. Package rerunfilecheck Warning: File \`output.brf' has changed.
  385. (rerunfilecheck) Rerun to get bibliographical references right.
  386. Package rerunfilecheck Info: Checksums for \`output.brf':
  387. (rerunfilecheck) Before: D41D8CD98F00B204E9800998ECF8427E;0
  388. (rerunfilecheck) After: DF3260FAD3828D54C5E4E9337E97F7AF;4841.
  389. )
  390. `,
  391. })
  392. mockValidPdf()
  393. renderWithEditorContext(<PdfPreview />, { scope })
  394. await screen.findByText(
  395. /Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right./
  396. )
  397. expect(
  398. screen.queryByText(
  399. /You have referenced something which has not yet been labelled/
  400. )
  401. ).to.not.exist
  402. })
  403. })
  404. })