pdf-preview.test.js 15 KB

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