pdf-preview.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. import localStorage from '../../../../frontend/js/infrastructure/local-storage'
  2. import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview'
  3. import { EditorProviders } from '../../helpers/editor-providers'
  4. import { mockScope } from './scope'
  5. import {
  6. IdeLayout,
  7. IdeView,
  8. useLayoutContext,
  9. } from '../../../../frontend/js/shared/context/layout-context'
  10. import { FC, useEffect } from 'react'
  11. const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
  12. localStorage.setItem(key, value)
  13. win.dispatchEvent(new StorageEvent('storage', { key }))
  14. }
  15. const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
  16. layout,
  17. view,
  18. }) => {
  19. const { changeLayout } = useLayoutContext()
  20. useEffect(() => {
  21. changeLayout(layout, view)
  22. }, [changeLayout, layout, view])
  23. return null
  24. }
  25. describe('<PdfPreview/>', function () {
  26. beforeEach(function () {
  27. window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
  28. window.metaAttributesCache.set(
  29. 'ol-compilesUserContentDomain',
  30. 'https://compiles-user.dev-overleaf.com'
  31. )
  32. cy.interceptEvents()
  33. })
  34. it('renders the PDF preview', function () {
  35. window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
  36. cy.interceptCompile('compile')
  37. const scope = mockScope()
  38. cy.mount(
  39. <EditorProviders scope={scope}>
  40. <div className="pdf-viewer">
  41. <PdfPreview />
  42. </div>
  43. </EditorProviders>
  44. )
  45. // wait for "compile on load" to finish
  46. cy.waitForCompile({ pdf: true })
  47. cy.findByRole('button', { name: 'Recompile' })
  48. })
  49. it('runs a compile when the Recompile button is pressed', function () {
  50. cy.interceptCompile()
  51. const scope = mockScope()
  52. cy.mount(
  53. <EditorProviders scope={scope}>
  54. <div className="pdf-viewer">
  55. <PdfPreview />
  56. </div>
  57. </EditorProviders>
  58. )
  59. // press the Recompile button => compile
  60. cy.findByRole('button', { name: 'Recompile' }).click()
  61. // wait for compile to finish
  62. cy.waitForCompile({ pdf: true })
  63. cy.contains('Your Paper')
  64. })
  65. it('runs a compile on `pdf:recompile` event', function () {
  66. cy.interceptCompile()
  67. const scope = mockScope()
  68. cy.mount(
  69. <EditorProviders scope={scope}>
  70. <div className="pdf-viewer">
  71. <PdfPreview />
  72. </div>
  73. </EditorProviders>
  74. )
  75. cy.window().then(win => {
  76. win.dispatchEvent(new CustomEvent('pdf:recompile'))
  77. })
  78. // wait for compile to finish
  79. cy.waitForCompile({ pdf: true })
  80. cy.contains('Your Paper')
  81. })
  82. it('does not compile while compiling', function () {
  83. let counter = 0
  84. cy.interceptDeferredCompile(() => counter++).then(
  85. resolveDeferredCompile => {
  86. const scope = mockScope()
  87. cy.mount(
  88. <EditorProviders scope={scope}>
  89. <div className="pdf-viewer">
  90. <PdfPreview />
  91. </div>
  92. </EditorProviders>
  93. )
  94. // start compiling
  95. cy.findByRole('button', { name: 'Recompile' }).click()
  96. cy.findByRole('button', { name: 'Compiling…' }).then(() => {
  97. // trigger a recompile
  98. cy.window().then(win => {
  99. win.dispatchEvent(new CustomEvent('pdf:recompile'))
  100. })
  101. // finish the original compile
  102. resolveDeferredCompile()
  103. // wait for the original compile to finish
  104. cy.waitForCompile().then(() => {
  105. // NOTE: difficult to assert that a second request won't be sent, at some point
  106. expect(counter).to.equal(1)
  107. })
  108. })
  109. }
  110. )
  111. })
  112. it('disables compile button while compile is running', function () {
  113. cy.interceptDeferredCompile().then(resolveDeferredCompile => {
  114. const scope = mockScope()
  115. cy.mount(
  116. <EditorProviders scope={scope}>
  117. <div className="pdf-viewer">
  118. <PdfPreview />
  119. </div>
  120. </EditorProviders>
  121. )
  122. cy.findByRole('button', { name: 'Recompile' }).click()
  123. cy.findByRole('button', { name: 'Compiling…' })
  124. .should('be.disabled')
  125. .then(resolveDeferredCompile)
  126. cy.waitForCompile()
  127. cy.findByRole('button', { name: 'Recompile' }).should('not.be.disabled')
  128. })
  129. })
  130. it('runs a compile on doc change if autocompile is enabled', function () {
  131. cy.interceptCompile()
  132. const scope = mockScope()
  133. cy.mount(
  134. <EditorProviders scope={scope}>
  135. <div className="pdf-viewer">
  136. <PdfPreview />
  137. </div>
  138. </EditorProviders>
  139. )
  140. cy.window().then(win => {
  141. cy.clock()
  142. // switch on auto compile
  143. storeAndFireEvent(win, 'autocompile_enabled:project123', true)
  144. // fire a doc:changed event => compile
  145. win.dispatchEvent(new CustomEvent('doc:changed'))
  146. // wait enough time for the compile to start
  147. cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
  148. cy.clock().invoke('restore')
  149. })
  150. // wait for compile to finish
  151. cy.waitForCompile({ pdf: true })
  152. cy.findByRole('button', { name: 'Recompile' })
  153. })
  154. it('does not run a compile on doc change if autocompile is disabled', function () {
  155. cy.interceptCompile()
  156. const scope = mockScope()
  157. cy.mount(
  158. <EditorProviders scope={scope}>
  159. <div className="pdf-viewer">
  160. <PdfPreview />
  161. </div>
  162. </EditorProviders>
  163. )
  164. cy.window().then(win => {
  165. cy.clock()
  166. // make sure auto compile is switched off
  167. storeAndFireEvent(win, 'autocompile_enabled:project123', false)
  168. // fire a doc:changed event => no compile
  169. win.dispatchEvent(new CustomEvent('doc:changed'))
  170. // wait enough time for the compile to start
  171. cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
  172. cy.clock().invoke('restore')
  173. })
  174. // NOTE: difficult to assert that a request hasn't been sent
  175. cy.findByRole('button', { name: 'Recompile' })
  176. })
  177. it('does not run a compile on doc change if autocompile is blocked by syntax check', function () {
  178. cy.interceptCompile()
  179. const scope = mockScope()
  180. // enable linting in the editor
  181. const userSettings = { syntaxValidation: true }
  182. // mock a linting error
  183. scope.hasLintingError = true
  184. cy.mount(
  185. <EditorProviders scope={scope} userSettings={userSettings}>
  186. <div className="pdf-viewer">
  187. <PdfPreview />
  188. </div>
  189. </EditorProviders>
  190. )
  191. cy.window().then(win => {
  192. cy.clock()
  193. // switch on auto compile
  194. storeAndFireEvent(win, 'autocompile_enabled:project123', true)
  195. // switch on syntax checking
  196. storeAndFireEvent(win, 'stop_on_validation_error', true)
  197. // fire a doc:changed event => no compile
  198. win.dispatchEvent(new CustomEvent('doc:changed'))
  199. // wait enough time for the compile to start
  200. cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
  201. cy.clock().invoke('restore')
  202. })
  203. // NOTE: difficult to assert that a request hasn't been sent
  204. cy.findByRole('button', { name: 'Recompile' })
  205. cy.findByText('Code check failed')
  206. })
  207. it('does not run a compile on doc change if the PDF preview is not open', function () {
  208. cy.interceptCompile()
  209. const scope = mockScope()
  210. cy.mount(
  211. <EditorProviders scope={scope}>
  212. <Layout layout="flat" view="editor" />
  213. <div className="pdf-viewer">
  214. <PdfPreview />
  215. </div>
  216. </EditorProviders>
  217. )
  218. cy.window().then(win => {
  219. cy.clock()
  220. // switch on auto compile
  221. storeAndFireEvent(win, 'autocompile_enabled:project123', true)
  222. // fire a doc:changed event => compile
  223. win.dispatchEvent(new CustomEvent('doc:changed'))
  224. // wait enough time for the compile to start
  225. cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
  226. cy.clock().invoke('restore')
  227. })
  228. // NOTE: difficult to assert that a request hasn't been sent
  229. cy.findByRole('button', { name: 'Recompile' })
  230. })
  231. describe('error messages', function () {
  232. const compileErrorStatuses = {
  233. 'clear-cache':
  234. 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  235. 'clsi-maintenance':
  236. 'The compile servers are down for maintenance, and will be back shortly.',
  237. 'compile-in-progress':
  238. 'A previous compile is still running. Please wait a minute and try compiling again.',
  239. exited: 'Server Error',
  240. failure: 'No PDF',
  241. generic: 'Server Error',
  242. 'project-too-large': 'Project too large',
  243. 'rate-limited': 'Compile rate limit hit',
  244. terminated: 'Compilation cancelled',
  245. timedout: 'Timed out',
  246. 'too-recently-compiled':
  247. 'This project was compiled very recently, so this compile has been skipped.',
  248. unavailable:
  249. 'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
  250. foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
  251. }
  252. for (const [status, message] of Object.entries(compileErrorStatuses)) {
  253. it(`displays error message for '${status}' status`, function () {
  254. cy.intercept('POST', '/project/*/compile*', {
  255. body: {
  256. status,
  257. clsiServerId: 'foo',
  258. compileGroup: 'priority',
  259. },
  260. }).as('compile')
  261. const scope = mockScope()
  262. cy.mount(
  263. <EditorProviders scope={scope}>
  264. <div className="pdf-viewer">
  265. <PdfPreview />
  266. </div>
  267. </EditorProviders>
  268. )
  269. cy.findByRole('button', { name: 'Recompile' }).click()
  270. cy.wait('@compile')
  271. cy.findByText(message)
  272. })
  273. }
  274. })
  275. it('displays expandable raw logs', function () {
  276. cy.interceptCompile()
  277. const scope = mockScope()
  278. cy.mount(
  279. <EditorProviders scope={scope}>
  280. <div className="pdf-viewer">
  281. <PdfPreview />
  282. </div>
  283. </EditorProviders>
  284. )
  285. cy.findByRole('button', { name: 'Recompile' }).click()
  286. cy.waitForCompile({ pdf: true })
  287. cy.findByRole('button', { name: 'View logs' }).click()
  288. cy.findByRole('button', { name: 'View PDF' })
  289. cy.findByRole('button', { name: 'Expand' }).click()
  290. cy.findByRole('button', { name: 'Collapse' }).click()
  291. })
  292. it('displays error messages if there were validation problems', function () {
  293. const validationProblems = {
  294. sizeCheck: {
  295. resources: [
  296. { path: 'foo/bar', kbSize: 76221 },
  297. { path: 'bar/baz', kbSize: 2342 },
  298. ],
  299. },
  300. mainFile: true,
  301. conflictedPaths: [
  302. {
  303. path: 'foo/bar',
  304. },
  305. {
  306. path: 'foo/baz',
  307. },
  308. ],
  309. }
  310. cy.intercept('POST', '/project/*/compile*', {
  311. body: {
  312. status: 'validation-problems',
  313. validationProblems,
  314. clsiServerId: 'foo',
  315. compileGroup: 'priority',
  316. },
  317. }).as('compile')
  318. const scope = mockScope()
  319. cy.mount(
  320. <EditorProviders scope={scope}>
  321. <div className="pdf-viewer">
  322. <PdfPreview />
  323. </div>
  324. </EditorProviders>
  325. )
  326. cy.findByRole('button', { name: 'Recompile' }).click()
  327. cy.wait('@compile')
  328. cy.findByText('Project too large')
  329. cy.findByText('Unknown main document')
  330. cy.findByText('Conflicting Paths Found')
  331. })
  332. describe('clear cache', function () {
  333. it('sends a clear cache request when the button is pressed', function () {
  334. cy.interceptCompile()
  335. const scope = mockScope()
  336. cy.mount(
  337. <EditorProviders scope={scope}>
  338. <div className="pdf-viewer">
  339. <PdfPreview />
  340. </div>
  341. </EditorProviders>
  342. )
  343. cy.findByRole('button', { name: 'Recompile' }).click()
  344. cy.waitForCompile({ pdf: true })
  345. cy.findByRole('button', { name: 'View logs' }).click()
  346. cy.findByRole('button', { name: 'Clear cached files' }).should(
  347. 'not.be.disabled'
  348. )
  349. cy.intercept('DELETE', '/project/*/output*', {
  350. statusCode: 204,
  351. delay: 100,
  352. }).as('clear-cache')
  353. // click the button
  354. cy.findByRole('button', { name: 'Clear cached files' }).click()
  355. cy.findByRole('button', { name: 'Clear cached files' }).should(
  356. 'be.disabled'
  357. )
  358. cy.wait('@clear-cache')
  359. cy.findByRole('button', { name: 'Clear cached files' }).should(
  360. 'not.be.disabled'
  361. )
  362. })
  363. it('handle "recompile from scratch"', function () {
  364. cy.interceptCompile()
  365. const scope = mockScope()
  366. cy.mount(
  367. <EditorProviders scope={scope}>
  368. <div className="pdf-viewer">
  369. <PdfPreview />
  370. </div>
  371. </EditorProviders>
  372. )
  373. cy.findByRole('button', { name: 'Recompile' }).click()
  374. cy.waitForCompile({ pdf: true })
  375. cy.interceptCompile('recompile')
  376. cy.intercept('DELETE', '/project/*/output*', {
  377. statusCode: 204,
  378. delay: 100,
  379. }).as('clear-cache')
  380. // show the logs UI
  381. cy.findByRole('button', { name: 'View logs' }).click()
  382. cy.findByRole('button', { name: 'Clear cached files' }).should(
  383. 'not.be.disabled'
  384. )
  385. // TODO: open the menu?
  386. cy.findByRole('menuitem', {
  387. name: 'Recompile from scratch',
  388. hidden: true,
  389. }).trigger('click', { force: true })
  390. cy.findByRole('button', { name: 'Clear cached files' }).should(
  391. 'be.disabled'
  392. )
  393. cy.findByRole('button', { name: 'Compiling…' })
  394. cy.wait('@clear-cache')
  395. // wait for recompile from scratch to finish
  396. cy.waitForCompile({ pdf: true, prefix: 'recompile' })
  397. cy.findByRole('button', { name: 'Recompile' })
  398. })
  399. })
  400. describe('invalid URLs and broken PDFs', function () {
  401. it('shows an error for an invalid URL', function () {
  402. cy.interceptCompile()
  403. cy.intercept('/build/*/output.pdf*', {
  404. statusCode: 500,
  405. body: {
  406. message: 'something awful happened',
  407. code: 'AWFUL_ERROR',
  408. },
  409. }).as('compile-pdf-error')
  410. const scope = mockScope()
  411. cy.mount(
  412. <EditorProviders scope={scope}>
  413. <div className="pdf-viewer">
  414. <PdfPreview />
  415. </div>
  416. </EditorProviders>
  417. )
  418. cy.findByRole('button', { name: 'Recompile' }).click()
  419. cy.waitForCompile()
  420. cy.wait('@compile-pdf-error')
  421. cy.contains('Something went wrong while rendering this PDF.')
  422. cy.contains(
  423. 'Please try recompiling the project from scratch, and if that doesn’t help, follow our troubleshooting guide.'
  424. )
  425. cy.findByLabelText('Page 1').should('not.exist')
  426. })
  427. it('shows an error for a corrupt PDF', function () {
  428. cy.interceptCompile()
  429. cy.intercept('/build/*/output.pdf*', {
  430. fixture: 'build/output-corrupt.pdf,null',
  431. }).as('compile-pdf-corrupt')
  432. const scope = mockScope()
  433. cy.mount(
  434. <EditorProviders scope={scope}>
  435. <div className="pdf-viewer">
  436. <PdfPreview />
  437. </div>
  438. </EditorProviders>
  439. )
  440. cy.findByRole('button', { name: 'Recompile' }).click()
  441. cy.waitForCompile()
  442. cy.wait('@compile-pdf-corrupt')
  443. cy.contains('Something went wrong while rendering this PDF.')
  444. cy.contains(
  445. 'Please try recompiling the project from scratch, and if that doesn’t help, follow our troubleshooting guide.'
  446. )
  447. cy.findByLabelText('Page 1').should('not.exist')
  448. })
  449. })
  450. describe('human readable logs', function () {
  451. it('shows human readable hint for undefined reference errors', function () {
  452. cy.interceptCompile()
  453. cy.intercept('/build/*/output.log*', {
  454. fixture: 'build/output-human-readable.log',
  455. }).as('compile-log')
  456. const scope = mockScope()
  457. cy.mount(
  458. <EditorProviders scope={scope}>
  459. <div className="pdf-viewer">
  460. <PdfPreview />
  461. </div>
  462. </EditorProviders>
  463. )
  464. cy.findByRole('button', { name: 'Recompile' }).click()
  465. cy.waitForCompile()
  466. cy.findByRole('button', { name: 'View logs' }).click()
  467. cy.findByText(
  468. "Reference `intorduction' on page 1 undefined on input line 11."
  469. )
  470. cy.findByText(
  471. "Reference `section1' on page 1 undefined on input line 13."
  472. )
  473. cy.findByText('There were undefined references.')
  474. cy.findAllByText(
  475. /You have referenced something which has not yet been labelled/
  476. ).should('have.length', 3)
  477. })
  478. it('does not show human readable hint when no undefined reference errors', function () {
  479. cy.interceptCompile()
  480. cy.intercept('/build/*/output.log?*', {
  481. fixture: 'build/output-undefined-references.log',
  482. }).as('compile-log')
  483. const scope = mockScope()
  484. cy.mount(
  485. <EditorProviders scope={scope}>
  486. <div className="pdf-viewer">
  487. <PdfPreview />
  488. </div>
  489. </EditorProviders>
  490. )
  491. cy.findByRole('button', { name: 'Recompile' }).click()
  492. cy.waitForCompile()
  493. cy.findByRole('button', { name: 'View logs' }).click()
  494. cy.findByText(
  495. "Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right."
  496. )
  497. cy.findByText(
  498. /You have referenced something which has not yet been labelled/
  499. ).should('not.exist')
  500. })
  501. })
  502. })