pdf-synctex-controls.test.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import PdfSynctexControls from '../../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
  2. import { renderWithEditorContext } from '../../../helpers/render-with-context'
  3. import sysendTestHelper from '../../../helpers/sysend'
  4. import { cloneDeep } from 'lodash'
  5. import fetchMock from 'fetch-mock'
  6. import { fireEvent, screen, waitFor } from '@testing-library/react'
  7. import fs from 'fs'
  8. import path from 'path'
  9. import { expect } from 'chai'
  10. import { useDetachCompileContext as useCompileContext } from '../../../../../frontend/js/shared/context/detach-compile-context'
  11. import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context'
  12. import { useEffect } from 'react'
  13. const examplePDF = path.join(__dirname, '../fixtures/test-example.pdf')
  14. const scope = {
  15. settings: {
  16. syntaxValidation: false,
  17. pdfViewer: 'pdfjs',
  18. },
  19. editor: {
  20. sharejs_doc: {
  21. doc_id: 'test-doc',
  22. getSnapshot: () => 'some doc content',
  23. },
  24. },
  25. }
  26. const outputFiles = [
  27. {
  28. path: 'output.pdf',
  29. build: '123',
  30. url: '/build/output.pdf',
  31. type: 'pdf',
  32. },
  33. {
  34. path: 'output.log',
  35. build: '123',
  36. url: '/build/output.log',
  37. type: 'log',
  38. },
  39. ]
  40. const mockCompile = () =>
  41. fetchMock.post('express:/project/:projectId/compile', {
  42. body: {
  43. status: 'success',
  44. clsiServerId: 'foo',
  45. compileGroup: 'standard',
  46. pdfDownloadDomain: 'https://clsi.test-overleaf.com',
  47. outputFiles: cloneDeep(outputFiles),
  48. },
  49. })
  50. const fileResponses = {
  51. '/build/output.pdf': () => fs.createReadStream(examplePDF),
  52. '/build/output.log': '',
  53. }
  54. const mockBuildFile = () =>
  55. fetchMock.get('begin:https://clsi.test-overleaf.com/', _url => {
  56. const url = new URL(_url, 'https://clsi.test-overleaf.com')
  57. if (url.pathname in fileResponses) {
  58. return fileResponses[url.pathname]
  59. }
  60. return 404
  61. })
  62. const mockHighlights = [
  63. {
  64. page: 1,
  65. h: 85.03936,
  66. v: 509.999878,
  67. width: 441.921265,
  68. height: 8.855677,
  69. },
  70. {
  71. page: 1,
  72. h: 85.03936,
  73. v: 486.089539,
  74. width: 441.921265,
  75. height: 8.855677,
  76. },
  77. ]
  78. const mockPosition = {
  79. page: 1,
  80. offset: { top: 10, left: 10 },
  81. pageSize: { height: 500, width: 500 },
  82. }
  83. const mockSelectedEntities = [{ type: 'doc' }]
  84. const mockSynctex = () =>
  85. fetchMock
  86. .get('express:/project/:projectId/sync/code', () => {
  87. return { pdf: cloneDeep(mockHighlights) }
  88. })
  89. .get('express:/project/:projectId/sync/pdf', () => {
  90. return { code: [{ file: 'main.tex', line: 100 }] }
  91. })
  92. const WithPosition = ({ mockPosition }) => {
  93. const { setPosition } = useCompileContext()
  94. // mock PDF scroll position update
  95. useEffect(() => {
  96. setPosition(mockPosition)
  97. }, [mockPosition, setPosition])
  98. return null
  99. }
  100. const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
  101. const { setSelectedEntities } = useFileTreeData()
  102. useEffect(() => {
  103. setSelectedEntities(mockSelectedEntities)
  104. }, [mockSelectedEntities, setSelectedEntities])
  105. return null
  106. }
  107. describe('<PdfSynctexControls/>', function () {
  108. beforeEach(function () {
  109. window.metaAttributesCache = new Map()
  110. fetchMock.restore()
  111. mockCompile()
  112. mockSynctex()
  113. mockBuildFile()
  114. })
  115. afterEach(function () {
  116. window.metaAttributesCache = new Map()
  117. fetchMock.restore()
  118. })
  119. it('handles clicks on sync buttons', async function () {
  120. const { container } = renderWithEditorContext(
  121. <>
  122. <WithPosition mockPosition={mockPosition} />
  123. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  124. <PdfSynctexControls />
  125. </>,
  126. { scope }
  127. )
  128. const syncToPdfButton = await screen.findByRole('button', {
  129. name: 'Go to code location in PDF',
  130. })
  131. const syncToCodeButton = await screen.findByRole('button', {
  132. name: /Go to PDF location in code/,
  133. })
  134. expect(container.querySelectorAll('.synctex-control-icon').length).to.equal(
  135. 2
  136. )
  137. // mock editor cursor position update
  138. fireEvent(
  139. window,
  140. new CustomEvent('cursor:editor:update', {
  141. detail: { row: 100, column: 10 },
  142. })
  143. )
  144. fireEvent.click(syncToPdfButton)
  145. expect(syncToPdfButton.disabled).to.be.true
  146. await waitFor(() => {
  147. expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
  148. .true
  149. })
  150. fireEvent.click(syncToCodeButton)
  151. expect(syncToCodeButton.disabled).to.be.true
  152. await waitFor(() => {
  153. expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
  154. .true
  155. })
  156. })
  157. it('disables button when multiple entities are selected', async function () {
  158. renderWithEditorContext(
  159. <>
  160. <WithPosition mockPosition={mockPosition} />
  161. <WithSelectedEntities
  162. mockSelectedEntities={[{ type: 'doc' }, { type: 'doc' }]}
  163. />
  164. <PdfSynctexControls />
  165. </>,
  166. { scope }
  167. )
  168. const syncToPdfButton = await screen.findByRole('button', {
  169. name: 'Go to code location in PDF',
  170. })
  171. expect(syncToPdfButton.disabled).to.be.true
  172. const syncToCodeButton = await screen.findByRole('button', {
  173. name: /Go to PDF location in code/,
  174. })
  175. expect(syncToCodeButton.disabled).to.be.true
  176. })
  177. it('disables button when a file is selected', async function () {
  178. renderWithEditorContext(
  179. <>
  180. <WithPosition mockPosition={mockPosition} />
  181. <WithSelectedEntities mockSelectedEntities={[{ type: 'file' }]} />
  182. <PdfSynctexControls />
  183. </>,
  184. { scope }
  185. )
  186. const syncToPdfButton = await screen.findByRole('button', {
  187. name: 'Go to code location in PDF',
  188. })
  189. expect(syncToPdfButton.disabled).to.be.true
  190. const syncToCodeButton = await screen.findByRole('button', {
  191. name: /Go to PDF location in code/,
  192. })
  193. expect(syncToCodeButton.disabled).to.be.true
  194. })
  195. describe('with detacher role', async function () {
  196. beforeEach(function () {
  197. window.metaAttributesCache.set('ol-detachRole', 'detacher')
  198. })
  199. it('does not have go to PDF location button nor arrow icon', async function () {
  200. const { container } = renderWithEditorContext(
  201. <>
  202. <WithPosition mockPosition={mockPosition} />
  203. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  204. <PdfSynctexControls />
  205. </>,
  206. { scope }
  207. )
  208. expect(
  209. await screen.queryByRole('button', {
  210. name: 'Go to PDF location in code',
  211. })
  212. ).to.not.exist
  213. expect(container.querySelector('.synctex-control-icon')).to.not.exist
  214. })
  215. it('send set highlights action', async function () {
  216. renderWithEditorContext(
  217. <>
  218. <WithPosition mockPosition={mockPosition} />
  219. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  220. <PdfSynctexControls />
  221. </>,
  222. { scope }
  223. )
  224. sysendTestHelper.resetHistory()
  225. const syncToPdfButton = await screen.findByRole('button', {
  226. name: 'Go to code location in PDF',
  227. })
  228. // mock editor cursor position update
  229. fireEvent(
  230. window,
  231. new CustomEvent('cursor:editor:update', {
  232. detail: { row: 100, column: 10 },
  233. })
  234. )
  235. expect(syncToPdfButton.disabled).to.be.false
  236. fireEvent.click(syncToPdfButton)
  237. expect(syncToPdfButton.disabled).to.be.true
  238. await waitFor(() => {
  239. expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
  240. .true
  241. })
  242. // synctex is called locally and the result are broadcast for the detached
  243. // tab
  244. expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
  245. role: 'detacher',
  246. event: 'action-setHighlights',
  247. data: { args: [mockHighlights] },
  248. })
  249. })
  250. it('reacts to sync to code action', async function () {
  251. renderWithEditorContext(
  252. <>
  253. <WithPosition mockPosition={mockPosition} />
  254. <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
  255. <PdfSynctexControls />
  256. </>,
  257. { scope }
  258. )
  259. await waitFor(() => {
  260. expect(fetchMock.called('express:/project/:projectId/compile')).to.be
  261. .true
  262. })
  263. sysendTestHelper.receiveMessage({
  264. role: 'detached',
  265. event: 'action-sync-to-code',
  266. data: {
  267. args: [mockPosition],
  268. },
  269. })
  270. await waitFor(() => {
  271. expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
  272. .true
  273. })
  274. })
  275. })
  276. describe('with detached role', async function () {
  277. beforeEach(function () {
  278. window.metaAttributesCache.set('ol-detachRole', 'detached')
  279. })
  280. it('does not have go to code location button nor arrow icon', async function () {
  281. const { container } = renderWithEditorContext(
  282. <>
  283. <WithPosition mockPosition={mockPosition} />
  284. <PdfSynctexControls />
  285. </>,
  286. { scope }
  287. )
  288. expect(
  289. await screen.queryByRole('button', {
  290. name: 'Go to code location in PDF',
  291. })
  292. ).to.not.exist
  293. expect(container.querySelector('.synctex-control-icon')).to.not.exist
  294. })
  295. it('send go to code line action', async function () {
  296. const { container } = renderWithEditorContext(
  297. <>
  298. <WithPosition mockPosition={mockPosition} />
  299. <PdfSynctexControls />
  300. </>,
  301. { scope }
  302. )
  303. const syncToCodeButton = await screen.findByRole('button', {
  304. name: /Go to PDF location in code/,
  305. })
  306. expect(syncToCodeButton.disabled).to.be.true
  307. sysendTestHelper.receiveMessage({
  308. role: 'detached',
  309. event: 'state-has-single-selected-doc',
  310. data: { value: true },
  311. })
  312. expect(syncToCodeButton.disabled).to.be.false
  313. sysendTestHelper.resetHistory()
  314. fireEvent.click(syncToCodeButton)
  315. // the button is only disabled when the state is updated via sysend
  316. expect(syncToCodeButton.disabled).to.be.false
  317. expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
  318. 0
  319. )
  320. expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
  321. role: 'detached',
  322. event: 'action-sync-to-code',
  323. data: {
  324. args: [mockPosition, 72],
  325. },
  326. })
  327. })
  328. it('update inflight state', async function () {
  329. const { container } = renderWithEditorContext(
  330. <>
  331. <WithPosition mockPosition={mockPosition} />
  332. <PdfSynctexControls />
  333. </>,
  334. { scope }
  335. )
  336. sysendTestHelper.receiveMessage({
  337. role: 'detached',
  338. event: 'state-has-single-selected-doc',
  339. data: { value: true },
  340. })
  341. const syncToCodeButton = await screen.findByRole('button', {
  342. name: /Go to PDF location in code/,
  343. })
  344. expect(syncToCodeButton.disabled).to.be.false
  345. expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
  346. 0
  347. )
  348. sysendTestHelper.receiveMessage({
  349. role: 'detacher',
  350. event: 'state-sync-to-code-inflight',
  351. data: { value: true },
  352. })
  353. expect(syncToCodeButton.disabled).to.be.true
  354. expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
  355. 1
  356. )
  357. sysendTestHelper.receiveMessage({
  358. role: 'detacher',
  359. event: 'state-sync-to-code-inflight',
  360. data: { value: false },
  361. })
  362. expect(syncToCodeButton.disabled).to.be.false
  363. expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
  364. 0
  365. )
  366. })
  367. })
  368. })